Merge branch 'dev' into feature/list-your-product-ui

This commit is contained in:
MTG2000
2022-09-15 13:25:06 +03:00
74 changed files with 2691 additions and 792 deletions

View File

@@ -77,7 +77,6 @@ function App() {
}, []);
return <div id="app" className='w-full'>
<Helmet>
<title >makers.bolt.fun</title>

View File

@@ -1,66 +0,0 @@
import { useToggle } from "@react-hookz/web";
import React from "react";
import { FileDrop } from "react-file-drop";
export default function DropInput({
value: files,
onChange,
emptyContent,
draggingContent,
hasFilesContent,
height,
multiple = false,
allowedType = "*",
classes = {
base: "",
idle: "",
dragging: "",
},
}) {
const [isDragging, toggleDrag] = useToggle(false);
const fileInputRef = React.useRef(null);
const onAddFiles = (_files) => {
onChange(_files);
// do something with your files...
};
const uploadClick = () => {
fileInputRef.current.click();
};
const status = isDragging ? "dragging" : files ? "has-files" : "empty";
return (
<div
style={{
height: height + "px",
}}
>
<FileDrop
onDrop={(files) => onAddFiles(files)}
onTargetClick={uploadClick}
onFrameDragEnter={() => toggleDrag(true)}
onFrameDragLeave={() => toggleDrag(false)}
onFrameDrop={() => toggleDrag(false)}
className={`h-full cursor-pointer`}
targetClassName={`h-full ${classes.base} ${
status === "empty" && classes.idle
}`}
draggingOverFrameClassName={`${classes.dragging}`}
>
{status === "dragging" && draggingContent}
{status === "empty" && emptyContent}
{status === "has-files" && hasFilesContent}
</FileDrop>
<input
onChange={(e) => onAddFiles(e.target.files)}
ref={fileInputRef}
type="file"
className="hidden"
multiple={multiple}
accept={allowedType}
/>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { BsImages } from 'react-icons/bs';
import Button from 'src/Components/Button/Button';
import FilesInput from './FilesInput';
import FileDropInput from './FilesDropInput';
export default {
title: 'Shared/Inputs/Files Input',
component: FilesInput,
} as ComponentMeta<typeof FilesInput>;
const Template: ComponentStory<typeof FilesInput> = (args) => <FilesInput {...args} />
export const DefaultButton = Template.bind({});
DefaultButton.args = {
}
export const CustomizedButton = Template.bind({});
CustomizedButton.args = {
multiple: true,
uploadBtn: <Button color='primary'><span className="align-middle">Drop Images</span> <BsImages className='ml-12 scale-125' /></Button>
}
const DropTemplate: ComponentStory<typeof FileDropInput> = (args) => <div className="max-w-[500px]"><FileDropInput {...args as any} /></div>
export const DropZoneInput = DropTemplate.bind({});
DropZoneInput.args = {
onChange: console.log,
}

View File

@@ -1,75 +0,0 @@
import { useMemo } from "react";
import { MdClose } from "react-icons/md";
import IconButton from "src/Components/IconButton/IconButton";
interface Props {
file: File | string,
onRemove?: () => void
}
function getFileType(file: File | string) {
if (typeof file === 'string') {
if (/^http[^?]*.(jpg|jpeg|gif|png|tiff|bmp)(\?(.*))?$/gmi.test(file))
return 'image'
if (/\.(pdf|doc|docx)$/.test(file))
return 'document';
return 'unknown'
}
else {
if (file['type'].split('/')[0] === 'image')
return 'image'
return 'unknown'
}
}
type ThumbnailFile = {
name: string;
src: string;
type: ReturnType<typeof getFileType>
}
function processFile(file: Props['file']): ThumbnailFile {
const fileType = getFileType(file);
if (typeof file === 'string') return { name: file, src: file, type: fileType };
return {
name: file.name,
src: URL.createObjectURL(file),
type: fileType
};
}
export default function FileThumbnail({ file: f, onRemove }: Props) {
const file = useMemo(() => processFile(f), [f])
return (
<div className="bg-gray-100 rounded-8 p-12 shrink-0 flex gap-4 overflow-hidden">
<div className="w-[100px]">
<p className="text-body6 overflow-hidden overflow-ellipsis whitespace-nowrap">
{file.name}
</p>
<a
href={file.src}
target='_blank'
rel="noreferrer"
>
{
file.type === 'image' && <img src={file.src} alt={file.name} className="p-4 w-3/4 mx-auto max-h-full object-contain" />
}
</a>
</div>
<div className="w-32 shrink-0 self-start" >
<IconButton size="sm" className="hover:bg-gray-500" onClick={onRemove}>
<MdClose />
</IconButton>
</div>
</div>
)
}

View File

@@ -1,88 +0,0 @@
import { FaImage } from "react-icons/fa";
import { UnionToObjectKeys } from "src/utils/types/utils";
import DropInput from "./DropInput";
type Props = {
height?: number
multiple?: boolean;
value?: File[] | string[] | string;
max?: number;
onBlur?: () => void;
onChange?: (files: (File | string)[] | null) => void
uploadBtn?: JSX.Element
uploadText?: string;
allowedType?: 'images';
classes?: Partial<{
base: string,
idle: string,
dragging: string,
hasFiles: string
}>
}
const fileAccept: UnionToObjectKeys<Props, 'allowedType'> = {
images: ".png, .jpg, .jpeg"
} as const;
const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
const res = await fetch(url);
const contentType = res.headers.get('content-type') as string;
const blob = await res.blob()
const file = new File([blob], fileName, { contentType } as any)
return file
}
export default function FilesInput({
height = 200,
multiple,
value,
max = 3,
onBlur,
onChange,
allowedType = 'images',
classes,
...props
}: Props) {
const baseClasses = classes?.base ?? 'p-32 rounded-8 text-center flex flex-col justify-center items-center'
const idleClasses = classes?.idle ?? 'bg-primary-50 hover:bg-primary-25 border border-dashed border-primary-500 text-gray-800'
const draggingClasses = classes?.dragging ?? 'bg-primary-500 text-white'
return (
<DropInput
height={height}
emptyContent={defaultEmptyContent}
draggingContent={defaultDraggingContent}
hasFilesContent={defaultHasFilesContent}
value={value}
onChange={onChange}
multiple={multiple}
allowedType={fileAccept[allowedType]}
classes={{
base: baseClasses,
idle: idleClasses,
dragging: draggingClasses
}}
/>
)
}
const defaultEmptyContent = (
<>
<div>
<FaImage className="scale-150 mr-8 text-gray-400" />{" "}
<span className="align-middle">Drop your files here</span>
</div>
<p className="mt-4">
or <button className="hover:underline font-bold">Click to Upload</button>{" "}
</p>
</>
);
const defaultDraggingContent = <p className="font-bold text-body2">Drop your files here </p>;
const defaultHasFilesContent = (
<p className="font-bolder">Files Uploaded Successfully!!</p>
);

View File

@@ -1,136 +0,0 @@
import { createAction } from "@reduxjs/toolkit";
import React, { ChangeEvent, useCallback, useRef } from "react"
import { BsUpload } from "react-icons/bs";
import { FaImage } from "react-icons/fa";
import Button from "src/Components/Button/Button"
import { openModal } from "src/redux/features/modals.slice";
import { useAppDispatch } from "src/utils/hooks";
import { useReduxEffect } from "src/utils/hooks/useReduxEffect";
import { UnionToObjectKeys } from "src/utils/types/utils";
import FilesThumbnails from "./FilesThumbnails";
type Props = {
multiple?: boolean;
value?: File[] | string[] | string;
max?: number;
onBlur?: () => void;
onChange?: (files: (File | string)[] | null) => void
uploadBtn?: JSX.Element
uploadText?: string;
allowedType?: 'images';
}
const fileAccept: UnionToObjectKeys<Props, 'allowedType'> = {
images: ".png, .jpg, .jpeg"
} as const;
const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
const res = await fetch(url);
const contentType = res.headers.get('content-type') as string;
const blob = await res.blob()
const file = new File([blob], fileName, { contentType } as any)
return file
}
const INSERT_IMAGE_ACTION = createAction<{ src: string, alt?: string }>('COVER_IMAGE_INSERTED')({ src: '', alt: "" })
const FilesInput = React.forwardRef<any, Props>(({
multiple,
value,
max = 3,
onBlur,
onChange,
allowedType = 'images',
uploadText = 'Upload files',
...props
}, ref) => {
const dispatch = useAppDispatch();
const handleClick = () => {
// ref.current.click();
dispatch(openModal({
Modal: "InsertImageModal",
props: {
callbackAction: {
type: INSERT_IMAGE_ACTION.type,
payload: {
src: "",
alt: ""
}
}
}
}))
}
const onInsertImgUrl = useCallback(({ payload: { src, alt } }: typeof INSERT_IMAGE_ACTION) => {
if (typeof value === 'string')
onChange?.([value, src]);
else
onChange?.([...(value ?? []), src]);
}, [onChange, value])
useReduxEffect(onInsertImgUrl, INSERT_IMAGE_ACTION.type)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files && Array.from(e.target.files).slice(0, max);
if (typeof value === 'string')
onChange?.([value, ...(files ?? [])]);
else
onChange?.([...(value ?? []), ...(files ?? [])]);
}
const handleRemove = async (idx: number) => {
if (!value) return onChange?.([]);
if (typeof value === 'string')
onChange?.([]);
else {
let files = [...value]
files.splice(idx, 1);
//change all files urls to file objects
const filesConverted = await Promise.all(files.map(async file => {
if (typeof file === 'string') return await fileUrlToObject(file, "")
else return file;
}))
onChange?.(filesConverted);
}
}
const canUploadMore = multiple ?
!value || (value && value.length < max)
:
!value || value.length === 0
const uploadBtn = props.uploadBtn ?
React.cloneElement(props.uploadBtn, { onClick: handleClick })
:
<Button type='button' onClick={handleClick} ><span className="align-middle">{uploadText}</span> <FaImage className="ml-12 scale-125" /></Button>
return (
<>
<FilesThumbnails files={value} onRemove={handleRemove} />
{
canUploadMore &&
<>
{uploadBtn}
<input
ref={ref}
type="file"
onBlur={onBlur}
style={{ display: 'none' }}
multiple={multiple}
accept={fileAccept[allowedType]}
onChange={handleChange} />
</>
}
</>
)
})
export default FilesInput;

View File

@@ -1,29 +0,0 @@
import { useMemo } from 'react'
import FileThumbnail from './FileThumbnail';
interface Props {
files?: (File | string)[] | string;
onRemove?: (idx: number) => void
}
function processFiles(files: Props['files']) {
if (!files) return [];
if (typeof files === 'string') return [files];
return files;
}
export default function FilesThumbnails({ files, onRemove }: Props) {
const filesConverted = useMemo(() => processFiles(files), [files])
return (
<div className="flex gap-12 mb-12">
{
filesConverted.map((file, idx) => <FileThumbnail
key={idx}
file={file}
onRemove={() => onRemove?.(idx)} />)
}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import AvatarInput from './AvatarInput';
import { WrapFormController } from 'src/utils/storybook/decorators';
import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput';
export default {
title: 'Shared/Inputs/Files Inputs/Avatar ',
component: AvatarInput,
decorators: [
WrapFormController<{ avatar: ImageType | null }>({
logValues: true,
name: "avatar",
defaultValues: {
avatar: null
}
})]
} as ComponentMeta<typeof AvatarInput>;
const Template: ComponentStory<typeof AvatarInput> = (args, context) => {
return <AvatarInput {...context.controller} {...args} />
}
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,105 @@
import { motion } from 'framer-motion';
import React, { ComponentProps, useRef } from 'react'
import { AiOutlineCloudUpload } from 'react-icons/ai';
import { CgArrowsExchangeV } from 'react-icons/cg';
import { FiCamera } from 'react-icons/fi';
import { IoMdClose } from 'react-icons/io';
import { RotatingLines } from 'react-loader-spinner';
import { Nullable } from 'remirror';
import { useIsDraggingOnElement } from 'src/utils/hooks';
import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput'
type Value = ComponentProps<typeof SingleImageUploadInput>['value']
interface Props {
width?: number;
isRemovable?: boolean
value: Value;
onChange: (new_value: Nullable<Value>) => void
}
export default function AvatarInput(props: Props) {
const dropAreaRef = useRef<HTMLDivElement>(null!)
const isDragging = useIsDraggingOnElement({ ref: dropAreaRef });
return (
<div
style={{
width: props.width ?? 120,
}}
ref={dropAreaRef}
className='aspect-square rounded-full outline outline-2 outline-gray-200 overflow-hidden cursor-pointer '
>
<SingleImageUploadInput
value={props.value}
onChange={props.onChange}
wrapperClass='rounded-full bg-white h-full'
render={({ img, isUploading, isDraggingOnWindow }) =>
<div className="w-full h-full rounded-full relative group">
{!img &&
<div className='w-full h-full rounded-full bg-white hover:bg-gray-100 flex flex-col justify-center items-center'>
<p className="text-center text-gray-400 text-body2 mb-8"><FiCamera /></p>
<div className={`text-gray-400 text-center text-body5`}>
Add Image
</div>
</div>}
{img &&
<>
<img src={img.url} className='w-full h-full object-cover rounded-full' alt="" />
{!isUploading &&
<div className="flex flex-wrap gap-16 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ">
<button type='button' className='py-8 px-12 rounded-full bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body1'>
<CgArrowsExchangeV />
</button>
{props.isRemovable && <button type='button' className='py-8 px-12 rounded-full bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body1' onClick={(e) => { e.stopPropagation(); props.onChange(null) }}>
<IoMdClose />
</button>}
</div>
}
</>}
{isUploading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>
}
{isDraggingOnWindow &&
<div
className={
`absolute inset-0 ${isDragging ? 'bg-primary-600' : 'bg-primary-400'} bg-opacity-80 flex flex-col justify-center items-center text-white font-bold transition-transform`
}
>
<motion.div
initial={{ y: 0 }}
animate={
isDragging ? {
y: 5,
transition: {
duration: .4,
repeat: Infinity,
repeatType: 'mirror',
}
} : {
y: 0
}}
className='text-center text-body4'
>
<AiOutlineCloudUpload className="scale-150 text-body2" />
</motion.div>
</div>
}
</div>}
/>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/decorators';
import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput';
import CoverImageInput from './CoverImageInput';
export default {
title: 'Shared/Inputs/Files Inputs/Cover Image ',
component: CoverImageInput,
decorators: [
WrapFormController<{ thumbnail: ImageType | null }>({
logValues: true,
name: "thumbnail",
defaultValues: {
thumbnail: null
}
})]
} as ComponentMeta<typeof CoverImageInput>;
const Template: ComponentStory<typeof CoverImageInput> = (args, context) => {
return <div className="aspect-[5/2] md:aspect-[4/1] rounded-t-16 overflow-hidden">
<CoverImageInput {...context.controller} {...args} />
</div>
}
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,103 @@
import React, { ComponentProps, useEffect, useRef, useState } from 'react'
import { FaImage } from 'react-icons/fa';
import { CgArrowsExchangeV } from 'react-icons/cg';
import { IoMdClose } from 'react-icons/io';
import { RotatingLines } from 'react-loader-spinner';
import { Nullable } from 'remirror';
import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput'
import { motion } from 'framer-motion';
import { AiOutlineCloudUpload } from 'react-icons/ai';
import { useIsDraggingOnElement } from 'src/utils/hooks';
type Value = ComponentProps<typeof SingleImageUploadInput>['value']
interface Props {
value: Value;
rounded?: string;
onChange: (new_value: Nullable<Value>) => void
}
export default function CoverImageInput(props: Props) {
const dropAreaRef = useRef<HTMLDivElement>(null!)
const isDragging = useIsDraggingOnElement({ ref: dropAreaRef });
return (
<div
className='overflow-hidden cursor-pointer w-full h-full'
ref={dropAreaRef}
>
<SingleImageUploadInput
value={props.value}
onChange={props.onChange}
wrapperClass='h-full'
render={({ img, isUploading, isDraggingOnWindow }) =>
<div className="w-full h-full group relative ">
{!img && <div className='w-full h-full flex flex-col justify-center items-center bg-gray-500 outline outline-2 outline-gray-200'>
<p className="text-center text-gray-100 text-body1 md:text-h1 mb-8"><FaImage /></p>
<div className={`text-gray-100 text-center text-body4`}>
Drop a <span className="font-bold">COVER IMAGE</span> here or <br /> <span className="text-blue-300 underline">Click to browse</span>
</div>
</div>}
{img && <>
<img src={img.url} className={`w-full h-full ${props.rounded ?? 'rounded-12'} object-cover`} alt="" />
{!isUploading &&
<div className="flex flex-wrap gap-16 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ">
<button type='button' className='py-8 px-16 rounded-12 bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-h1'>
<CgArrowsExchangeV />
</button>
<button type='button' className='py-8 px-16 rounded-12 bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-h1' onClick={(e) => { e.stopPropagation(); props.onChange(null) }}>
<IoMdClose />
</button>
</div>
}
</>}
{isUploading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>
}
{isDraggingOnWindow &&
<div
className={
`absolute inset-0 ${isDragging ? 'bg-primary-600' : 'bg-primary-400'} bg-opacity-80 flex flex-col justify-center items-center text-white font-bold transition-transform`
}
>
<motion.div
initial={{ y: 0 }}
animate={
isDragging ? {
y: 5,
transition: {
duration: .4,
repeat: Infinity,
repeatType: 'mirror',
}
} : {
y: 0
}}
className='text-center text-body1'
>
<AiOutlineCloudUpload className="scale-150 text-h1 mb-16" />
<br />
Drop here to upload
</motion.div>
</div>
}
</div>}
/>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import FileUploadInput from './FileUploadInput';
export default {
title: 'Shared/Inputs/Files Inputs/Basic',
component: FileUploadInput,
} as ComponentMeta<typeof FileUploadInput>;
const Template: ComponentStory<typeof FileUploadInput> = (args) => <FileUploadInput {...args} />
export const DefaultButton = Template.bind({});
DefaultButton.args = {
}

View File

@@ -0,0 +1,141 @@
import Uploady, { useUploady, useRequestPreSend, UPLOADER_EVENTS, } from "@rpldy/uploady";
import { asUploadButton } from "@rpldy/upload-button";
import Button from "src/Components/Button/Button";
import { fetchUploadUrl } from "../fetch-upload-img-url";
import ImagePreviews from "./ImagePreviews";
import { FaImage } from "react-icons/fa";
import UploadDropZone from "@rpldy/upload-drop-zone";
import { forwardRef, useCallback } from "react";
import styles from './styles.module.scss'
import { MdFileUpload } from "react-icons/md";
import { AiOutlineCloudUpload } from "react-icons/ai";
import { motion } from "framer-motion";
interface Props {
url: string;
}
const UploadBtn = asUploadButton((props: any) => {
useRequestPreSend(async (data) => {
const filename = data.items?.[0].file.name ?? ''
const url = await fetchUploadUrl({ filename });
return {
options: {
destination: {
url
}
}
}
})
// const handleClick = async () => {
// // Make a request to get the url
// try {
// var bodyFormData = new FormData();
// bodyFormData.append('requireSignedURLs', "false");
// const res = await axios({
// url: 'https://cors-anywhere.herokuapp.com/https://api.cloudflare.com/client/v4/accounts/783da4f06e5fdb9012c0632959a6f5b3/images/v2/direct_upload',
// method: 'POST',
// data: bodyFormData,
// headers: {
// "Authorization": "Bearer Xx2-CdsTliYkq6Ayz-1GX4CZubdQVxMwOSDbajP0",
// }
// })
// uploady.upload(res.data.result.uploadUrl, {
// destination: res.data.result.uploadUrl
// })
// } catch (error) {
// console.log(error);
// }
// // make the request with the files
// // uploady.upload()
// }
return <Button {...props} color='primary'>
Upload Image <FaImage className="ml-8 scale-125 align-middle" />
</Button>
});
const DropZone = forwardRef<any, any>((props, ref) => {
const { onClick, ...buttonProps } = props;
useRequestPreSend(async (data) => {
const filename = data.items?.[0].file.name ?? ''
const url = await fetchUploadUrl({ filename });
return {
options: {
destination: {
url
}
}
}
})
const onZoneClick = useCallback(
(e: any) => {
if (onClick) {
onClick(e);
}
},
[onClick]
);
return <UploadDropZone
{...buttonProps}
ref={ref}
onDragOverClassName={styles.active}
extraProps={{ onClick: onZoneClick }}
className={`${styles.zone} border-2 w-full min-h-[200px] max-w-[600px] rounded-16 flex flex-col justify-center items-center text text-body3 border-dashed`}
>
<div className={`${styles.idle_content} text-gray-600`}>
Drop your <span className="font-bold uppercase">IMAGES</span> here or <button className="font-bold text-blue-400 underline">Click to browse</button>
</div>
<motion.div
animate={{
y: 5,
}}
transition={{
duration: .5,
repeat: Infinity,
repeatType: 'mirror'
}}
className={`${styles.active_content} text-white font-bold`}>
Drop it to upload <AiOutlineCloudUpload className="scale-150 text-body1 ml-16" />
</motion.div>
</UploadDropZone>
})
const DropZoneButton = asUploadButton(DropZone);
export default function FileUploadInput(props: Props) {
return (
<Uploady
multiple={true}
inputFieldName='file'
grouped={false}
listeners={{
[UPLOADER_EVENTS.ITEM_FINISH]: (item) => {
const { id, filename, variants } = item?.uploadResponse?.data?.result ?? {}
if (id) {
console.log(id, filename, variants);
}
}
}}
>
<DropZoneButton />
{/* <UploadBtn /> */}
<ImagePreviews />
</Uploady>
)
}

View File

@@ -0,0 +1,92 @@
import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview'
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady';
import React, { useState } from 'react'
import { RotatingLines } from 'react-loader-spinner';
export default function ImagePreviews() {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-16 mt-24">
<UploadPreview PreviewComponent={CustomImagePreview} rememberPreviousBatches />
</div>
)
}
function CustomImagePreview({ id, url }: PreviewComponentProps) {
const [progress, setProgress] = useState<number>(0);
const [itemState, setItemState] = useState<string>(STATES.PROGRESS);
useItemProgressListener(item => {
if (item.completed > progress) {
setProgress(() => item.completed);
if (item.completed === 100) {
setItemState(STATES.DONE)
} else {
setItemState(STATES.PROGRESS)
}
}
}, id);
useItemAbortListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemCancelListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemErrorListener(item => {
setItemState(STATES.ERROR);
}, id);
return <div className="aspect-video relative rounded-12 md:rounded-16 overflow-hidden border-2 border-gray-200">
<img src={url}
className={`
w-full h-full object-cover
${itemState === STATES.PROGRESS && 'opacity-50'}
`}
alt="" />
<div className="text-body5 absolute inset-0"
>
</div>
{itemState === STATES.PROGRESS &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>}
{itemState === STATES.ERROR &&
<div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
Failed...
</div>}
{itemState === STATES.CANCELLED &&
<div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
Cancelled
</div>}
</div>;
};
const STATES = {
PROGRESS: "PROGRESS",
DONE: "DONE",
CANCELLED: "CANCELLED",
ERROR: "ERROR"
};
const STATE_COLORS = {
[STATES.PROGRESS]: "#f4e4a4",
[STATES.DONE]: "#a5f7b3",
[STATES.CANCELLED]: "#f7cdcd",
[STATES.ERROR]: "#ee4c4c"
};

View File

@@ -0,0 +1,25 @@
.zone {
background-color: #f2f4f7;
border-color: #e4e7ec;
.active_content {
display: none;
}
.idle_content {
display: block;
}
&.active {
background-color: #b3a0ff;
border-color: #9e88ff;
.active_content {
display: block;
}
.idle_content {
display: none;
}
}
}

View File

@@ -0,0 +1,97 @@
import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview'
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady';
import { useState } from 'react'
import ScreenShotsThumbnail from './ScreenshotThumbnail'
export default function ImagePreviews() {
return (
<UploadPreview PreviewComponent={CustomImagePreview} rememberPreviousBatches />
)
}
function CustomImagePreview({ id, url }: PreviewComponentProps) {
const [progress, setProgress] = useState<number>(0);
const [itemState, setItemState] = useState<string>(STATES.PROGRESS);
const abortItem = useAbortItem();
useItemProgressListener(item => {
if (item.completed > progress) {
setProgress(() => item.completed);
if (item.completed === 100) {
setItemState(STATES.DONE)
} else {
setItemState(STATES.PROGRESS)
}
}
}, id);
useItemAbortListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemCancelListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemErrorListener(item => {
setItemState(STATES.ERROR);
}, id);
if (itemState === STATES.DONE || itemState === STATES.CANCELLED)
return null
return <ScreenShotsThumbnail
url={url}
isLoading={itemState === STATES.PROGRESS}
isError={itemState === STATES.ERROR}
onCancel={() => {
abortItem(id)
}}
/>
// return <div className="aspect-video relative rounded-12 md:rounded-16 overflow-hidden border-2 border-gray-200">
// <img src={url}
// className={`
// w-full h-full object-cover
// ${itemState === STATES.PROGRESS && 'opacity-50'}
// `}
// alt="" />
// <div className="text-body5 absolute inset-0"
// >
// </div>
// {itemState === STATES.PROGRESS &&
// <div
// className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
// >
// <RotatingLines
// strokeColor="#fff"
// strokeWidth="3"
// animationDuration="0.75"
// width="48"
// visible={true}
// />
// </div>}
// {itemState === STATES.ERROR &&
// <div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
// Failed...
// </div>}
// {itemState === STATES.CANCELLED &&
// <div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
// Cancelled
// </div>}
// </div>;
};
const STATES = {
PROGRESS: "PROGRESS",
DONE: "DONE",
CANCELLED: "CANCELLED",
ERROR: "ERROR"
};

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { FaTimes } from 'react-icons/fa';
import { RotatingLines } from 'react-loader-spinner';
interface Props {
url?: string,
isLoading?: boolean;
isError?: boolean;
onCancel?: () => void;
}
export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel }: Props) {
const isEmpty = !url;
return (
<div className={`
aspect-video relative rounded-16 md:rounded-14 overflow-hidden border-2 border-gray-200
${isEmpty && "border-dashed"}
`}>
{!isEmpty && <img src={url}
className={`
w-full h-full object-cover
${isLoading && 'opacity-50'}
`}
alt="" />}
<div className="text-body5 absolute inset-0"
>
</div>
{isLoading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>}
{isError &&
<div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
Failed...
</div>}
{!isEmpty &&
<button className="absolute bg-gray-900 hover:bg-opacity-100 bg-opacity-60 text-white rounded-full w-32 h-32 top-8 right-8" onClick={() => onCancel?.()}><FaTimes /></button>
}
</div>
)
}

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ScreenshotsInput, { ScreenshotType } from './ScreenshotsInput';
import { WrapForm, WrapFormController } from 'src/utils/storybook/decorators';
export default {
title: 'Shared/Inputs/Files Inputs/Screenshots',
component: ScreenshotsInput,
decorators: [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
logValues: true,
name: "screenshots",
defaultValues: {
screenshots: []
}
})]
} as ComponentMeta<typeof ScreenshotsInput>;
const Template: ComponentStory<typeof ScreenshotsInput> = (args, context) => {
return <ScreenshotsInput {...context.controller} {...args} />
}
export const Empty = Template.bind({});
Empty.args = {
}
export const WithValues = Template.bind({});
WithValues.decorators = [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
logValues: true,
name: "screenshots",
defaultValues: {
screenshots: [{
id: '123',
name: 'tree',
url: "https://picsum.photos/id/1021/800/800.jpg"
},
{
id: '555',
name: 'whatever',
url: "https://picsum.photos/id/600/800/800.jpg"
},]
}
}) as any
];
WithValues.args = {
}
export const Full = Template.bind({});
Full.decorators = [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
logValues: true,
name: "screenshots",
defaultValues: {
screenshots: [
{
id: '123',
name: 'tree',
url: "https://picsum.photos/id/1021/800/800.jpg"
},
{
id: '555',
name: 'whatever',
url: "https://picsum.photos/id/600/800/800.jpg"
},
{
id: '562',
name: 'Moon',
url: "https://picsum.photos/id/32/800/800.jpg"
},
{
id: '342',
name: 'Sun',
url: "https://picsum.photos/id/523/800/800.jpg"
},
]
}
}) as any
];
Full.args = {
}

View File

@@ -0,0 +1,151 @@
import Uploady, { useRequestPreSend, UPLOADER_EVENTS } from "@rpldy/uploady";
import { asUploadButton } from "@rpldy/upload-button";
// import { fetchUploadUrl } from "./fetch-upload-img-url";
import ImagePreviews from "./ImagePreviews";
import UploadDropZone from "@rpldy/upload-drop-zone";
import { forwardRef, useCallback, useState } from "react";
import styles from './styles.module.scss'
import { AiOutlineCloudUpload } from "react-icons/ai";
import { motion } from "framer-motion";
import { getMockSenderEnhancer } from "@rpldy/mock-sender";
import ScreenshotThumbnail from "./ScreenshotThumbnail";
import { FiCamera } from "react-icons/fi";
import { Control, Path, useController } from "react-hook-form";
const mockSenderEnhancer = getMockSenderEnhancer({
delay: 1500,
});
const MAX_UPLOAD_COUNT = 4 as const;
export interface ScreenshotType {
id: string,
name: string,
url: string;
}
interface Props {
value: ScreenshotType[],
onChange: (new_value: ScreenshotType[]) => void
}
export default function ScreenshotsInput(props: Props) {
const { value: uploadedFiles, onChange } = props;
const [uploadingCount, setUploadingCount] = useState(0)
const canUploadMore = uploadingCount + uploadedFiles.length < MAX_UPLOAD_COUNT;
const placeholdersCount = (MAX_UPLOAD_COUNT - (uploadingCount + uploadedFiles.length + 1));
return (
<Uploady
multiple={true}
inputFieldName='file'
grouped={false}
enhancer={mockSenderEnhancer}
listeners={{
[UPLOADER_EVENTS.BATCH_ADD]: (batch) => {
setUploadingCount(v => v + batch.items.length)
},
[UPLOADER_EVENTS.ITEM_FINALIZE]: () => setUploadingCount(v => v - 1),
[UPLOADER_EVENTS.ITEM_FINISH]: (item) => {
// Just for mocking purposes
const dataUrl = URL.createObjectURL(item.file);
const { id, filename, variants } = item?.uploadResponse?.data?.result ?? {
id: Math.random().toString(),
filename: item.file.name,
variants: [
"",
dataUrl
]
}
if (id) {
onChange([...uploadedFiles, { id, name: filename, url: variants[1] }].slice(-MAX_UPLOAD_COUNT))
}
}
}}
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-16 mt-24">
{canUploadMore && <DropZoneButton />}
{uploadedFiles.map(f => <ScreenshotThumbnail
key={f.id}
url={f.url}
onCancel={() => {
onChange(uploadedFiles.filter(file => file.id !== f.id))
}} />)}
<ImagePreviews />
{(placeholdersCount > 0) &&
Array(placeholdersCount).fill(0).map((_, idx) => <ScreenshotThumbnail key={idx} />)}
</div>
</Uploady>
)
}
const DropZone = forwardRef<any, any>((props, ref) => {
const { onClick, ...buttonProps } = props;
useRequestPreSend(async (data) => {
const filename = data.items?.[0].file.name ?? ''
// const url = await fetchUploadUrl({ filename });
return {
options: {
destination: {
url: "URL"
}
}
}
})
const onZoneClick = useCallback(
(e: any) => {
if (onClick) {
onClick(e);
}
},
[onClick]
);
return <UploadDropZone
{...buttonProps}
ref={ref}
onDragOverClassName={styles.active}
extraProps={{ onClick: onZoneClick }}
className={`${styles.zone} aspect-video relative rounded-16 md:rounded-14 overflow-hidden border-2 border-gray-200 flex flex-col justify-center items-center cursor-pointer border-dashed`}
>
<div className={styles.idle_content}>
<p className="text-center text-gray-400 text-body1 mb-8"><FiCamera /></p>
<div className={`text-gray-600 text-center text-body4`}>
<span className="text-blue-500 underline">Browse images</span> or <br /> <span className="text-blue-500">drop </span>
them here
</div>
</div>
<motion.div
animate={{
y: 5,
}}
transition={{
duration: .5,
repeat: Infinity,
repeatType: 'mirror'
}}
className={`${styles.active_content} text-white font-bold text-center`}>
Drop to upload <br /> <AiOutlineCloudUpload className="scale-150 text-body1 mt-16" />
</motion.div>
</UploadDropZone>
})
const DropZoneButton = asUploadButton(DropZone);

View File

@@ -0,0 +1,25 @@
.zone {
background-color: #f2f4f7;
border-color: #e4e7ec;
.active_content {
display: none;
}
.idle_content {
display: block;
}
&.active {
background-color: #b3a0ff;
border-color: #9e88ff;
.active_content {
display: block;
}
.idle_content {
display: none;
}
}
}

View File

@@ -0,0 +1,97 @@
import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview'
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady';
import { useState } from 'react'
import ScreenShotsThumbnail from './ScreenshotThumbnail'
export default function ImagePreviews() {
return (
<UploadPreview PreviewComponent={CustomImagePreview} rememberPreviousBatches />
)
}
function CustomImagePreview({ id, url }: PreviewComponentProps) {
const [progress, setProgress] = useState<number>(0);
const [itemState, setItemState] = useState<string>(STATES.PROGRESS);
const abortItem = useAbortItem();
useItemProgressListener(item => {
if (item.completed > progress) {
setProgress(() => item.completed);
if (item.completed === 100) {
setItemState(STATES.DONE)
} else {
setItemState(STATES.PROGRESS)
}
}
}, id);
useItemAbortListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemCancelListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemErrorListener(item => {
setItemState(STATES.ERROR);
}, id);
if (itemState === STATES.DONE || itemState === STATES.CANCELLED)
return null
return <ScreenShotsThumbnail
url={url}
isLoading={itemState === STATES.PROGRESS}
isError={itemState === STATES.ERROR}
onCancel={() => {
abortItem(id)
}}
/>
// return <div className="aspect-video relative rounded-12 md:rounded-16 overflow-hidden border-2 border-gray-200">
// <img src={url}
// className={`
// w-full h-full object-cover
// ${itemState === STATES.PROGRESS && 'opacity-50'}
// `}
// alt="" />
// <div className="text-body5 absolute inset-0"
// >
// </div>
// {itemState === STATES.PROGRESS &&
// <div
// className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
// >
// <RotatingLines
// strokeColor="#fff"
// strokeWidth="3"
// animationDuration="0.75"
// width="48"
// visible={true}
// />
// </div>}
// {itemState === STATES.ERROR &&
// <div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
// Failed...
// </div>}
// {itemState === STATES.CANCELLED &&
// <div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
// Cancelled
// </div>}
// </div>;
};
const STATES = {
PROGRESS: "PROGRESS",
DONE: "DONE",
CANCELLED: "CANCELLED",
ERROR: "ERROR"
};

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { FaTimes } from 'react-icons/fa';
import { RotatingLines } from 'react-loader-spinner';
interface Props {
url?: string,
isLoading?: boolean;
isError?: boolean;
onCancel?: () => void;
}
export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel }: Props) {
const isEmpty = !url;
return (
<div className={`
aspect-video relative rounded-16 md:rounded-14 overflow-hidden border-2 border-gray-200
${isEmpty && "border-dashed"}
`}>
{!isEmpty && <img src={url}
className={`
w-full h-full object-cover
${isLoading && 'opacity-50'}
`}
alt="" />}
<div className="text-body5 absolute inset-0"
>
</div>
{isLoading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>}
{isError &&
<div className="absolute inset-0 bg-red-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold">
Failed...
</div>}
{!isEmpty &&
<button className="absolute bg-gray-900 hover:bg-opacity-100 bg-opacity-60 text-white rounded-full w-32 h-32 top-8 right-8" onClick={() => onCancel?.()}><FaTimes /></button>
}
</div>
)
}

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import SingleImageUploadInput, { ImageType } from './SingleImageUploadInput';
import { WrapFormController } from 'src/utils/storybook/decorators';
import { RotatingLines } from 'react-loader-spinner';
import { FiCamera, } from 'react-icons/fi';
import { FaExchangeAlt, FaImage } from 'react-icons/fa';
export default {
title: 'Shared/Inputs/Files Inputs/Single Image Upload ',
component: SingleImageUploadInput,
decorators: [
WrapFormController<{ avatar: ImageType | null }>({
logValues: true,
name: "avatar",
defaultValues: {
avatar: null
}
})]
} as ComponentMeta<typeof SingleImageUploadInput>;
const Template: ComponentStory<typeof SingleImageUploadInput> = (args, context) => {
return <SingleImageUploadInput {...context.controller} {...args} />
}

View File

@@ -0,0 +1,141 @@
import Uploady, { useRequestPreSend, UPLOADER_EVENTS, useAbortAll } from "@rpldy/uploady";
import { asUploadButton } from "@rpldy/upload-button";
// import { fetchUploadUrl } from "./fetch-upload-img-url";
import UploadDropZone from "@rpldy/upload-drop-zone";
import { forwardRef, ReactElement, useCallback, useState } from "react";
import styles from './styles.module.scss'
import { getMockSenderEnhancer } from "@rpldy/mock-sender";
import { NotificationsService } from "src/services";
import { useIsDraggingOnElement } from 'src/utils/hooks';
import { fetchUploadImageUrl } from "src/api/uploading";
const mockSenderEnhancer = getMockSenderEnhancer({
delay: 1500,
});
export interface ImageType {
id?: string | null,
name?: string | null,
url: string;
}
type RenderPropArgs = {
isUploading?: boolean;
img: ImageType | null,
onAbort: () => void,
isDraggingOnWindow?: boolean
}
interface Props {
value: ImageType | null | undefined,
onChange: (new_value: ImageType | null) => void;
wrapperClass?: string;
render: (args: RenderPropArgs) => ReactElement;
}
export default function SingleImageUploadInput(props: Props) {
const { value, onChange, render } = props;
const [currentlyUploadingItem, setCurrentlyUploadingItem] = useState<ImageType | null>(null)
return (
<Uploady
accept="image/*"
inputFieldName='file'
grouped={false}
listeners={{
[UPLOADER_EVENTS.ITEM_START]: (item) => {
onChange(null)
setCurrentlyUploadingItem({
id: item.id,
url: URL.createObjectURL(item.file),
name: item.file.name,
})
},
[UPLOADER_EVENTS.ITEM_ERROR]: (item) => {
NotificationsService.error("An error happened while uploading. Please try again.")
},
[UPLOADER_EVENTS.ITEM_FINALIZE]: () => setCurrentlyUploadingItem(null),
[UPLOADER_EVENTS.ITEM_FINISH]: (item) => {
const { id, filename, variants } = item?.uploadResponse?.data?.result;
const url = (variants as string[]).find(v => v.includes('public'));
if (id && url) {
onChange({ id, name: filename, url, })
}
}
}}
>
<DropZoneButton
extraProps={{
renderProps: {
isUploading: !!currentlyUploadingItem,
img: currentlyUploadingItem ?? value ?? null,
render,
wrapperClass: props.wrapperClass
}
}
}
/>
</Uploady>
)
}
const DropZone = forwardRef<any, any>((props, ref) => {
const { onClick, children, renderProps, ...buttonProps } = props;
const isDraggingOnWindow = useIsDraggingOnElement()
useRequestPreSend(async (data) => {
const filename = data.items?.[0].file.name ?? ''
const res = await fetchUploadImageUrl({ filename });
return {
options: {
destination: {
url: res.uploadURL
},
}
}
})
const onZoneClick = useCallback(
(e: any) => {
if (onClick) {
onClick(e);
}
},
[onClick]
);
return <UploadDropZone
{...buttonProps}
ref={ref}
type='button'
onDragOverClassName={'drag-active'}
extraProps={{ onClick: onZoneClick }}
className={renderProps.wrapperClass}
>
{renderProps.render({
img: renderProps.img,
isUploading: renderProps.isUploading,
isDraggingOnWindow,
})}
</UploadDropZone>
})
const DropZoneButton = asUploadButton(DropZone);

View File

@@ -0,0 +1,25 @@
.zone {
background-color: #f2f4f7;
border-color: #e4e7ec;
.active_content {
display: none;
}
.idle_content {
display: block;
}
&.active {
background-color: #b3a0ff;
border-color: #9e88ff;
.active_content {
display: block;
}
.idle_content {
display: none;
}
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ThumbnailInput from './ThumbnailInput';
import { WrapFormController } from 'src/utils/storybook/decorators';
import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput';
export default {
title: 'Shared/Inputs/Files Inputs/Thumbnail ',
component: ThumbnailInput,
decorators: [
WrapFormController<{ thumbnail: ImageType | null }>({
logValues: true,
name: "thumbnail",
defaultValues: {
thumbnail: null
}
})]
} as ComponentMeta<typeof ThumbnailInput>;
const Template: ComponentStory<typeof ThumbnailInput> = (args, context) => {
return <ThumbnailInput {...context.controller} {...args} />
}
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,54 @@
import React, { ComponentProps } from 'react'
import { FiCamera } from 'react-icons/fi';
import { RotatingLines } from 'react-loader-spinner';
import { Nullable } from 'remirror';
import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput'
type Value = ComponentProps<typeof SingleImageUploadInput>['value']
interface Props {
width?: number
value: Value;
onChange: (new_value: Nullable<Value>) => void
}
export default function ThumbnailInput(props: Props) {
return (
<div
style={{
width: props.width ?? 120,
}}
className='aspect-square rounded-16 outline outline-2 outline-gray-200 overflow-hidden cursor-pointer bg-white hover:bg-gray-100'
>
<SingleImageUploadInput
value={props.value}
onChange={props.onChange}
wrapperClass='h-full'
render={({ img, isUploading }) => <div className="w-full h-full relative flex flex-col justify-center items-center">
{img && <img src={img.url} className='w-full h-full object-cover rounded-16' alt="" />}
{!img &&
<>
<p className="text-center text-gray-400 text-body2 mb-8"><FiCamera /></p>
<div className={`text-gray-400 text-center text-body5`}>
Add Image
</div>
</>}
{isUploading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>
}
</div>}
/>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import axios from "axios";
import { NotificationsService } from "src/services";
export async function fetchUploadUrl(options?: Partial<{ filename: string }>) {
const { filename } = options ?? {}
try {
const bodyFormData = new FormData();
bodyFormData.append('requireSignedURLs', "false");
const res = await axios({
url: 'https://cors-anywhere.herokuapp.com/https://api.cloudflare.com/client/v4/accounts/783da4f06e5fdb9012c0632959a6f5b3/images/v2/direct_upload',
method: 'POST',
data: bodyFormData,
headers: {
"Authorization": "Bearer XXX",
}
})
return res.data.result.uploadURL as string;
} catch (error) {
console.log(error);
NotificationsService.error("A network error happened.")
return "couldnt fetch upload url";
}
}

View File

@@ -13,7 +13,9 @@ interface Option {
readonly description: string | null
}
type Tag = Omit<OfficialTagsQuery['officialTags'][number], 'id'>
type Value = { title: Tag['title'] }
interface Props {
classes?: {
@@ -22,11 +24,80 @@ interface Props {
}
placeholder?: string
max?: number;
value: Value[];
onChange?: (new_value: Value[]) => void;
onBlur?: () => void;
[k: string]: any
}
export default function TagsInput({
classes,
placeholder = 'Write some tags',
max = 5,
value,
onChange,
onBlur,
...props }: Props) {
const officalTags = useOfficialTagsQuery();
const handleChange = (newValue: OnChangeValue<Option, true>,) => {
onChange?.([...newValue.map(transformer.optionToTag)]);
onBlur?.();
}
const maxReached = value.length >= max;
const currentPlaceholder = maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder;
const tagsOptions = !maxReached ? (officalTags.data?.officialTags ?? []).filter(t => !value.some((v) => v.title === t.title)).map(transformer.tagToOption) : [];
return (
<div className={`${classes?.container}`}>
<Select
isLoading={officalTags.loading}
options={tagsOptions}
isMulti
isOptionDisabled={() => maxReached}
placeholder={currentPlaceholder}
noOptionsMessage={() => {
return maxReached
? "You've reached the max number of tags."
: "No tags available";
}}
closeMenuOnSelect={false}
value={value.map(transformer.valueToOption)}
onChange={handleChange as any}
onBlur={onBlur}
components={{
Option: OptionComponent,
// ValueContainer: CustomValueContainer
}}
styles={colourStyles as any}
theme={(theme) => ({
...theme,
borderRadius: 8,
colors: {
...theme.colors,
primary: 'var(--primary)',
},
})}
/>
{/* <div className="flex mt-16 gap-8 flex-wrap">
{(value as Tag[]).map((tag, idx) => <Badge color="gray" key={tag.title} onRemove={() => handleRemove(idx)} >{tag.title}</Badge>)}
</div> */}
</div>
)
}
const transformer = {
valueToOption: (tag: Value): Option => ({ label: tag.title, value: tag.title, icon: null, description: null }),
tagToOption: (tag: Tag): Option => ({ label: tag.title, value: tag.title, icon: tag.icon, description: tag.description }),
optionToTag: (o: Option): Tag => ({ title: o.value, icon: o.icon, description: o.description, })
}
@@ -107,75 +178,3 @@ const colourStyles: StylesConfig = {
paddingRight: 0,
})
}
export default function TagsInput({
classes,
placeholder = 'Write some tags',
max = 5,
...props }: Props) {
const officalTags = useOfficialTagsQuery();
const { field: { value, onChange, onBlur } } = useController({
name: props.name ?? "tags",
control: props.control,
})
const handleChange = (newValue: OnChangeValue<Option, true>,) => {
onChange([...newValue.map(transformer.optionToTag)]);
onBlur();
}
const handleRemove = (idx: number) => {
onChange((value as Tag[]).filter((_, i) => idx !== i))
onBlur();
}
const maxReached = value.length >= max;
const currentPlaceholder = maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder;
const tagsOptions = !maxReached ? (officalTags.data?.officialTags ?? []).filter(t => !value.some((v: Tag) => v.title === t.title)).map(transformer.tagToOption) : [];
return (
<div className={`${classes?.container}`}>
<Select
isLoading={officalTags.loading}
options={tagsOptions}
isMulti
isOptionDisabled={() => maxReached}
placeholder={currentPlaceholder}
noOptionsMessage={() => {
return maxReached
? "You've reached the max number of tags."
: "No tags available";
}}
closeMenuOnSelect={false}
value={value.map(transformer.tagToOption)}
onChange={handleChange as any}
onBlur={onBlur}
components={{
Option: OptionComponent,
// ValueContainer: CustomValueContainer
}}
styles={colourStyles as any}
theme={(theme) => ({
...theme,
borderRadius: 8,
colors: {
...theme.colors,
primary: 'var(--primary)',
},
})}
/>
{/* <div className="flex mt-16 gap-8 flex-wrap">
{(value as Tag[]).map((tag, idx) => <Badge color="gray" key={tag.title} onRemove={() => handleRemove(idx)} >{tag.title}</Badge>)}
</div> */}
</div>
)
}

View File

@@ -1,91 +0,0 @@
import React, { FormEvent, useState } from 'react'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { motion } from 'framer-motion'
import { IoClose } from 'react-icons/io5'
import Button from 'src/Components/Button/Button'
import { useAppDispatch } from 'src/utils/hooks'
import { PayloadAction } from '@reduxjs/toolkit'
interface Props extends ModalCard {
callbackAction: PayloadAction<{ src: string, alt?: string }>
}
export default function InsertImageModal({ onClose, direction, callbackAction, ...props }: Props) {
const [urlInput, setUrlInput] = useState("")
const [altInput, setAltInput] = useState("")
const dispatch = useAppDispatch();
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (urlInput.length > 10) {
// onInsert({ src: urlInput, alt: altInput })
const action = Object.assign({}, callbackAction);
action.payload = { src: urlInput, alt: altInput }
dispatch(action)
onClose?.();
}
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[660px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Add Image</h2>
<form onSubmit={handleSubmit}>
<div className="grid md:grid-cols-3 gap-16 mt-32">
<div className='md:col-span-2'>
<p className="text-body5">
Image URL
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
placeholder='https://images.com/my-image'
/>
</div>
</div>
<div>
<p className="text-body5">
Alt Text
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={altInput}
onChange={e => setAltInput(e.target.value)}
placeholder=''
/>
</div>
</div>
</div>
<div className="mt-32 w-1/2 mx-auto aspect-video bg-gray-200 rounded-10">
{urlInput && <img
src={urlInput}
className='w-full h-full object-cover rounded-10'
alt={altInput}
/>}
</div>
<div className="flex gap-16 justify-end mt-32">
<Button onClick={onClose}>
Cancel
</Button>
<Button type='submit' color='primary' >
Add
</Button>
</div>
</form>
</motion.div>
)
}

View File

@@ -5,7 +5,7 @@ import InsertImageModal from './InsertImageModal';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Shared/Inputs/Text Editor/Insert Image Modal',
title: 'Shared/Inputs/Files Inputs/Image Modal',
component: InsertImageModal,
decorators: [ModalsDecorator]
@@ -14,4 +14,13 @@ export default {
const Template: ComponentStory<typeof InsertImageModal> = (args) => <InsertImageModal {...args} />;
export const Default = Template.bind({});
Default.args = {
callbackAction: {
type: "INSERT_IMAGE_IN_STORY",
payload: {
src: "",
alt: "",
}
}
}

View File

@@ -0,0 +1,176 @@
import React, { FormEvent, useRef, useState } from 'react'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { motion } from 'framer-motion'
import { IoClose } from 'react-icons/io5'
import Button from 'src/Components/Button/Button'
import { useAppDispatch, useIsDraggingOnElement } from 'src/utils/hooks'
import { PayloadAction } from '@reduxjs/toolkit'
import { RotatingLines } from 'react-loader-spinner'
import { FaExchangeAlt, FaImage } from 'react-icons/fa'
import SingleImageUploadInput, { ImageType } from 'src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput'
import { AiOutlineCloudUpload } from 'react-icons/ai'
interface Props extends ModalCard {
callbackAction: PayloadAction<{ src: string, alt?: string }>
}
export default function InsertImageModal({ onClose, direction, callbackAction, ...props }: Props) {
const [uploadedImage, setUploadedImage] = useState<ImageType | null>(null)
const [altInput, setAltInput] = useState("")
const dispatch = useAppDispatch();
const dropAreaRef = useRef<HTMLDivElement>(null!)
const isDragging = useIsDraggingOnElement({ ref: dropAreaRef });
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
console.log(uploadedImage?.url);
if (uploadedImage?.url) {
// onInsert({ src: urlInput, alt: altInput })
const action = Object.assign({}, callbackAction);
action.payload = { src: uploadedImage.url, alt: altInput }
dispatch(action)
onClose?.();
}
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[660px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Add Image</h2>
<form onSubmit={handleSubmit}>
{/* <div className="grid md:grid-cols-3 gap-16 mt-32">
<div className='md:col-span-2'>
<p className="text-body5">
Image URL
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
placeholder='https://images.com/my-image'
/>
</div>
</div>
<div>
<p className="text-body5">
Alt Text
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={altInput}
onChange={e => setAltInput(e.target.value)}
placeholder=''
/>
</div>
</div>
</div>
<div className="mt-32 w-1/2 mx-auto aspect-video bg-gray-200 rounded-10">
{urlInput && <img
src={urlInput}
className='w-full h-full object-cover rounded-10'
alt={altInput}
/>}
</div> */}
<SingleImageUploadInput
value={uploadedImage}
onChange={setUploadedImage}
wrapperClass='h-full mt-32'
render={({ img, isUploading, isDraggingOnWindow }) => <div ref={dropAreaRef} className="w-full group aspect-video bg-gray-100 cursor-pointer rounded-16 border-2 border-gray200 overflow-hidden relative flex flex-col justify-center items-center">
{img && <>
<img src={img.url} className='w-full h-full object-cover rounded-16' alt="" />
{!isUploading &&
<button type='button' className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 py-16 px-24 rounded-12 bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-h3'>
<FaExchangeAlt />
</button>}
</>}
{!img &&
<>
<p className="text-center text-gray-700 text-body1 md:text-h1 mb-8"><FaImage /></p>
<div className={`text-gray-600 text-center text-body4`}>
Drop an <span className="font-bold">IMAGE</span> here or <br /> <span className="text-blue-500 underline">Click to browse</span>
</div>
</>}
{isUploading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
>
<RotatingLines
strokeColor="#fff"
strokeWidth="3"
animationDuration="0.75"
width="48"
visible={true}
/>
</div>
}
{isDraggingOnWindow &&
<div
className={
`absolute inset-0 ${isDragging ? 'bg-primary-600' : 'bg-primary-400'} bg-opacity-80 flex flex-col justify-center items-center text-white font-bold transition-transform`
}
>
<motion.div
initial={{ y: 0 }}
animate={
isDragging ? {
y: 5,
transition: {
duration: .4,
repeat: Infinity,
repeatType: 'mirror',
}
} : {
y: 0
}}
className='text-center text-body1'
>
<AiOutlineCloudUpload className="scale-150 text-h1 mb-16" />
<br />
Drop here to upload
</motion.div>
</div>
}
</div>}
/>
<div className='mt-24'>
<p className="text-body5">
Alternative Text
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={altInput}
onChange={e => setAltInput(e.target.value)}
placeholder='A description for the content of this image'
/>
</div>
</div>
<div className="flex gap-16 justify-end mt-32">
<Button onClick={onClose}>
Cancel
</Button>
<Button type='submit' color='primary' >
Add
</Button>
</div>
</form>
</motion.div>
)
}

21
src/api/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { CONSTS } from "src/utils";
export async function fetchLnurlAuth() {
const res = await fetch(CONSTS.apiEndpoint + '/get-login-url', {
credentials: 'include'
})
const data = await res.json()
return data;
}
export async function fetchIsLoggedIn(session_token: string) {
const res = await fetch(CONSTS.apiEndpoint + '/is-logged-in', {
credentials: 'include',
headers: {
session_token
}
});
const data = await res.json();
return data.logged_in;
}

12
src/api/uploading.ts Normal file
View File

@@ -0,0 +1,12 @@
import axios from "axios";
import { CONSTS } from "src/utils";
export async function fetchUploadImageUrl({ filename }: { filename: string }) {
const res = await axios.post(CONSTS.apiEndpoint + '/upload-image-url', {
filename
}, {
withCredentials: true
})
return res.data;
}

View File

@@ -10,17 +10,12 @@ import Button from "src/Components/Button/Button";
import { FiCopy } from "react-icons/fi";
import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard";
import { getPropertyFromUnknown, trimText, } from "src/utils/helperFunctions";
import { fetchIsLoggedIn, fetchLnurlAuth } from "src/api/auth";
import { useErrorHandler } from 'react-error-boundary';
const fetchLnurlAuth = async () => {
const res = await fetch(CONSTS.apiEndpoint + '/get-login-url', {
credentials: 'include'
})
const data = await res.json()
return data;
}
export const useLnurlQuery = () => {
const [loading, setLoading] = useState(true)
@@ -102,15 +97,9 @@ export default function LoginPage() {
if (canFetchIsLogged.current === false) return;
canFetchIsLogged.current = false;
fetch(CONSTS.apiEndpoint + '/is-logged-in', {
credentials: 'include',
headers: {
session_token
}
})
.then(data => data.json())
.then(data => {
if (data.logged_in) {
fetchIsLoggedIn(session_token)
.then(is_logged_in => {
if (is_logged_in) {
clearInterval(interval)
refetch();
}

View File

@@ -2,8 +2,9 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form";
import Button from "src/Components/Button/Button";
import DatePicker from "src/Components/Inputs/DatePicker/DatePicker";
import FilesInput from "src/Components/Inputs/FilesInput/FilesInput";
import TagsInput from "src/Components/Inputs/TagsInput/TagsInput";
import { Tag } from "src/graphql";
import { imageSchema } from "src/utils/validation";
import * as yup from "yup";
import ContentEditor from "../ContentEditor/ContentEditor";
@@ -31,29 +32,14 @@ const schema = yup.object({
.string()
.required()
.min(50, 'you have to write at least 10 words'),
cover_image: yup
.lazy((value: string | File[]) => {
switch (typeof value) {
case 'object':
return yup
.array()
.test("fileSize", "File Size is too large", (files) => (files as File[]).every(file => file.size <= 5242880))
.test("fileType", "Unsupported File Format, only png/jpg/jpeg images are allowed",
(files) => (files as File[]).every((file: File) =>
["image/jpeg", "image/png", "image/jpg"].includes(file.type)))
case 'string':
return yup.string().url();
default:
return yup.mixed()
}
})
cover_image: imageSchema,
}).required();
interface IFormInputs {
title: string
deadline: Date
bounty_amount: number
tags: NestedValue<object[]>
tags: NestedValue<Tag[]>
cover_image: NestedValue<File[]> | string
body: string
}
@@ -86,7 +72,7 @@ export default function BountyForm() {
<div
className='bg-white shadow-lg rounded-8 overflow-hidden'>
<div className="p-32">
<Controller
{/* <Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur } }) => (
@@ -97,7 +83,7 @@ export default function BountyForm() {
uploadText='Add a cover image'
/>
)}
/>
/> */}
<p className='input-error'>{errors.cover_image?.message}</p>
@@ -155,10 +141,20 @@ export default function BountyForm() {
<p className="text-body5 mt-16">
Tags
</p>
<TagsInput
placeholder="Enter your tag and click enter. You can add multiple tags to your post"
classes={{ container: 'mt-8' }}
<Controller
control={control}
name="tags"
render={({ field: { onChange, value, onBlur } }) => (
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
)}
/>
{errors.tags && <p className="input-error">
{errors.tags.message}
</p>}

View File

@@ -14,7 +14,6 @@ export default {
decorators: [WithModals, WrapForm<IStoryFormInputs>({
defaultValues: {
tags: [],
cover_image: [],
}
})]
} as ComponentMeta<typeof DraftsContainer>;

View File

@@ -10,7 +10,7 @@ import { NotificationsService } from 'src/services';
import { getDateDifference } from 'src/utils/helperFunctions';
import { useAppDispatch } from 'src/utils/hooks';
import { useReduxEffect } from 'src/utils/hooks/useReduxEffect';
import { IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
import { CreateStoryType, IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
interface Props {
id?: string;
@@ -28,7 +28,7 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) {
const [deleteStory] = useDeleteStoryMutation({
refetchQueries: ['GetMyDrafts']
})
const { setValue } = useFormContext<IStoryFormInputs>()
const { setValue } = useFormContext<CreateStoryType>()
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false)
@@ -45,7 +45,7 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) {
setValue('title', data.getPostById.title);
setValue('tags', data.getPostById.tags);
setValue('body', data.getPostById.body);
setValue('cover_image', data.getPostById.cover_image ? [data.getPostById.cover_image] : []);
setValue('cover_image', data.getPostById.cover_image ? { url: data.getPostById.cover_image, id: null, name: null } : null);
setValue('is_published', data.getPostById.is_published);
}

View File

@@ -1,8 +1,8 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form";
import Button from "src/Components/Button/Button";
import FilesInput from "src/Components/Inputs/FilesInput/FilesInput";
import TagsInput from "src/Components/Inputs/TagsInput/TagsInput";
import { Tag } from "src/graphql";
import * as yup from "yup";
import ContentEditor from "../ContentEditor/ContentEditor";
@@ -29,7 +29,7 @@ const schema = yup.object({
interface IFormInputs {
title: string
tags: NestedValue<object[]>
tags: NestedValue<Tag[]>
cover_image: NestedValue<File[]> | string
body: string
}
@@ -60,7 +60,7 @@ export default function QuestionForm() {
<div
className='bg-white shadow-lg rounded-8 overflow-hidden'>
<div className="p-32">
<Controller
{/* <Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur } }) => (
@@ -71,7 +71,7 @@ export default function QuestionForm() {
uploadText='Add a cover image'
/>
)}
/>
/> */}
<p className='input-error'>{errors.cover_image?.message}</p>
@@ -95,9 +95,18 @@ export default function QuestionForm() {
<p className="text-body5 mt-16">
Tags
</p>
<TagsInput
placeholder="Enter your tag and click enter. You can add multiple tags to your post"
classes={{ container: 'mt-8' }}
<Controller
control={control}
name="tags"
render={({ field: { onChange, value, onBlur } }) => (
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
)}
/>
{errors.tags && <p className="input-error">
{errors.tags.message}

View File

@@ -13,7 +13,6 @@ export default {
decorators: [WithModals, WrapForm<IStoryFormInputs>({
defaultValues: {
tags: [],
cover_image: [],
}
})]
} as ComponentMeta<typeof StoryForm>;

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Controller, useFormContext } from "react-hook-form";
import Button from "src/Components/Button/Button";
import FilesInput from "src/Components/Inputs/FilesInput/FilesInput";
import TagsInput from "src/Components/Inputs/TagsInput/TagsInput";
import ContentEditor from "../ContentEditor/ContentEditor";
import { useCreateStoryMutation } from 'src/graphql'
@@ -13,7 +12,8 @@ import { createRoute } from 'src/utils/routing';
import PreviewPostCard from '../PreviewPostCard/PreviewPostCard'
import { StorageService } from 'src/services';
import { useThrottledCallback } from '@react-hookz/web';
import { CreateStoryType, IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
import { CreateStoryType } from '../../CreateStoryPage/CreateStoryPage';
import CoverImageInput from 'src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput';
interface Props {
isUpdating?: boolean;
@@ -29,7 +29,7 @@ export default function StoryForm(props: Props) {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { handleSubmit, control, register, trigger, getValues, watch, reset } = useFormContext<IStoryFormInputs>();
const { handleSubmit, control, register, trigger, getValues, watch, reset } = useFormContext<CreateStoryType>();
const [editMode, setEditMode] = useState(true)
@@ -80,7 +80,7 @@ export default function StoryForm(props: Props) {
refetchQueries: ['GetMyDrafts']
});
const clickSubmit = (publish_now: boolean) => handleSubmit<IStoryFormInputs>(data => {
const clickSubmit = (publish_now: boolean) => handleSubmit<CreateStoryType>(data => {
setLoading(true);
createStory({
variables: {
@@ -90,7 +90,7 @@ export default function StoryForm(props: Props) {
body: data.body,
tags: data.tags.map(t => t.title),
is_published: publish_now,
cover_image: (data.cover_image[0] ?? null) as string | null,
cover_image: data.cover_image,
},
}
})
@@ -103,6 +103,8 @@ export default function StoryForm(props: Props) {
const { ref: registerTitleRef, ...titleRegisteration } = register('title');
return (
<>
<div id='preview-switch' className="flex gap-16">
@@ -117,19 +119,21 @@ export default function StoryForm(props: Props) {
<div
className='bg-white border-2 border-gray-200 rounded-16 overflow-hidden'>
<div className="p-16 md:p-24 lg:p-32">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur, ref } }) => (
<FilesInput
ref={ref}
<div className="w-full h-[120px] md:h-[240px] rounded-12 mb-16 overflow-hidden">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur, ref } }) => <CoverImageInput
value={value}
onBlur={onBlur}
onChange={onChange}
uploadText='Add a cover image'
onChange={e => {
onChange(e)
}}
// uploadText='Add a cover image'
/>
)}
/>
}
/>
</div>
@@ -153,11 +157,21 @@ export default function StoryForm(props: Props) {
/>
</div>
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
<Controller
control={control}
name="tags"
render={({ field: { onChange, value, onBlur } }) => (
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
)}
/>
</div>
<ContentEditor
key={postId}
@@ -167,7 +181,7 @@ export default function StoryForm(props: Props) {
/>
</div>
</>}
{!editMode && <PreviewPostCard post={{ ...getValues(), cover_image: getValues().cover_image[0] }} />}
{!editMode && <PreviewPostCard post={{ ...getValues(), cover_image: getValues('cover_image.url') }} />}
<div className="flex gap-16 mt-32">
<Button
type='submit'

View File

@@ -4,54 +4,42 @@ import { useRef, useState } from "react";
import { ErrorBoundary, withErrorBoundary } from "react-error-boundary";
import { FormProvider, NestedValue, Resolver, useForm } from "react-hook-form";
import ErrorPage from "src/Components/Errors/ErrorPage/ErrorPage";
import { Post_Type } from "src/graphql";
import { CreateStoryMutationVariables, Post_Type } from "src/graphql";
import { StorageService } from "src/services";
import { useAppSelector } from "src/utils/hooks";
import { Override } from "src/utils/interfaces";
import { imageSchema, tagSchema } from "src/utils/validation";
import * as yup from "yup";
import DraftsContainer from "../Components/DraftsContainer/DraftsContainer";
import ErrorsContainer from "../Components/ErrorsContainer/ErrorsContainer";
import StoryForm from "../Components/StoryForm/StoryForm";
import styles from './styles.module.scss'
const FileSchema = yup.lazy((value: string | File[]) => {
switch (typeof value) {
case 'object':
return yup.mixed()
.test("fileSize", "File Size is too large", file => file.size <= 5242880)
.test("fileType", "Unsupported File Format, only png/jpg/jpeg images are allowed",
(file: File) =>
["image/jpeg", "image/png", "image/jpg"].includes(file.type))
case 'string':
return yup.string().url();
default:
return yup.mixed()
}
})
const schema = yup.object({
title: yup.string().trim().required().min(10, 'Story title must be 2+ words').transform(v => v.replace(/(\r\n|\n|\r)/gm, "")),
tags: yup.array().required().min(1, 'Add at least one tag'),
body: yup.string().required().min(50, 'Post must contain at least 10+ words'),
cover_image: yup.array().of(FileSchema as any)
tags: yup.array().of(tagSchema).required().min(1, 'Add at least one tag'),
body: yup.string().required("Write some content in the post").min(50, 'Post must contain at least 10+ words'),
cover_image: imageSchema.nullable(true),
}).required();
export interface IStoryFormInputs {
id: number | null
title: string
tags: NestedValue<{ title: string }[]>
cover_image: NestedValue<File[]> | NestedValue<string[]>
body: string
is_published: boolean | null
type ApiStoryInput = NonNullable<CreateStoryMutationVariables['data']>;
export type IStoryFormInputs = {
id: ApiStoryInput['id']
title: ApiStoryInput['title']
body: ApiStoryInput['body']
cover_image: NestedValue<NonNullable<ApiStoryInput['cover_image']>> | null
tags: NestedValue<ApiStoryInput['tags']>
is_published: ApiStoryInput['is_published']
}
export type CreateStoryType = Override<IStoryFormInputs, {
cover_image: ApiStoryInput['cover_image'],
tags: { title: string }[]
cover_image: File[] | string[]
}>
const storageService = new StorageService<CreateStoryType>('story-edit');
@@ -64,13 +52,13 @@ function CreateStoryPage() {
story: state.staging.story || storageService.get()
}))
const formMethods = useForm<IStoryFormInputs>({
resolver: yupResolver(schema) as Resolver<IStoryFormInputs>,
const formMethods = useForm<CreateStoryType>({
resolver: yupResolver(schema) as Resolver<CreateStoryType>,
shouldFocusError: false,
defaultValues: {
id: story?.id ?? null,
title: story?.title ?? '',
cover_image: story?.cover_image ?? [],
cover_image: story?.cover_image,
tags: story?.tags ?? [],
body: story?.body ?? '',
is_published: story?.is_published ?? false,

View File

@@ -31,7 +31,8 @@ export default function StoryPageContent({ story }: Props) {
<Card id="content" onlyMd className="relative max">
{story.cover_image &&
<img src={story.cover_image}
className='w-full object-cover rounded-12 md:rounded-16 mb-16'
className='w-full h-[120px] md:h-[240px] object-cover rounded-12 mb-16'
// className='w-full object-cover rounded-12 md:rounded-16 mb-16'
alt="" />}
<div className="flex flex-col gap-24 relative">
{curUser?.id === story.author.id && <Menu

View File

@@ -28,7 +28,7 @@ export const useUpdateStory = (story: Story) => {
const handleEdit = () => {
dispatch(stageStory({
...story,
cover_image: story.cover_image ? [story.cover_image] : []
cover_image: story.cover_image ? { id: null, name: null, url: story.cover_image } : null,
}))
navigate("/blog/create-post?type=story")

View File

@@ -1,9 +1,8 @@
import { SubmitHandler, useForm } from "react-hook-form"
import { Controller, SubmitHandler, useForm } from "react-hook-form"
import { useUpdateProfileAboutMutation, useMyProfileAboutQuery, UpdateProfileAboutMutationVariables, UserBasicInfoFragmentDoc } from "src/graphql";
import { NotificationsService } from "src/services/notifications.service";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { useAppDispatch, usePrompt } from "src/utils/hooks";
import SaveChangesCard from "../SaveChangesCard/SaveChangesCard";
import { toast } from "react-toastify";
@@ -12,15 +11,18 @@ import NotFoundPage from "src/features/Shared/pages/NotFoundPage/NotFoundPage";
import { setUser } from "src/redux/features/user.slice";
import UpdateProfileAboutTabSkeleton from "./BasicProfileInfoTab.Skeleton";
import { useApolloClient } from "@apollo/client";
import AvatarInput from "src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput";
import { imageSchema } from "src/utils/validation";
interface Props {
}
type IFormInputs = NonNullable<UpdateProfileAboutMutationVariables['data']>;
const schema: yup.SchemaOf<IFormInputs> = yup.object({
name: yup.string().trim().required().min(2),
avatar: yup.string().url().required(),
avatar: imageSchema.required(),
bio: yup.string().ensure(),
email: yup.string().email().ensure(),
github: yup.string().ensure(),
@@ -55,8 +57,10 @@ const schema: yup.SchemaOf<IFormInputs> = yup.object({
export default function BasicProfileInfoTab() {
const { register, formState: { errors, isDirty, }, handleSubmit, reset } = useForm<IFormInputs>({
defaultValues: {},
const { register, formState: { errors, isDirty, }, handleSubmit, reset, control } = useForm<IFormInputs>({
defaultValues: {
},
resolver: yupResolver(schema),
mode: 'onBlur',
});
@@ -65,7 +69,7 @@ export default function BasicProfileInfoTab() {
const profileQuery = useMyProfileAboutQuery({
onCompleted: data => {
if (data.me)
reset(data.me)
reset({ ...data.me, avatar: { url: data.me.avatar } })
}
})
const [mutate, mutationStatus] = useUpdateProfileAboutMutation();
@@ -107,7 +111,7 @@ export default function BasicProfileInfoTab() {
onCompleted: ({ updateProfileDetails: data }) => {
if (data) {
dispatch(setUser(data))
reset(data);
reset({ ...data, avatar: { url: data.avatar } });
apolloClient.writeFragment({
id: `User:${data?.id}`,
data,
@@ -123,12 +127,21 @@ export default function BasicProfileInfoTab() {
})
};
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-24">
<Card className="md:col-span-2" defaultPadding={false}>
<div className="bg-gray-600 relative h-[160px] rounded-t-16">
<div className="absolute left-24 bottom-0 translate-y-1/2">
<Avatar src={profileQuery.data.me.avatar} width={120} />
<Controller
control={control}
name="avatar"
render={({ field: { onChange, value } }) => (
<AvatarInput value={value} onChange={onChange} width={120} />
)}
/>
</div>
</div>
<div className="p-16 md:p-24 mt-64">
@@ -148,29 +161,14 @@ export default function BasicProfileInfoTab() {
{errors.name && <p className="input-error">
{errors.name.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Avatar
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder='https://images.com/my-avatar.jpg'
{...register("avatar")}
/>
</div>
{errors.avatar && <p className="input-error">
{errors.avatar.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Bio
</p>
<div className="input-wrapper mt-8 relative">
<textarea
rows={3}
className="input-text !p-20"
rows={4}
className="input-text"
placeholder='Tell others a little bit about yourself'
{...register("bio")}
/>

View File

@@ -2,23 +2,16 @@ import { motion } from 'framer-motion'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { useEffect, useState } from "react"
import { Grid } from "react-loader-spinner";
import { CONSTS } from "src/utils";
import { QRCodeSVG } from 'qrcode.react';
import Button from "src/Components/Button/Button";
import { FiCopy } from "react-icons/fi";
import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard";
import { useApolloClient } from '@apollo/client';
import { IoClose } from 'react-icons/io5';
import { fetchLnurlAuth } from 'src/api/auth';
const fetchLnurlAuth = async () => {
const res = await fetch(CONSTS.apiEndpoint + '/get-login-url?action=link', {
credentials: 'include'
})
const data = await res.json()
return data;
}
const useLnurlQuery = () => {
const [loading, setLoading] = useState(true)

View File

@@ -141,6 +141,12 @@ export type Hackathon = {
website: Scalars['String'];
};
export type ImageInput = {
id?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
url: Scalars['String'];
};
export type LnurlDetails = {
__typename?: 'LnurlDetails';
commentAllowed: Maybe<Scalars['Int']>;
@@ -318,7 +324,7 @@ export type PostComment = {
};
export type ProfileDetailsInput = {
avatar?: InputMaybe<Scalars['String']>;
avatar?: InputMaybe<ImageInput>;
bio?: InputMaybe<Scalars['String']>;
discord?: InputMaybe<Scalars['String']>;
email?: InputMaybe<Scalars['String']>;
@@ -550,7 +556,7 @@ export type Story = PostBase & {
export type StoryInputType = {
body: Scalars['String'];
cover_image?: InputMaybe<Scalars['String']>;
cover_image?: InputMaybe<ImageInput>;
id?: InputMaybe<Scalars['Int']>;
is_published?: InputMaybe<Scalars['Boolean']>;
tags: Array<Scalars['String']>;

View File

@@ -3,9 +3,9 @@ import { Login_ScanningWalletCard, Login_ExternalWalletCard, Login_NativeWalletC
import { ProjectDetailsCard } from "src/features/Projects/pages/ProjectPage/ProjectDetailsCard";
import { ProjectListedModal } from "src/features/Projects/pages/ListProjectPage/Components/ProjectListedModal";
import VoteCard from "src/features/Projects/pages/ProjectPage/VoteCard/VoteCard";
import { InsertImageModal } from 'src/Components/Inputs/TextEditor/InsertImageModal'
import { InsertVideoModal } from 'src/Components/Inputs/TextEditor/InsertVideoModal'
import { InsertLinkModal } from 'src/Components/Inputs/TextEditor/InsertLinkModal'
import { Claim_FundWithdrawCard, Claim_CopySignatureCard, Claim_GenerateSignatureCard, Claim_SubmittedCard } from "src/features/Projects/pages/ProjectPage/ClaimProject";
import { ModalCard } from "src/Components/Modals/ModalsContainer/ModalsContainer";
import { ConfirmModal } from "src/Components/Modals/ConfirmModal";
@@ -19,6 +19,7 @@ import { ConnectToMakerModal } from "src/features/Tournaments/pages/MakersPage/C
import { RegistrationModals } from "src/features/Tournaments/pages/OverviewPage/RegisterationModals";
import { InsertImageModal } from "src/Components/Modals/InsertImageModal";
export enum Direction {
START,

View File

@@ -54,6 +54,8 @@
img {
border-radius: 16px;
outline: 1px solid #e4e7ec;
max-width: 100%;
object-fit: contain;
}
pre {

View File

@@ -12,5 +12,6 @@ export * from './useCurrentSection'
export * from './usePreload'
export * from './useCarousel'
export * from './usePrompt'
export * from './useIsDraggingOnElement'
export * from './useCountdown'

View File

@@ -0,0 +1,76 @@
import { MutableRefObject, useEffect, useRef, useState } from "react";
function addEventListener<K extends keyof HTMLElementEventMap>(element: HTMLElement, type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions) {
element.addEventListener(type, listener, options);
return () => element.removeEventListener(type, listener, options);
}
function setImmediate(callback: (...args: any[]) => void, ...args: any[]) {
let cancelled = false;
Promise.resolve().then(() => cancelled || callback(...args));
return () => {
cancelled = true;
};
}
function noop() { }
function handleDragOver(ev: DragEvent) {
ev.preventDefault();
ev.dataTransfer!.dropEffect = 'copy';
}
export const useIsDraggingOnElement = (options?: Partial<{
ref: MutableRefObject<HTMLElement>
}>) => {
const listenersRef = useRef<any[]>([]);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
let count = 0;
let cancelImmediate = noop;
const element = options?.ref?.current ?? document as unknown as HTMLElement;
listenersRef.current = [
addEventListener(element, 'dragover', handleDragOver),
addEventListener(element, 'dragenter', ev => {
ev.preventDefault();
if (count === 0) {
setIsDragging(true)
}
++count;
}),
addEventListener(element, 'dragleave', ev => {
ev.preventDefault();
cancelImmediate = setImmediate(() => {
--count;
if (count === 0) {
setIsDragging(false)
}
})
}),
addEventListener(element, 'drop', ev => {
ev.preventDefault();
cancelImmediate();
if (count > 0) {
count = 0;
setIsDragging(false)
}
}),
]
return () => {
listenersRef.current.forEach(f => f());
}
}, [options?.ref])
return isDragging
}

View File

@@ -16,8 +16,10 @@ import "src/styles/index.scss";
import 'react-loading-skeleton/dist/skeleton.css'
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from '../apollo';
import { FormProvider, useForm, UseFormProps, Controller } from 'react-hook-form';
import { Controller, FormProvider, useForm, UseFormProps, Controller } from 'react-hook-form';
import ModalsContainer from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import { ToastContainer } from 'react-toastify';
import { NotificationsService } from 'src/services';
// Enable the Mocks Service Worker
@@ -63,6 +65,11 @@ export const WrapperDecorator: DecoratorFn = (Story, options) => {
effect='solid'
delayShow={1000}
/>
<ToastContainer
{...NotificationsService.defaultOptions}
newestOnTop={false}
limit={2}
/>
</>
);
}
@@ -112,16 +119,42 @@ export const centerDecorator: DecoratorFn = (Story) => {
</div>
}
export function WrapForm<T = any>(options?: Partial<UseFormProps<T>>): DecoratorFn {
export function WrapForm<T = any>(options?: Partial<UseFormProps<T> & { logValues: boolean }>): DecoratorFn {
const Func: DecoratorFn = (Story) => {
const methods = useForm<T>(options);
if (options?.logValues) {
console.log(methods.watch())
}
return <FormProvider {...methods} >
<Story />
<Story onChang />
</FormProvider>
}
return Func
}
export function WrapFormController<T = any>(options: Partial<UseFormProps<T> & { logValues: boolean }> & { name: string }): DecoratorFn {
const Func: DecoratorFn = (Story) => {
const methods = useForm<T>(options);
if (options?.logValues) {
console.log(methods.watch(options.name as any))
}
return <Controller
control={methods.control}
name={options.name as any}
render={({ field: { value, onChange, onBlur } }) =>
<Story controller={{ value, onChange, onBlur }} />
}
/>
}
return Func
}
export const WithModals: DecoratorFn = (Component) => <>

View File

@@ -0,0 +1 @@
export * from './misc';

View File

@@ -0,0 +1,12 @@
import * as yup from "yup";
export const imageSchema = yup.object().shape({
id: yup.string().nullable(true),
name: yup.string().nullable(true),
url: yup.string().trim().required().url(),
});
export const tagSchema = yup.object().shape({
title: yup.string().trim().min(2).required(),
});