update: remove unused components

This commit is contained in:
MTG2000
2022-10-06 12:35:10 +03:00
parent f2752078a1
commit 9c388ecdb4
386 changed files with 521 additions and 25569 deletions

View File

@@ -1,5 +1,5 @@
overwrite: true
schema: "http://localhost:8888/dev/graphql"
schema: "https://api.baseql.com/airtable/graphql/app7wOLbDNm617R18"
documents: "./src/**/*.{ts,graphql}"
generates:
src/graphql/index.tsx:

View File

@@ -5,35 +5,33 @@ import { Wallet_Service } from "./services";
import { Navigate, Route, Routes } from "react-router-dom";
import { useWrapperSetup } from "./utils/Wrapper";
import LoadingPage from "./Components/LoadingPage/LoadingPage";
import { useMeQuery } from "./graphql";
import { setUser } from "./redux/features/user.slice";
// import { setUser } from "./redux/features/user.slice";
import ProtectedRoute from "./Components/ProtectedRoute/ProtectedRoute";
import { Helmet } from "react-helmet";
import { NavbarLayout } from "./utils/routing/layouts";
import { Loadable, PAGES_ROUTES } from "./utils/routing";
import ListProjectPage from "./features/Projects/pages/ListProjectPage/ListProjectPage";
// Pages
const FeedPage = Loadable(React.lazy(() => import( /* webpackChunkName: "feed_page" */ "./features/Posts/pages/FeedPage/FeedPage")))
const PostDetailsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "post_details_page" */ "./features/Posts/pages/PostDetailsPage/PostDetailsPage")))
const CreatePostPage = Loadable(React.lazy(() => import( /* webpackChunkName: "create_post_page" */ "./features/Posts/pages/CreatePostPage/CreatePostPage")))
// const FeedPage = Loadable(React.lazy(() => import( /* webpackChunkName: "feed_page" */ "./features/Posts/pages/FeedPage/FeedPage")))
// const PostDetailsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "post_details_page" */ "./features/Posts/pages/PostDetailsPage/PostDetailsPage")))
// const CreatePostPage = Loadable(React.lazy(() => import( /* webpackChunkName: "create_post_page" */ "./features/Posts/pages/CreatePostPage/CreatePostPage")))
const HottestPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hottest_page" */ "src/features/Projects/pages/HottestPage/HottestPage")))
const CategoryPage = Loadable(React.lazy(() => import( /* webpackChunkName: "category_page" */ "src/features/Projects/pages/CategoryPage/CategoryPage")))
const ExplorePage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ExplorePage")))
const ProjectPage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ProjectPage/ProjectPage")))
// const HottestPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hottest_page" */ "src/features/Projects/pages/HottestPage/HottestPage")))
// const CategoryPage = Loadable(React.lazy(() => import( /* webpackChunkName: "category_page" */ "src/features/Projects/pages/CategoryPage/CategoryPage")))
// const ProjectPage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ProjectPage/ProjectPage")))
const HackathonsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hackathons_page" */ "./features/Hackathons/pages/HackathonsPage/HackathonsPage")))
// const HackathonsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hackathons_page" */ "./features/Hackathons/pages/HackathonsPage/HackathonsPage")))
const TournamentDetailsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hackathons_page" */ "./features/Tournaments/pages/TournamentDetailsPage/TournamentDetailsPage")))
// const TournamentDetailsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hackathons_page" */ "./features/Tournaments/pages/TournamentDetailsPage/TournamentDetailsPage")))
const DonatePage = Loadable(React.lazy(() => import( /* webpackChunkName: "donate_page" */ "./features/Donations/pages/DonatePage/DonatePage")))
const LoginPage = Loadable(React.lazy(() => import( /* webpackChunkName: "login_page" */ "./features/Auth/pages/LoginPage/LoginPage")))
const LogoutPage = Loadable(React.lazy(() => import( /* webpackChunkName: "logout_page" */ "./features/Auth/pages/LogoutPage/LogoutPage")))
const ProfilePage = Loadable(React.lazy(() => import( /* webpackChunkName: "profile_page" */ "./features/Profiles/pages/ProfilePage/ProfilePage")))
const EditProfilePage = Loadable(React.lazy(() => import( /* webpackChunkName: "edit_profile_page" */ "./features/Profiles/pages/EditProfilePage/EditProfilePage")))
// const DonatePage = Loadable(React.lazy(() => import( /* webpackChunkName: "donate_page" */ "./features/Donations/pages/DonatePage/DonatePage")))
// const LoginPage = Loadable(React.lazy(() => import( /* webpackChunkName: "login_page" */ "./features/Auth/pages/LoginPage/LoginPage")))
// const LogoutPage = Loadable(React.lazy(() => import( /* webpackChunkName: "logout_page" */ "./features/Auth/pages/LogoutPage/LogoutPage")))
// const ProfilePage = Loadable(React.lazy(() => import( /* webpackChunkName: "profile_page" */ "./features/Profiles/pages/ProfilePage/ProfilePage")))
// const EditProfilePage = Loadable(React.lazy(() => import( /* webpackChunkName: "edit_profile_page" */ "./features/Profiles/pages/EditProfilePage/EditProfilePage")))
const ExplorePage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ExplorePage/ExplorePage")))
@@ -45,14 +43,14 @@ function App() {
const dispatch = useAppDispatch();
useWrapperSetup()
useMeQuery({
onCompleted: (data) => {
dispatch(setUser(data.me))
},
onError: (error) => {
dispatch(setUser(null))
},
});
// useMeQuery({
// onCompleted: (data) => {
// dispatch(setUser(data.me))
// },
// onError: (error) => {
// dispatch(setUser(null))
// },
// });
useEffect(() => {
// if (typeof window.webln != "undefined") {
@@ -93,12 +91,12 @@ function App() {
</Helmet>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path={PAGES_ROUTES.blog.writeStory} element={<ProtectedRoute><CreatePostPage initType="story" /></ProtectedRoute>} />
{/* <Route path={PAGES_ROUTES.blog.writeStory} element={<ProtectedRoute><CreatePostPage initType="story" /></ProtectedRoute>} /> */}
<Route element={<NavbarLayout />}>
<Route path={PAGES_ROUTES.projects.hottest} element={<HottestPage />} />
{/* <Route path={PAGES_ROUTES.projects.hottest} element={<HottestPage />} />
<Route path={PAGES_ROUTES.projects.byCategoryId} element={<CategoryPage />} />
<Route path={PAGES_ROUTES.projects.default} element={<ExplorePage />} />
<Route path={PAGES_ROUTES.projects.listProject} element={<ListProjectPage />} />
<Route path={PAGES_ROUTES.projects.projectPage} element={<ProjectPage />} />
<Route path={PAGES_ROUTES.projects.catchProject} element={<Navigate replace to={PAGES_ROUTES.projects.default} />} />
@@ -117,9 +115,9 @@ function App() {
<Route path={PAGES_ROUTES.profile.byId} element={<ProfilePage />} />
<Route path={PAGES_ROUTES.auth.login} element={<LoginPage />} />
<Route path={PAGES_ROUTES.auth.logout} element={<LogoutPage />} />
<Route path="/" element={<Navigate replace to={PAGES_ROUTES.blog.feed} />} />
<Route path={PAGES_ROUTES.auth.logout} element={<LogoutPage />} /> */}
<Route path={PAGES_ROUTES.projects.default} element={<ExplorePage />} />
<Route path="/" element={<Navigate replace to={PAGES_ROUTES.projects.default} />} />
</Route>
</Routes>

View File

@@ -1,29 +0,0 @@
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

@@ -1,105 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,103 +0,0 @@
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-100 border-dashed border-2 border-gray-200 ${props.rounded ?? 'rounded-12'}`}>
<p className="text-center text-gray-800 text-body1 md:text-h1 mb-8"><FaImage /></p>
<div className={`text-gray-700 text-center text-body4`}>
Drop a <span className="font-bold">COVER IMAGE</span> here or <br /> <span className="text-blue-400 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='w-42 h-42 flex justify-center items-center rounded-full bg-gray-800 bg-opacity-60 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body3'>
<CgArrowsExchangeV />
</button>
<button type='button' className='w-42 h-42 flex justify-center items-center rounded-full bg-gray-800 bg-opacity-60 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body3' onClick={(e) => { e.stopPropagation(); props.onChange(null) }}>
<IoMdClose />
</button>
</div>
}
</>}
{isUploading &&
<div
className={`absolute inset-0 bg-gray-400 ${props.rounded ?? 'rounded-12'} 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

@@ -1,16 +0,0 @@
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

@@ -1,141 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,25 +0,0 @@
.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

@@ -1,96 +0,0 @@
import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview'
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemFinishListener, 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);
}
}, id);
useItemFinishListener(() => setItemState(STATES.DONE), id)
useItemAbortListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemCancelListener(item => {
setItemState(STATES.CANCELLED);
}, id);
useItemErrorListener(item => {
console.log(item);
setItemState(STATES.ERROR);
setTimeout(() => setItemState(STATES.CANCELLED), 2000)
}, 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

@@ -1,52 +0,0 @@
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 type='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 flex flex-col justify-center items-center" onClick={() => onCancel?.()}><FaTimes /></button>
}
</div>
)
}

View File

@@ -1,85 +0,0 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ScreenshotsInput from './ScreenshotsInput';
import { WrapForm, WrapFormController } from 'src/utils/storybook/decorators';
import { ImageInput } from 'src/graphql';
export default {
title: 'Shared/Inputs/Files Inputs/Screenshots',
component: ScreenshotsInput,
decorators: [
WrapFormController<{ screenshots: Array<ImageInput> }>({
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<ImageInput> }>({
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<ImageInput> }>({
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

@@ -1,147 +0,0 @@
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";
import { ImageInput } from "src/graphql";
import { fetchUploadImageUrl } from "src/api/uploading";
import { removeArrayItemAtIndex } from "src/utils/helperFunctions";
const mockSenderEnhancer = getMockSenderEnhancer({
delay: 1500,
});
const MAX_UPLOAD_COUNT = 4 as const;
interface Image extends ImageInput {
local_id?: string
}
interface Props {
value: Image[],
onChange: (new_value: Image[]) => 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
accept="image/*"
multiple={true}
inputFieldName='file'
grouped={false}
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) => {
const { id, filename, variants } = item?.uploadResponse?.data?.result;
const url = (variants as string[]).find(v => v.includes('public'));
if (id && url) {
onChange([...uploadedFiles, { id, local_id: id, name: filename, url: url }].slice(-MAX_UPLOAD_COUNT))
}
}
}}
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-16 mt-24">
<DropZoneButton extraProps={{ canUploadMore }} />
{uploadedFiles.map((f, idx) => <ScreenshotThumbnail
key={f.local_id}
url={f.url}
onCancel={() => {
onChange(removeArrayItemAtIndex(uploadedFiles, idx))
}} />)}
<ImagePreviews />
{(placeholdersCount > 0) &&
Array(placeholdersCount).fill(0).map((_, idx) => <ScreenshotThumbnail key={idx} />)}
</div>
</Uploady>
)
}
const DropZone = forwardRef<any, any>((props, ref) => {
const { canUploadMore, onClick, ...buttonProps } = props;
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]
);
if (!canUploadMore) return null
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

@@ -1,25 +0,0 @@
.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

@@ -1,97 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,141 +0,0 @@
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

@@ -1,25 +0,0 @@
.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

@@ -1,29 +0,0 @@
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

@@ -1,54 +0,0 @@
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

@@ -1,25 +0,0 @@
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

@@ -1,29 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapForm } from 'src/utils/storybook/decorators';
import TagsInput from './TagsInput';
export default {
title: 'Shared/Inputs/Tags Input',
component: TagsInput,
argTypes: {
backgroundColor: { control: 'color' },
},
decorators: [WrapForm({
defaultValues: {
tags: [{
title: "Webln"
}]
}
})]
} as ComponentMeta<typeof TagsInput>;
const Template: ComponentStory<typeof TagsInput> = (args) => <div>
<p className="text-body4 mb-8 text-gray-700">
Enter Tags:
</p>
<TagsInput classes={{ input: "max-w-[320px]" }} {...args}></TagsInput>
</div>
export const Default = Template.bind({});

View File

@@ -1,181 +0,0 @@
import { useController } from "react-hook-form";
// import CreatableSelect from 'react-select/creatable';
import Select from 'react-select'
import { OnChangeValue, StylesConfig, components, OptionProps, } from "react-select";
import { OfficialTagsQuery, useOfficialTagsQuery } from "src/graphql";
import React from "react";
interface Option {
readonly label: string;
readonly value: string;
readonly icon: string | null
readonly description: string | null
}
type Tag = Omit<OfficialTagsQuery['officialTags'][number], 'id'>
type Value = { title: Tag['title'] }
interface Props {
classes?: {
container?: string
input?: string
}
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 = props.placeholder ?? <div className="flex gap-8 items-center text-gray-500">
{maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder} </div>
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, })
}
const OptionComponent = (props: OptionProps<Option>) => {
return (
<div>
<components.Option {...props} className='!flex items-center gap-16 !py-16'>
<div className={`rounded-8 w-40 h-40 text-center py-8 shrink-0 bg-gray-100`}>
{props.data.icon}
</div>
<div>
<p className="font-medium self-center">
{props.data.label}
</p>
<p className="text-body5 text-gray-500">
{props.data.description}
</p>
</div>
</components.Option>
</div>
);
};
const { ValueContainer, Placeholder } = components;
const CustomValueContainer = ({ children, ...props }: any) => {
return (
<ValueContainer {...props}>
{React.Children.map(children, child =>
child && child.type !== Placeholder ? child : null
)}
<Placeholder {...props} isFocused={props.isFocused}>
{props.selectProps.placeholder}
</Placeholder>
</ValueContainer>
);
};
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
padding: '1px 0',
border: 'none',
boxShadow: 'none',
":hover": {
cursor: "pointer"
}
}),
multiValueRemove: (styles) => ({
...styles,
":hover": {
background: 'none'
}
}),
indicatorsContainer: () => ({ display: 'none' }),
clearIndicator: () => ({ display: 'none' }),
indicatorSeparator: () => ({ display: "none" }),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
multiValue: styles => ({
...styles,
padding: '4px 12px',
borderRadius: 48,
fontWeight: 500
}),
valueContainer: (styles) => ({
...styles,
paddingLeft: 0,
paddingRight: 0,
})
}

View File

@@ -1,8 +0,0 @@
query OfficialTags {
officialTags {
id
title
icon
description
}
}

View File

@@ -38,18 +38,18 @@ export default function ImageToolButton({ classes }: Props) {
const { activeCmd, cmd, tip, Icon } = cmdToBtn['img'];
const onClick = () => {
dispatch(openModal({
Modal: "InsertImageModal",
props: {
callbackAction: {
type: INSERT_IMAGE_ACTION.type,
payload: {
src: "",
alt: ""
}
}
}
}))
// dispatch(openModal({
// Modal: "InsertImageModal",
// props: {
// callbackAction: {
// type: INSERT_IMAGE_ACTION.type,
// payload: {
// src: "",
// alt: ""
// }
// }
// }
// }))
}
return (

View File

@@ -1,29 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapForm } from 'src/utils/storybook/decorators';
import UsersInput from './UsersInput';
export default {
title: 'Shared/Inputs/Users Input',
component: UsersInput,
argTypes: {
backgroundColor: { control: 'color' },
},
decorators: [WrapForm({
defaultValues: {
tags: [{
title: "Webln"
}]
}
})]
} as ComponentMeta<typeof UsersInput>;
const Template: ComponentStory<typeof UsersInput> = (args) => <div>
<p className="text-body4 mb-8 text-gray-700">
Search for users:
</p>
<UsersInput classes={{ input: "max-w-[320px]" }} {...args}></UsersInput>
</div>
export const Default = Template.bind({});

View File

@@ -1,164 +0,0 @@
import AsyncSelect from 'react-select/async';
import { OnChangeValue, StylesConfig, components, OptionProps } from "react-select";
import { SearchUsersDocument, SearchUsersQuery, SearchUsersQueryResult } from "src/graphql";
import { apolloClient } from "src/utils/apollo";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { FiSearch } from 'react-icons/fi';
import { useState } from 'react';
import debounce from 'lodash.debounce';
type User = SearchUsersQuery['searchUsers'][number]
interface Props {
classes?: {
container?: string
input?: string
}
placeholder?: string,
onSelect?: (selectedUser: User) => void
}
const fetchOptions = debounce((value, callback: any) => {
apolloClient.query({
query: SearchUsersDocument,
variables: {
value
}
})
.then((result) => callback((result as SearchUsersQueryResult).data?.searchUsers ?? []))
.catch((error: any) => callback(error, null));
}, 1000);
const OptionComponent = (props: OptionProps<User>) => {
return (
<div>
<components.Option {...props} className='!flex items-center gap-16 !py-16'>
<Avatar src={props.data.avatar} width={48} />
<div>
<p className="font-medium self-center">
{props.data.name}
</p>
<p className="text-body5 text-gray-500">
{props.data.jobTitle}
</p>
</div>
</components.Option>
</div>
);
};
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
padding: '5px 16px',
borderRadius: 12,
// border: 'none',
// boxShadow: 'none',
":hover": {
cursor: "pointer"
},
":focus-within": {
'--tw-border-opacity': '1',
borderColor: 'rgb(179 160 255 / var(--tw-border-opacity))',
outlineColor: '#9E88FF',
'--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
'--tw-ring-color': 'rgb(179 160 255 / var(--tw-ring-opacity))',
'--tw-ring-opacity': '0.5'
}
}),
multiValueRemove: (styles) => ({
...styles,
":hover": {
background: 'none'
}
}),
indicatorsContainer: () => ({ display: 'none' }),
clearIndicator: () => ({ display: 'none' }),
indicatorSeparator: () => ({ display: "none" }),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
multiValue: styles => ({
...styles,
padding: '4px 12px',
borderRadius: 48,
fontWeight: 500
}),
valueContainer: (styles) => ({
...styles,
paddingLeft: 0,
paddingRight: 0,
})
}
export default function UsersInput({
classes,
...props }: Props) {
const [inputValue, setInputValue] = useState("")
const placeholder = props.placeholder ?? <span className='text-gray-400'><FiSearch /> <span className='align-middle'>Search by username</span></span>
const handleChange = (newValue: OnChangeValue<User, false>,) => {
if (newValue)
props.onSelect?.(newValue);
}
let emptyMessage = "Type at least 2 characters";
if (inputValue.length >= 2)
emptyMessage = "Couldn't find any users..."
let loadingMessage = "Searching...";
if (inputValue.length < 2)
loadingMessage = "Type at least 2 characters"
return (
<div className={`${classes?.container}`}>
<AsyncSelect
value={null}
inputValue={inputValue}
onInputChange={setInputValue}
defaultOptions={false}
loadOptions={fetchOptions}
loadingMessage={() => loadingMessage}
placeholder={placeholder}
noOptionsMessage={() => emptyMessage}
onChange={handleChange as any}
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,8 +0,0 @@
query SearchUsers($value: String!) {
searchUsers(value: $value) {
id
name
avatar
jobTitle
}
}

View File

@@ -1,26 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import InsertImageModal from './InsertImageModal';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Shared/Inputs/Files Inputs/Image Modal',
component: InsertImageModal,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof InsertImageModal>;
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

@@ -1,176 +0,0 @@
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>
)
}

View File

@@ -1,4 +0,0 @@
import { lazyModal } from 'src/utils/helperFunctions';
export const { LazyComponent: InsertImageModal } = lazyModal(() => import('./InsertImageModal'))

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Menu, MenuButton } from '@szhsin/react-menu';
import Button from 'src/Components/Button/Button';
import CategoriesList from './CategoriesList';
export default {
title: 'Shared/Navbar/CategoriesList',
component: CategoriesList,
} as ComponentMeta<typeof CategoriesList>;
const Template: ComponentStory<typeof CategoriesList> = (args) => <Menu offsetY={24} menuButton={<MenuButton className='text-body4 font-bold hover:text-primary-600'>Open Categories Menu</MenuButton>}>
<CategoriesList {...args} />
</Menu>;
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,50 +0,0 @@
import {
MenuItem,
} from '@szhsin/react-menu'
import Skeleton from 'react-loading-skeleton'
import { Link, useNavigate } from 'react-router-dom'
import { useNavCategoriesQuery } from 'src/graphql'
import { numberFormatter } from 'src/utils/helperFunctions'
interface Props {
// categories: Pick<ProjectCategory, 'id' | 'title' | 'icon' | 'votes_sum'>[]
classes?: Partial<{ item: string }>
onClick?: (categoryId: number) => void
}
;
export default function CategoriesList({ classes = {}, onClick }: Props) {
const { data, loading } = useNavCategoriesQuery()
const navigate = useNavigate()
if (loading)
return <>
{Array(5).fill(0).map((_, idx) =>
<li key={idx} className={`flex p-16 text-body4 font-semibold items-center hover:bg-gray-100 rounded-8 ${classes.item}`} >
<span className="text-body3 mr-8"><Skeleton width='1.5ch' /></span> <Skeleton width='10ch' /> <span className="ml-auto text-body5 font-normal text-gray-400"><Skeleton width='2ch' /></span>
</li>
)}
</>
return (
<>
{data?.allCategories.map(category =>
<MenuItem
key={category.id}
className={`w-full !p-16 text-body4 font-semibold hover:bg-gray-100 !rounded-8 flex w-items-center ${classes.item}`}
href={`/products/category/${category.id}`}
onClick={(e) => {
e.syntheticEvent.preventDefault();
onClick?.(category.id)
navigate(`/products/category/${category.id}`)
}}
>
<span className="text-body3 mr-8">{category.icon}</span> {category.title} <span className="ml-auto pl-8 text-body5 font-normal text-gray-400">{numberFormatter(category.votes_sum)}</span>
</MenuItem>
)}
</>
)
}

View File

@@ -1,8 +0,0 @@
query NavCategories {
allCategories {
id
title
icon
votes_sum
}
}

View File

@@ -2,7 +2,6 @@ import { BsSearch } from "react-icons/bs";
import { motion } from "framer-motion";
import { useAppSelector, useCurrentSection } from "src/utils/hooks";
import ASSETS from "src/assets";
import Search from "./Search/Search";
import IconButton from "../IconButton/IconButton";
import { Link, useNavigate } from "react-router-dom";
import { useState } from "react";
@@ -13,7 +12,6 @@ import {
} from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import { FiChevronDown } from "react-icons/fi";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { createRoute, PAGES_ROUTES } from "src/utils/routing";
import Button from "../Button/Button";
@@ -24,7 +22,7 @@ export default function NavDesktop() {
const { curUser } = useAppSelector((state) => ({
curUser: state.user.me,
curUser: null,
}));
@@ -164,83 +162,8 @@ export default function NavDesktop() {
: <Button className="ml-16 py-12 px-16 lg:px-20" onClick={onConnectWallet}><AiFillThunderbolt className='inline-block text-thunder transform scale-125' /> Connect Wallet </Button>
} */}
{currentSection === 'apps' && <IconButton className='mr-16 self-center' onClick={openSearch}>
<BsSearch className='scale-125 text-gray-400' />
</IconButton>}
</motion.div>
{curUser !== undefined &&
(curUser ?
<Menu
align="end"
offsetY={4}
menuClassName='!p-8 !rounded-12'
menuButton={<MenuButton ><Avatar src={curUser.avatar} width={40} /> </MenuButton>}>
<MenuItem
href={createRoute({ type: 'profile', id: curUser.id, username: curUser.name })}
onClick={(e) => {
e.syntheticEvent.preventDefault();
navigate(createRoute({ type: 'profile', id: curUser.id, username: curUser.name }));
}}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
👾 Profile
</MenuItem>
<MenuItem
href="/edit-profile"
onClick={(e) => {
e.syntheticEvent.preventDefault();
navigate("/edit-profile");
}}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Settings
</MenuItem>
<MenuItem
href="/logout"
onClick={(e) => {
e.syntheticEvent.preventDefault();
navigate("/logout");
}}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
👋 Logout
</MenuItem>
</Menu>
:
<Button size="sm" color="white" href="/login">
Connect
</Button>
)
}
<div className="relative h-36">
<motion.div
initial={{
opacity: 0,
y: '0'
}}
animate={searchOpen ? {
opacity: 1,
y: '0',
transition: { type: "spring", stiffness: 70 }
} : {
opacity: 0,
y: '-120px',
transition: {
ease: "easeIn"
}
}}
className='absolute top-0 right-0 flex items-center h-full'
>
<Search
width={326}
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
onResultClick={() => setSearchOpen(false)}
/>
</motion.div>
</div>
</div>
</div>

View File

@@ -4,7 +4,6 @@ import { BsChevronDown } from "react-icons/bs";
import { GrClose } from "react-icons/gr";
import Button from "../Button/Button";
import ASSETS from "src/assets";
import Search from "./Search/Search";
import IconButton from "../IconButton/IconButton";
import { useAppSelector } from "src/utils/hooks";
import { FiMenu, } from "react-icons/fi";
@@ -13,8 +12,8 @@ import { useToggle } from "@react-hookz/web";
import styles from './styles.module.css'
import '@szhsin/react-menu/dist/index.css';
import { Menu, MenuButton, MenuItem } from "@szhsin/react-menu";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { createRoute, PAGES_ROUTES } from "src/utils/routing";
import Avatar from "../Avatar/Avatar";
const navBtnVariant = {
menuHide: { rotate: 90, opacity: 0 },
@@ -59,7 +58,7 @@ export default function NavMobile() {
const [communityOpen, toggleCommunityOpen] = useToggle(false)
const { curUser } = useAppSelector((state) => ({
curUser: state.user.me,
curUser: null,
}));
const navigate = useNavigate()
@@ -90,7 +89,7 @@ export default function NavMobile() {
</Link>
</div>
<div className="flex-1 flex justify-end">
{/* <div className="flex-1 flex justify-end">
{curUser ?
<Menu
@@ -134,7 +133,7 @@ export default function NavMobile() {
Connect ⚡
</Button>
}</div>
}</div> */}
</div>
</div>
@@ -154,7 +153,7 @@ export default function NavMobile() {
animate={drawerOpen ? "show" : "hide"}
>
<div className="flex flex-col gap-16 py-16">
<Search onResultClick={() => toggleDrawerOpen(false)} />
{/* <Search onResultClick={() => toggleDrawerOpen(false)} /> */}
</div>
<ul className="flex flex-col py-16 gap-32 border-t">

View File

@@ -1,130 +0,0 @@
import React, { FormEvent, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { BsSearch } from 'react-icons/bs';
import { useClickOutside, useThrottledCallback, useUpdateEffect } from '@react-hookz/web'
import SearchResults from './SearchResults/SearchResults'
import { SearchProjectsQuery, useSearchProjectsLazyQuery } from 'src/graphql';
interface Props {
height?: number | string;
width?: number | string;
onClose?: () => void;
onResultClick?: () => void;
isOpen?: boolean;
}
export type ProjectSearchItem = SearchProjectsQuery['searchProjects'][number];
const SearchResultsListVariants = {
hidden: {
opacity: 0,
y: 300,
display: 'none'
},
visible: {
opacity: 1,
y: 16,
display: 'block'
}
}
export default function Search({
width,
height,
onClose,
onResultClick,
isOpen
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchInput, setSearchInput] = useState("");
const containerRef = useRef<HTMLDivElement>(null)
// const { isOpen } = useAppSelector(state => ({
// isOpen: state.ui.isSearchOpen
// }))
// const dispatch = useAppDispatch()
useClickOutside(containerRef, () => {
onClose?.()
})
const [executeQuery, { data, loading }] = useSearchProjectsLazyQuery()
const throttledExecuteQuery = useThrottledCallback((search: string) => {
executeQuery({
variables: {
search
}
})
}, [executeQuery], 500)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchInput(e.target.value);
throttledExecuteQuery(e.target.value)
}
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
throttledExecuteQuery(searchInput)
// Make Search Request
// onSearch(searchInput);
};
useUpdateEffect(() => {
if (isOpen)
inputRef.current?.focus();
else {
setSearchInput('')
}
}, [isOpen])
return (
<div className="relative z-20 h-full" ref={containerRef}>
{<form
className='flex items-center h-full'
onSubmit={handleSubmit}
style={{
width: width ?? '100%',
height: height ?? '100%'
}}
>
<div className="input-wrapper border-0 !p-16 md:!py-12 !bg-gray-100 focus-within:!bg-gray-50 focus:ring-1 focus:ring-gray-300 !rounded-12">
<BsSearch className={`input-icon w-16 mr-10 !p-0`} />
<input
autoFocus
type='text'
ref={inputRef}
className="input-text placeholder-black !p-0"
placeholder='Search'
value={searchInput}
onChange={handleChange}
onKeyDown={e => {
if (e.key === 'Escape') onClose?.()
}}
/>
</div>
<motion.div
variants={SearchResultsListVariants}
initial='hidden'
animate={
searchInput.length > 0 ? 'visible' : 'hidden'
}
className="absolute top-full translate-y-8 w-full left-0">
<SearchResults
isLoading={loading}
projects={data?.searchProjects}
onResultClick={onResultClick}
/>
</motion.div>
</form>}
</div>
)
}

View File

@@ -1,14 +0,0 @@
import Skeleton from 'react-loading-skeleton'
export default function SearchProjectCardSkeleton() {
return <div
className='p-12 rounded-12 flex items-start gap-16 cursor-pointer'
>
<Skeleton width={40} height={40} containerClassName='flex-shrink-0' />
<div className="min-w-0">
<p className="text-body4 text-black w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap"><Skeleton width="15ch" /></p>
<p className="text-body6 text-gray-600 font-light mt-4"><Skeleton width="10ch" /></p>
</div>
</div>
}

View File

@@ -1,25 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import SearchProjectCard from './SearchProjectCard';
export default {
title: 'Shared/Navbar/Search/Result Project Card',
component: SearchProjectCard,
} as ComponentMeta<typeof SearchProjectCard>;
const Template: ComponentStory<typeof SearchProjectCard> = (args) => <div className="max-w-[326px]">
<SearchProjectCard {...args} />
</div>;
export const Default = Template.bind({});
Default.args = {
project: MOCK_DATA['projects'][0]
}
export const Loading = Template.bind({});
Loading.args = {
loading: true
}

View File

@@ -1,35 +0,0 @@
import SearchProjectCardSkeleton from './SearchProjectCard.Skeleton'
import { ProjectSearchItem } from '../Search';
type Props =
{
loading: true
}
|
{
loading?: false
project: ProjectSearchItem
onClick: (projectId: number) => void;
}
export default function SearchProjectCard(props: Props) {
if (props.loading)
return <SearchProjectCardSkeleton />
return (
<div
className='p-12 rounded-12 hover:bg-gray-100 flex items-start gap-16 cursor-pointer'
onClick={() => props.onClick(props.project.id)}
>
<img src={props.project.thumbnail_image} alt={props.project.title} draggable="false" className="flex-shrink-0 w-40 h-40 bg-gray-200 border-0 rounded-10 object-cover"></img>
<div className="min-w-0">
<p className="text-body4 text-black w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap">{props.project.title}</p>
<p className="text-body6 text-gray-600 font-light mt-4">{props.project.category.title}</p>
</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import SearchResults from './SearchResults';
export default {
title: 'Shared/Navbar/Search/Results List',
component: SearchResults,
} as ComponentMeta<typeof SearchResults>;
const Template: ComponentStory<typeof SearchResults> = (args) => <div className="max-w-[326px]">
<SearchResults {...args} />
</div>;
export const HasData = Template.bind({});
HasData.args = {
projects: MOCK_DATA['projects']
}
export const Loading = Template.bind({});
Loading.args = {
isLoading: true
}

View File

@@ -1,44 +0,0 @@
import { openModal } from 'src/redux/features/modals.slice';
import { useAppDispatch } from 'src/utils/hooks';
import { ProjectSearchItem } from '../Search';
import SearchProjectCard from '../SearchProjectCard/SearchProjectCard';
import styles from './styles.module.css'
interface Props {
isLoading?: boolean;
projects: ProjectSearchItem[] | undefined,
onResultClick?: () => void
}
export default function SearchResults({ projects, isLoading, onResultClick }: Props) {
const dispatch = useAppDispatch();
const handleOpenProject = (projectId: number) => {
onResultClick?.()
dispatch(openModal({ Modal: "ProjectDetailsCard", props: { projectId } }))
}
return (
<div className={`
max-h-[360px] rounded-10 bg-white border border-gray-200 px-8 py-16 overflow-y-scroll shadow-2xl
${styles['search-results']}
`}>
{
isLoading && !projects ?
Array(3).fill(0).map((_, idx) => <SearchProjectCard key={idx} loading />)
:
<>
<p className="text-gray-600 text-body5 px-16 py-8">
{projects?.length} search results
</p>
{
projects?.map(project => <SearchProjectCard key={project.id} project={project} onClick={handleOpenProject} />)
}
</>
}
</div>
)
}

View File

@@ -1,20 +0,0 @@
/* width */
.search-results::-webkit-scrollbar {
width: 4px;
}
/* Track */
.search-results::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
.search-results::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: #aaa;
}
/* Handle on hover */
.search-results::-webkit-scrollbar-thumb:hover {
background-color: #999;
}

View File

@@ -1,11 +0,0 @@
query SearchProjects($search: String!) {
searchProjects(search: $search) {
id
thumbnail_image
title
category {
title
id
}
}
}

View File

@@ -15,7 +15,8 @@ export default function ProtectedRoute({
children,
}: PropsWithChildren<Props>) {
const user = useAppSelector(state => state.user.me);
// const user = useAppSelector(state => state.user.me);
const user = null;
const location = useLocation();

View File

@@ -1,85 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { centerDecorator } from 'src/utils/storybook/decorators';
import { ComponentProps } from 'react'
import VoteButton from './VoteButton';
export default {
title: 'Shared/Vote Button',
component: VoteButton,
decorators: [
centerDecorator
]
} as ComponentMeta<typeof VoteButton>;
const Template: ComponentStory<typeof VoteButton> = (args) => <VoteButton {...args} />;
const onVoteHandler: ComponentProps<typeof VoteButton>['onVote'] = (a, c) => {
setTimeout(() => {
c?.onSuccess?.(10);
c?.onSetteled?.();
}, 2000)
}
export const Default = Template.bind({});
Default.args = {
votes: 540,
onVote: onVoteHandler
}
export const Vertical = Template.bind({});
Vertical.args = {
votes: 540,
onVote: onVoteHandler,
direction: 'vertical'
}
export const Dense = Template.bind({});
Dense.args = {
votes: 540,
onVote: onVoteHandler,
dense: true
}
export const FillTypeUpdown = Template.bind({});
FillTypeUpdown.args = {
votes: 540,
onVote: onVoteHandler,
fillType: 'upDown'
}
export const FillTypeBackground = Template.bind({});
FillTypeBackground.args = {
votes: 540,
onVote: onVoteHandler,
fillType: 'background'
}
export const FillTypeRadial = Template.bind({});
FillTypeRadial.args = {
votes: 540,
onVote: onVoteHandler,
fillType: 'radial'
}
export const NoCounter = Template.bind({});
NoCounter.args = {
votes: 540,
onVote: onVoteHandler,
disableCounter: true,
}
export const CounterReset = Template.bind({});
CounterReset.args = {
votes: 540,
onVote: onVoteHandler,
resetCounterOnRelease: true
}
export const NoShake = Template.bind({});
NoShake.args = {
votes: 540,
onVote: onVoteHandler,
disableShake: true,
}

View File

@@ -1,338 +0,0 @@
import { MdLocalFireDepartment } from 'react-icons/md'
import Button from 'src/Components/Button/Button'
import { useAppSelector, usePressHolder, useResizeListener, useVote } from 'src/utils/hooks'
import { ComponentProps, SyntheticEvent, useRef, useState, useEffect } from 'react'
import styles from './styles.module.scss'
import { random, randomItem, numberFormatter } from 'src/utils/helperFunctions'
import { useDebouncedCallback, useMountEffect, useThrottledCallback } from '@react-hookz/web'
import { UnionToObjectKeys } from 'src/utils/types/utils'
import { Portal } from '../Portal/Portal'
import { ThreeDots } from 'react-loader-spinner'
import { AnimatePresence, motion } from 'framer-motion'
interface Particle {
id: string,
offsetX: number,
offsetY: number,
color: string
animation: 'fly-spark-1' | 'fly-spark-2',
animationSpeed: number,
scale: number
}
type VoteFunction = ReturnType<typeof useVote>['vote']
type Props = {
votes: number,
onVote?: VoteFunction,
onSuccess?: (amount: number) => void
fillType?: 'leftRight' | 'upDown' | "background" | 'radial',
direction?: 'horizontal' | 'vertical'
disableCounter?: boolean
disableShake?: boolean
hideVotesCoun?: boolean
dense?: boolean
size?: 'sm' | 'md'
resetCounterOnRelease?: boolean
} & Omit<ComponentProps<typeof Button>, 'children'>
const btnPadding: UnionToObjectKeys<Props, 'direction', any> = {
horizontal: {
sm: '',
md: '',
} as UnionToObjectKeys<Props, 'size'>,
vertical: {
sm: 'p-8',
md: '',
} as UnionToObjectKeys<Props, 'size'>
}
type BtnState = 'ready' | 'voting' | 'loading' | "success" | "fail";
export default function VoteButton({
votes,
onVote = () => { },
fillType = 'background',
direction = 'horizontal',
disableCounter = false,
disableShake = true,
hideVotesCoun = false,
dense = false,
resetCounterOnRelease = true,
onSuccess,
...props }: Props) {
const [voteCnt, setVoteCnt] = useState(0)
const voteCntRef = useRef(0);
const btnContainerRef = useRef<HTMLDivElement>(null!!)
const [btnShakeClass, setBtnShakeClass] = useState('')
const [sparks, setSparks] = useState<Particle[]>([]);
const [incrementsCount, setIncrementsCount] = useState(0);
const totalIncrementsCountRef = useRef(0)
const currentIncrementsCountRef = useRef(0);
const [increments, setIncrements] = useState<Array<{ id: string, value: number }>>([]);
const [btnPosition, setBtnPosition] = useState<{ top: number, left: number, width: number, height: number }>();
const [btnState, setBtnState] = useState<BtnState>('ready');
const doVote = useDebouncedCallback(() => {
setBtnState('loading');
const amount = voteCntRef.current;
onVote(amount, {
onSuccess: (amount) => {
setBtnState("success");
spawnSparks(10);
onSuccess?.(amount);
},
onError: () => setBtnState('fail'),
onSetteled: () => {
setVoteCnt(v => v - amount);
setTimeout(() => {
setBtnState("ready")
if (resetCounterOnRelease) {
setIncrementsCount(0);
totalIncrementsCountRef.current = 0;
currentIncrementsCountRef.current = 0;
}
voteCntRef.current = 0;
}, 2000);
}
});
}, [], 1500);
const spawnSparks = (cnt = 5) => {
const newSparks = Array(cnt).fill(0).map((_, idx) => ({
id: (Math.random() + 1).toString(),
offsetX: random(-10, 99),
offsetY: random(10, 90),
animation: randomItem(styles.fly_spark_1, styles.fly_spark_1) as any,
animationSpeed: randomItem(1, 1.5, 2),
color: `hsl(0deg 86% ${random(50, 63)}%)`,
scale: random(1, 1.5)
} as const))
// if on mobile screen, reduce number of sparks particles to 60%
setSparks(oldSparks => [...oldSparks, ...newSparks])
setTimeout(() => {
setSparks(s => {
return s.filter(spark => !newSparks.some(newSpark => newSpark.id === spark.id))
})
}, 2 * 1000)
}
const clickIncrement = () => {
if (!disableShake)
setBtnShakeClass(s => s === styles.clicked_2 ? styles.clicked_1 : styles.clicked_2)
const _incStep = Math.ceil((currentIncrementsCountRef.current + 1) / 5);
currentIncrementsCountRef.current += 1;
totalIncrementsCountRef.current += 1;
setIncrementsCount(v => totalIncrementsCountRef.current);
if (!disableCounter)
setIncrements(v => {
const genId = Math.random().toString();
setTimeout(() => {
setIncrements(v => v.filter(e => e.id !== genId))
}, 500)
return [...v, { id: genId, value: _incStep }]
});
setVoteCnt(s => {
const newValue = s + _incStep;
voteCntRef.current = newValue;
return newValue;
})
// Each time the button make 5 increments, spawn some flames
if (totalIncrementsCountRef.current && totalIncrementsCountRef.current % 5 === 0)
spawnSparks(5);
doVote();
}
const onHold = useThrottledCallback(clickIncrement, [], 150)
const { onPressDown, onPressUp } = usePressHolder(onHold, 200);
const handlePressDown = () => {
if (btnState !== 'ready' && btnState !== 'voting') return;
setBtnState('voting');
onPressDown();
}
const handlePressUp = (event?: SyntheticEvent) => {
if (btnState !== 'voting') return;
if (event?.preventDefault) event.preventDefault();
onPressUp();
onHold();
}
const updateParticlesContainerPos = useDebouncedCallback(
() => {
const bodyRect = document.body.getBoundingClientRect();
const btnRect = btnContainerRef.current.getBoundingClientRect()
setBtnPosition({
top: btnRect.top - bodyRect.top,
left: btnRect.left - bodyRect.left,
width: btnRect.width,
height: btnRect.height
});
},
[],
300
)
useEffect(() => {
updateParticlesContainerPos();
document.addEventListener('scroll', updateParticlesContainerPos)
document.addEventListener('resize', updateParticlesContainerPos)
return () => {
document.removeEventListener('scroll', updateParticlesContainerPos)
document.removeEventListener('resize', updateParticlesContainerPos)
}
}, [updateParticlesContainerPos])
// useResizeListener(() => {
// const bodyRect = document.body.getBoundingClientRect();
// const btnRect = btnContainerRef.current.getBoundingClientRect()
// setBtnPosition({
// top: btnRect.top - bodyRect.top,
// left: btnRect.left - bodyRect.left,
// width: btnRect.width,
// height: btnRect.height
// });
// }, { debounce: 300 })
return (
<button
onMouseDown={handlePressDown}
onMouseUp={handlePressUp}
onMouseLeave={() => onPressUp()}
onTouchStart={handlePressDown}
onTouchEnd={handlePressUp}
className={`${styles.vote_button} relative noselect border-0`}
style={{
"--increments": incrementsCount,
"--offset": `${(incrementsCount ? (incrementsCount % 5 === 0 ? 5 : incrementsCount % 5) : 0) * 20}%`,
"--bg-color": fillType !== 'background' ?
'hsl(0deg 86% max(calc((93 - var(--increments) / 3) * 1%), 68%))'
:
"hsl(0deg 86% max(calc((100 - var(--increments) / 2) * 1%), 68%))",
} as any}
{...props}
>
<div
ref={btnContainerRef}
className={`
${styles.btn_content}
relative rounded-lg text-gray-600 ${!incrementsCount && 'bg-gray-50 hover:bg-gray-100'}
${direction === 'vertical' ?
dense ? "py-4 px-12" : "py-8 px-20"
:
dense ? "py-4 px-8" : "p-8 min-w-[80px]"}
${voteCntRef.current > 0 && "outline"} active:outline outline-1 outline-red-500
${btnShakeClass}
`}
>
<div
className={`
${styles.color_overlay}
${fillType === 'upDown' && styles.color_overlay__upDown}
${fillType === 'leftRight' && styles.color_overlay__leftRight}
${fillType === 'background' && styles.color_overlay__background}
${fillType === 'radial' && styles.color_overlay__radial}
`}
>
<div></div>
</div>
<div className={`
relative z-10
${incrementsCount ? "text-red-800" : "text-gray-500"}
flex justify-center items-center gap-8 text-center ${direction === 'vertical' && "flex-col !text-center"}
`}>
<MdLocalFireDepartment
className={`text-body2 ${incrementsCount ? "text-red-600" : "text-gray-400"}`}
/>{!hideVotesCoun && <span className="align-middle w-[4ch]"> {numberFormatter(votes + voteCnt)}</span>}
</div>
<AnimatePresence>
{(btnState === 'loading' || btnState === 'fail') &&
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={styles.loading}>
<ThreeDots width={20} color="#dc2626" />
</motion.div>
}
{btnState === 'success' &&
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={styles.success}>
+{numberFormatter(voteCntRef.current)}
</motion.div>
}
</AnimatePresence>
</div>
<Portal id='effects-container'>
<div
className='absolute pointer-events-none'
style={btnPosition && {
position: 'absolute',
top: btnPosition.top,
left: btnPosition.left,
width: btnPosition.width,
height: btnPosition.height,
pointerEvents: 'none'
}}
>
{increments.map(increment => <span
key={increment.id}
className={styles.vote_counter}
>+{increment.value}</span>)}
{sparks.map(spark =>
<div
key={spark.id}
className={styles.spark}
style={{
"--offsetX": spark.offsetX,
"--offsetY": spark.offsetY,
"--animationSpeed": spark.animationSpeed,
"--scale": spark.scale,
"animationName": spark.animation,
"color": spark.color
} as any}
><MdLocalFireDepartment className='' /></div>)
}
</div>
</Portal>
<div
className={styles.spark}
><MdLocalFireDepartment className='' /></div>
</button>
)
}

View File

@@ -1,219 +0,0 @@
.vote_button {
--scale: 0;
--increments: 0;
--offset: 0;
--bg-color: hsl(0deg 86% max(calc((93 - var(--increments) / 3) * 1%), 68%));
/* transition: background-color 1s; */
/* background-color: hsl(25, 100%, max(calc((95 - var(--scale) / 4) * 1%), 63%)); */
transition: transform 0.1s ease-out;
&:active {
transform: scale(0.9);
}
}
.btn_content.clicked_1 {
animation: shake_1 0.14s 1 ease-in-out;
}
/* Same animation, two classes so that the animation restarts between clicks */
.btn_content.clicked_2 {
animation: shake_2 0.14s 1 ease-in-out;
}
@keyframes shake_1 {
0% {
transform: rotate(0deg);
}
33% {
transform: rotate(calc(clamp(5, var(--increments) / 3, 20) * 1deg));
}
66% {
transform: rotate(calc(-1 * clamp(5, var(--increments) / 2, 20) * 1deg));
}
100% {
transform: rotate(0deg);
}
}
@keyframes shake_2 {
0% {
transform: rotate(0deg);
}
33% {
transform: rotate(calc(clamp(10, var(--increments) / 2, 30) * 1deg));
}
66% {
transform: rotate(calc(-1 * clamp(10, var(--increments) / 2, 30) * 1deg));
}
100% {
transform: rotate(0deg);
}
}
.vote_counter {
position: absolute;
left: 50%;
bottom: 100%;
color: #ff2727;
font-weight: bold;
font-size: 21px;
will-change: transform;
transform: translate(-50%, 0) scale(0.5);
animation: fly_value 0.5s 1 ease-out;
}
.color_overlay {
position: absolute;
border-radius: inherit;
inset: 0;
overflow: hidden;
transition: all 0.1s;
}
.color_overlay > div {
content: "";
background: var(--bg-color);
width: 100%;
height: 100%;
position: absolute;
}
.color_overlay__background > div {
top: 0;
right: 0;
}
.color_overlay__leftRight > div {
top: 0;
right: 100%;
transform: translateX(var(--offset));
}
.color_overlay__upDown > div {
top: 100%;
right: 0;
transform: translateY(calc(-1 * var(--offset)));
}
.color_overlay__radial > div {
top: 0;
right: 0;
background: radial-gradient(
circle at center,
var(--bg-color) var(--offset),
transparent calc(var(--offset) * 1.1)
);
}
.loading {
pointer-events: none;
position: absolute;
border-radius: inherit;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #f9f6f6;
font-size: 14px;
color: #dc2626;
z-index: 10;
}
.success {
pointer-events: none;
position: absolute;
border-radius: inherit;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #f9f6f6;
font-weight: 600;
font-size: 14px;
color: #dc2626;
outline: 1px solid #ef4444;
z-index: 11;
}
@keyframes fly_value {
0% {
transform: translate(-50%, 0) scale(0.5);
opacity: 1;
}
66% {
transform: translate(-50%, -26px) scale(1.2);
opacity: 0.6;
}
100% {
transform: translate(-50%, -38px) scale(0.8);
opacity: 0;
}
}
.spark {
position: absolute;
bottom: calc(var(--offsetY) * 1%);
left: calc(var(--offsetX) * 1%);
transform: scale(var(--scale));
opacity: 0;
will-change: transform;
z-index: 3000;
animation-name: fly-spark-1;
animation-duration: calc(var(--animationSpeed) * 1s);
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes fly_spark_1 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
33% {
transform: translate(12px, -70px) scale(var(--scale));
}
66% {
transform: translate(0, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(6px, -200px) scale(var(--scale));
opacity: 0;
}
}
@keyframes fly_spark_2 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
50% {
transform: translate(-10px, -80px) scale(var(--scale));
}
80% {
transform: translate(-4px, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(-6px, -160px) scale(var(--scale));
opacity: 0;
}
}

View File

@@ -1,207 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Helmet } from "react-helmet";
import { Grid } from "react-loader-spinner";
import { useNavigate, useLocation } from "react-router-dom";
import { useMeQuery } from "src/graphql"
import { CONSTS } from "src/utils";
import { QRCodeSVG } from 'qrcode.react';
import { IoRocketOutline } from "react-icons/io5";
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';
export const useLnurlQuery = () => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<any>(null);
const [data, setData] = useState<{ lnurl: string, session_token: string }>({ lnurl: '', session_token: '' })
useEffect(() => {
let timeOut: NodeJS.Timeout;
const doFetch = async () => {
const res = await fetchLnurlAuth();
if (!res?.encoded)
setError(new Error("Response doesn't contain data"))
else {
setLoading(false);
setData({
lnurl: res.encoded,
session_token: res.session_token
});
timeOut = setTimeout(doFetch, 1000 * 60 * 2)
}
}
doFetch().catch(err => setError(err));
return () => clearTimeout(timeOut)
}, [])
return {
loadingLnurl: loading,
error,
data
}
}
export default function LoginPage() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const [copied, setCopied] = useState(false);
const canFetchIsLogged = useRef(true)
const { loadingLnurl, data: { lnurl, session_token }, error } = useLnurlQuery();
useErrorHandler(error)
const clipboard = useCopyToClipboard()
useEffect(() => {
setCopied(false);
}, [lnurl])
const meQuery = useMeQuery({
onCompleted: (data) => {
if (data.me) {
setIsLoggedIn(true);
meQuery.stopPolling();
setTimeout(() => {
const cameFrom = getPropertyFromUnknown(location.state, 'from');
const navigateTo = cameFrom ? cameFrom : '/'
navigate(navigateTo)
}, 2000)
}
}
});
const copyToClipboard = () => {
setCopied(true);
clipboard(lnurl);
}
const refetch = meQuery.refetch;
const startPolling = useCallback(
() => {
const interval = setInterval(() => {
if (canFetchIsLogged.current === false) return;
canFetchIsLogged.current = false;
fetchIsLoggedIn(session_token)
.then(is_logged_in => {
if (is_logged_in) {
clearInterval(interval)
refetch();
}
})
.catch()
.finally(() => {
canFetchIsLogged.current = true;
})
}, 2000);
return interval;
}
, [refetch, session_token],
)
useEffect(() => {
let interval: NodeJS.Timer;
if (lnurl)
interval = startPolling();
return () => {
canFetchIsLogged.current = true;
clearInterval(interval)
}
}, [lnurl, startPolling])
let content = <></>
if (error)
content = <div className="flex flex-col gap-24 items-center">
<p className="text-body3 text-red-500 font-bold">Something wrong happened...</p>
<a href='/login' className="text body4 text-gray-500 hover:underline">Refresh the page</a>
</div>
else if (loadingLnurl)
content = <div className="flex flex-col gap-24 items-center">
<Grid color="var(--primary)" width="150" />
<p className="text-body3 font-bold">Fetching Lnurl-Auth...</p>
</div>
else if (isLoggedIn)
content = <div className="flex flex-col justify-center items-center">
<h3 className="text-body4">
Hello: <span className="font-bold">@{trimText(meQuery.data?.me?.name, 10)}</span>
</h3>
<img src={meQuery.data?.me?.avatar} className='w-80 h-80 object-cover rounded-full outline outline-2 outline-gray-200' alt="" />
</div>
else
content = <div className="max-w-[442px] bg-white border-2 border-gray-200 rounded-16 p-16 flex flex-col gap-24 items-center" >
<h2 className='text-h5 font-bold text-center'>Login with lightning </h2>
<a href={`lightning:${lnurl}`} >
<QRCodeSVG
width={280}
height={280}
value={lnurl}
bgColor='transparent'
imageSettings={{
src: '/assets/images/nut_3d.png',
width: 16,
height: 16,
excavate: true,
}}
/>
</a>
<p className="text-gray-600 text-body4 text-center">
Scan this code or copy + paste it to your lightning wallet. Or click to login with your browser's wallet.
</p>
<div className="w-full grid md:grid-cols-2 gap-16">
<a href={`lightning:${lnurl}`}
className='block text-body4 text-center text-white bg-primary-500 hover:bg-primary-600 rounded-10 px-16 py-12 active:scale-90 transition-transform'
>Click to connect <IoRocketOutline /></a>
<Button
color='gray'
onClick={copyToClipboard}
>{copied ? "Copied" : "Copy"} <FiCopy /></Button>
<a href={`https://makers.bolt.fun/story/sign-in-with-lightning--99`} target='_blank' rel="noreferrer"
className='md:col-span-2 block text-body4 text-center text-gray-900 border border-gray-200 rounded-10 px-16 py-12 active:scale-90 transition-transform'
>What is a lightning wallet?</a>
</div>
</div>;
return (
<>
<Helmet>
<title>{`makers.bolt.fun`}</title>
<meta property="og:title" content={`makers.bolt.fun`} />
</Helmet>
<div className="page-container">
<div className="min-h-[80vh] flex flex-col justify-center items-center">
{content}
</div>
</div>
</>
)
}

View File

@@ -1,33 +0,0 @@
import { useEffect } from "react"
import { LineWave } from "react-loader-spinner";
import { useNavigate } from "react-router-dom";
import { CONSTS } from "src/utils";
export default function LoginPage() {
const navigate = useNavigate();
useEffect(() => {
fetch(CONSTS.apiEndpoint + '/logout', {
method: "GET",
'credentials': "include"
})
.then(() => {
window.location.pathname = '/'
})
.catch(() => {
window.location.pathname = '/'
})
}, [navigate])
return (
<div className="min-h-[80vh] flex flex-col justify-center items-center">
<p className="text-body-2 text-gray-800">
Logging you out...
</p>
<LineWave color="var(--primary)" width="150" />
</div>
)
}

View File

@@ -1,10 +0,0 @@
query Me {
me {
id
name
avatar
join_date
jobTitle
bio
}
}

View File

@@ -1,13 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import DonateCard from './DonateCard';
export default {
title: 'Donations/Componets/Donate Card',
component: DonateCard,
} as ComponentMeta<typeof DonateCard>;
const Template: ComponentStory<typeof DonateCard> = (args) => <div className="max-w-[326px]"><DonateCard {...args as any} /></div>;
export const Default = Template.bind({});

View File

@@ -1,98 +0,0 @@
import React, { FormEvent, useState } from 'react';
import { PaymentStatus, } from 'src/utils/hooks';
import Confetti from "react-confetti";
import { useWindowSize } from '@react-hookz/web';
import { useDonate } from './useDonate';
const defaultOptions = [
{ text: '500', value: 500 },
{ text: '1,000', value: 1000 },
{ text: '5,000', value: 5000 },
{ text: '25,000', value: 25000 },
]
export default function DonateCard() {
const size = useWindowSize();
const [donationAmount, setDonationAmount] = useState("");
const { donate, paymentStatus, isLoading } = useDonate()
const onChangeInput = (event: React.ChangeEvent<HTMLInputElement>) => {
setDonationAmount(event.target.value);
};
const onSelectOption = (idx: number) => {
setDonationAmount(defaultOptions[idx].value.toString());
}
const requestPayment = (e: FormEvent) => {
e.preventDefault();
if (Number(donationAmount))
donate(Number(donationAmount), {
onSuccess: () => {
setTimeout(() => {
setDonationAmount("");
}, 4000);
},
onError: () => {
setTimeout(() => {
setDonationAmount("");
}, 4000);
}
});
}
return (
<div
className="bg-gray-50 border w-full shadow-2xl p-24 rounded-xl relative"
>
<h2 className='text-h5 font-bold'>Donate to BOLT🔩FUN</h2>
<form onSubmit={requestPayment} className="mt-32 ">
<div className="input-wrapper">
<input
className={`input-text input-removed-arrows`}
value={donationAmount} onChange={onChangeInput}
type="number"
placeholder="Select or enter amount"
autoFocus
/>
<p className='px-16 shrink-0 self-center text-primary-400'>
sats
</p>
</div>
<div className="flex mt-16 justify-between">
{defaultOptions.map((option, idx) =>
<button
type='button'
key={idx}
className={`btn border-0 px-12 rounded-md py-8 text-body5 bg-primary-50 hover:bg-primary-100 outline-2 outline-primary-200 active:outline `}
onClick={() => onSelectOption(idx)}
>
{option.text}
</button>
)}
</div>
<button
type='submit'
className="btn btn-primary w-full mt-32"
disabled={isLoading}
>
{!isLoading ? "Make a donation" : "Donating..."}
</button>
<div className="mt-12 text-center">
{paymentStatus === PaymentStatus.FETCHING_PAYMENT_DETAILS && <p className="text-body6 text-yellow-500">Please wait while we fetch payment details.</p>}
{paymentStatus === PaymentStatus.NOT_PAID && <p className="text-body6 text-red-500">You did not confirm the payment. Please try again.</p>}
{paymentStatus === PaymentStatus.CANCELED && <p className="text-body6 text-red-500">Payment canceled by user.</p>}
{paymentStatus === PaymentStatus.NETWORK_ERROR && <p className="text-body6 text-red-500">A network error happened while fetching data.</p>}
{paymentStatus === PaymentStatus.PAID && <p className="text-body6 text-green-500">The invoice was paid! Please wait while we confirm it.</p>}
{paymentStatus === PaymentStatus.AWAITING_PAYMENT && <p className="text-body6 text-yellow-500">Waiting for your payment...</p>}
{paymentStatus === PaymentStatus.PAYMENT_CONFIRMED && <p className="text-body6 text-green-500">Thanks for your vote</p>}
</div>
</form>
{paymentStatus === PaymentStatus.PAYMENT_CONFIRMED && <Confetti className='!fixed top-0 left-0' recycle={false} width={size.width} height={size.height} />}
</div>
)
}

View File

@@ -1,88 +0,0 @@
import { useCallback, useState } from 'react';
import { useConfirmDonationMutation, useDonateMutation } from 'src/graphql';
import { Wallet_Service } from 'src/services';
import { PaymentStatus } from 'src/utils/hooks';
export const useDonate = () => {
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(PaymentStatus.DEFAULT);
const [donateMutation] = useDonateMutation();
const [confirmDonation] = useConfirmDonationMutation();
const donate = useCallback((amount: number, config?: Partial<{
onSuccess: () => void,
onError: (error: any) => void,
onSetteled: () => void
}>) => {
Wallet_Service.getWebln()
.then(webln => {
if (!webln) {
config?.onError?.(new Error('No WebLN Detetcted'))
config?.onSetteled?.()
return
}
setPaymentStatus(PaymentStatus.FETCHING_PAYMENT_DETAILS)
donateMutation({
variables: {
amountInSat: amount
},
onCompleted: async (donationData) => {
try {
setPaymentStatus(PaymentStatus.AWAITING_PAYMENT);
const paymentResponse = await webln.sendPayment(donationData.donate.payment_request);
setPaymentStatus(PaymentStatus.PAID);
//Confirm Voting payment
confirmDonation({
variables: {
paymentRequest: donationData.donate.payment_request,
preimage: paymentResponse.preimage
},
onCompleted: () => {
setPaymentStatus(PaymentStatus.PAYMENT_CONFIRMED);
config?.onSuccess?.();
config?.onSetteled?.()
},
onError: (error) => {
console.log(error)
setPaymentStatus(PaymentStatus.NETWORK_ERROR);
config?.onError?.(error);
config?.onSetteled?.();
alert("A network error happened while confirming the payment...")
},
refetchQueries: [
'DonationsStats'
]
})
} catch (error) {
setPaymentStatus(PaymentStatus.CANCELED);
config?.onError?.(error);
config?.onSetteled?.();
alert("Payment rejected by user")
}
},
onError: (error) => {
console.log(error);
setPaymentStatus(PaymentStatus.NETWORK_ERROR);
config?.onError?.(error);
config?.onSetteled?.();
alert("A network error happened...")
}
})
})
}, [confirmDonation, donateMutation]);
const isLoading = paymentStatus !== PaymentStatus.DEFAULT && paymentStatus !== PaymentStatus.PAYMENT_CONFIRMED && paymentStatus !== PaymentStatus.NOT_PAID && paymentStatus !== PaymentStatus.NETWORK_ERROR && paymentStatus !== PaymentStatus.CANCELED
return {
paymentStatus,
donate,
isLoading
}
}

View File

@@ -1 +0,0 @@
export * from './pages/DonatePage/DonatePage'

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import DonatePage from './DonatePage';
export default {
title: 'Donations/Donate Page/Page',
component: DonatePage,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof DonatePage>;
const Template: ComponentStory<typeof DonatePage> = (args) => <DonatePage {...args as any} ></DonatePage>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,61 +0,0 @@
import { Helmet } from "react-helmet";
import Accordion from "src/Components/Accordion/Accordion";
import Header from "./Header/Header";
import styles from './styles.module.scss'
export default function DonatePage() {
return (
<>
<Helmet>
<title>{'Donate To Bolt.Fun'}</title>
<meta property="og:title" content={'Donate To Bolt.Fun'} />
</Helmet>
<div
className={`w-full bg-white`}
>
<Header />
<div className={`${styles.faq}`}>
<div>
<h2 className="text-h3 font-bolder mb-32">
FAQs
</h2>
<Accordion
items={[
{
heading: "How are donations spent?",
content: <p className=" whitespace-pre-line">
Donations that are sent to us directly via our Donate page are used to help fund the design and development of BOLT🔩FUN's Makers platform, as well as helping to fund our tournament and hackathon prize pools.
</p>
},
{
heading: "Who is working on BOLT🔩FUN?",
content: <p className=" whitespace-pre-line">
BOLT🔩FUN is an open-source project, so technically anyone can work on the platform's features & upgrades. That being said, the project was started by a core team of designers and developers from Peak Shift, a bitcoin only product design and development studio.
<br />
<br />
If you are interested in helping contribute to BOLT🔩FUN, feel free to <a href='https://discord.gg/HFqtxavb7x' className="text-blue-400 underline" target='_blank' rel="noreferrer" >hop into our Discord</a> and let us know how you'd like to help.
</p>
},
{
heading: "How can makers win prizes?",
content: <p className=" whitespace-pre-line">
Makers can win prizes through one of BOLT🔩FUN's online #ShockTheWeb hackathons. These hackathons provide an opportunity for makers to get hands on experience learning to build lightning enabled web applications in a fun, collaborative, and supportive environment.
<br />
<br />
Later on, we'd like to create ongoing monthly tournaments & prizes for makers who regularly submit standup reporting (plans, problems, and progress) on their current projects, as well as being active on our platform through Stories, Discussions, and voting.
</p>
},
{
heading: "How can I donate?",
content: <p className=" whitespace-pre-line">
Currently we are only accepting lightning donations through WebLN. To do this, you will first need to install the Alby extension on your browser. Once you've finished setting up your wallet, you can send us some sats using our donation widget, you'll have to confirm the transaction within your Alby extension.
</p>
},
]}
/>
</div>
</div>
</div></>
)
}

View File

@@ -1,47 +0,0 @@
import { BiCoinStack } from "react-icons/bi";
import { FiAward, FiGrid } from "react-icons/fi";
import { IoMedalOutline, IoRocketOutline } from "react-icons/io5";
import { useDonationsStatsQuery } from "src/graphql";
import { generateList, numberFormatter } from "src/utils/helperFunctions";
import StatCard from "../StatCard/StatCard";
import StatCardSkeleton from "../StatCard/StatCard.Skeleton";
export default function DonationStats() {
const donationsStatQuery = useDonationsStatsQuery();
return (
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-16">
{donationsStatQuery.loading && generateList(<StatCardSkeleton />, 4)}
{!donationsStatQuery.loading &&
<>
<StatCard
color="#8B5CF6"
label={<><BiCoinStack className='w-full lg:w-auto scale-125 mr-8' /> <span className="align-middle">Donations</span></>}
value={<>{numberFormatter(Number(donationsStatQuery.data?.getDonationsStats.donations))} < span className="text-body4">Sats</span></>}
/>
<StatCard
color="#F59E0B"
label={<><IoRocketOutline className='w-full lg:w-auto scale-125 mr-8' /> <span className="align-middle">Tournaments</span></>}
value={donationsStatQuery.data?.getDonationsStats.touranments}
/>
<StatCard
color="#22C55E"
label={<><FiAward className='w-full lg:w-auto scale-125 mr-8' /> <span className="align-middle">Prizes</span></>}
value={donationsStatQuery.data?.getDonationsStats.prizes}
/>
<StatCard
color="#3B82F6"
label={<><FiGrid className='w-full lg:w-auto scale-125 mr-8' /> <span className="align-middle">Applications</span></>}
value={donationsStatQuery.data?.getDonationsStats.applications}
/>
</>
}
</div>
)
}

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { FiGrid } from 'react-icons/fi'
import DonationStats from './DonationStats';
export default {
title: 'Donations/Donate Page/DonationStats',
component: DonationStats,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof DonationStats>;
const Template: ComponentStory<typeof DonationStats> = (args) => <div className="max-w-[910px] mx-auto"><DonationStats {...args as any} ></DonationStats></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,8 +0,0 @@
query DonationsStats {
getDonationsStats {
prizes
touranments
donations
applications
}
}

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Header from './Header';
export default {
title: 'Donations/Donate Page/Header',
component: Header,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Header>;
const Template: ComponentStory<typeof Header> = (args) => <Header {...args as any} ></Header>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,29 +0,0 @@
import { BiCoinStack } from 'react-icons/bi'
import DonateCard from 'src/features/Donations/components/DonateCard/DonateCard'
import DonationStats from '../DonationStats/DonationStats'
import styles from './styles.module.scss'
export default function Header() {
return (
<div className={`${styles.header}`}>
<div className='min-w-0'>
<div className="flex items-center gap-24 flex-col md:flex-row">
<div>
<h1 className="text-[54px] font-bolder">
Donate <BiCoinStack className='ml-8' />
</h1>
<p className='text-h3 font-bolder mt-24'>
Help fund <span className="text-primary-600">BOLT🔩FUN</span>, as well as other <span className="text-primary-600">Makers</span> working on lightning apps through tournaments and prize pools
</p>
</div>
<div className="max-w-[326px]">
<DonateCard />
</div>
</div>
<div className="mt-52 md:mt-80">
<DonationStats />
</div>
</div>
</div>
)
}

View File

@@ -1,30 +0,0 @@
@import "/src/styles/mixins";
.header {
padding: 56px 0;
min-height: calc(min(1080px, 90vh));
background: #ffecf9;
background: linear-gradient(40deg, white 7%, #ffdadaa3 62%, #d0f6ff5c 96%);
background-size: 120% 120%;
animation: Animation 3s ease infinite;
display: grid;
grid-template-areas: ". content .";
grid-template-columns: minmax(16px, 1fr) minmax(auto, 910px) minmax(16px, 1fr);
& > div {
grid-area: content;
}
}
@keyframes Animation {
0% {
background-position: 0% 20%;
}
50% {
background-position: 20% 0%;
}
100% {
background-position: 0% 20%;
}
}

View File

@@ -1,15 +0,0 @@
import Skeleton from 'react-loading-skeleton'
export default function StatCardSkeleton() {
return (
<div className="bg-white p-24 rounded-16 text-center" >
<p className="text-body4">
<Skeleton width={'10ch'} />
</p>
<p className="text-h2 mt-8">
<Skeleton width={'4ch'} />
</p>
</div>
)
}

View File

@@ -1,29 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { FiGrid } from 'react-icons/fi'
import StatCard from './StatCard';
import StatCardSkeleton from './StatCard.Skeleton';
export default {
title: 'Donations/Donate Page/StatCard',
component: StatCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof StatCard>;
const Template: ComponentStory<typeof StatCard> = (args) => <div className="max-w-[220px]"><StatCard {...args} ></StatCard></div>
export const Default = Template.bind({});
Default.args = {
color: "#3B82F6",
label: <><FiGrid className='scale-125 mr-8' /> Applications</>,
value: '36'
}
const LoadingTemplate: ComponentStory<typeof StatCard> = (args) => <div className="max-w-[220px]"><StatCardSkeleton ></StatCardSkeleton></div>
export const Loading = LoadingTemplate.bind({});
Loading.args = {
}

View File

@@ -1,24 +0,0 @@
import { ReactNode } from 'react'
interface Props {
label: ReactNode,
value: ReactNode,
color: string
}
export default function StatCard(props: Props) {
return (
<div className="bg-white p-24 rounded-16 text-center"
style={{
color: props.color,
}}
>
<p className="text-body4">
{props.label}
</p>
<p className="text-h4 sm:text-h2 mt-8 font-bolder whitespace-nowrap">
{props.value}
</p>
</div>
)
}

View File

@@ -1,16 +0,0 @@
mutation Donate($amountInSat: Int!) {
donate(amount_in_sat: $amountInSat) {
id
amount
payment_request
payment_hash
}
}
mutation ConfirmDonation($paymentRequest: String!, $preimage: String!) {
confirmDonation(payment_request: $paymentRequest, preimage: $preimage) {
id
amount
paid
}
}

View File

@@ -1,16 +0,0 @@
@import "/src/styles/mixins";
.faq {
padding: 40px 0;
display: grid;
grid-template-areas: ". content .";
grid-template-columns: minmax(16px, 1fr) minmax(auto, 910px) minmax(16px, 1fr);
& > div {
grid-area: content;
}
@include gt-md {
padding: 80px 0;
}
}

View File

@@ -1,38 +0,0 @@
import { Hackathon } from "src/features/Hackathons/types"
import { IoLocationOutline } from 'react-icons/io5'
import Button from "src/Components/Button/Button"
import Skeleton from "react-loading-skeleton"
export default function HackathonCardSkeleton() {
return (
<div className="rounded-16 bg-white overflow-hidden">
<div className="w-full h-[120px] bg-gray-200" />
<div className="p-16">
<div className="flex flex-col gap-8">
<h3 className="text-body1 font-bold text-gray-900">
<Skeleton width={'100%'} />
</h3>
<p className="text-body3 font-medium text-gray-900">
<Skeleton width={'100%'} />
</p>
<p className="text-body4 font-medium text-gray-600">
<Skeleton width={'50%'} />
</p>
<p className="text-body4 text-gray-600">
<Skeleton width={'100%'} />
<Skeleton width={'40%'} />
</p>
</div>
<div className="mt-16 flex flex-wrap gap-8">
<div className="p-8 bg-gray-50 rounded-8 w-[92px] h-36">
</div>
<div className="p-8 bg-gray-50 rounded-8 w-[92px] h-36">
</div>
</div>
<div className="bg-gray-100 h-[56px] mt-16 rounded-lg">
</div>
</div>
</div>
)
}

View File

@@ -1,31 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import HackathonCard from './HackathonCard';
import HackathonCardSkeleton from './HackathonCard.Skeleton';
export default {
title: 'Hackathons/Components/Hackathon Card',
component: HackathonCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonCard>;
const Template: ComponentStory<typeof HackathonCard> = (args) => <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))]"><HackathonCard {...args} ></HackathonCard></div>
export const Default = Template.bind({});
Default.args = {
hackathon: MOCK_DATA['hackathons'][0]
}
const LoadingTemplate: ComponentStory<typeof HackathonCard> = (args) => <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))]"><HackathonCardSkeleton></HackathonCardSkeleton></div>
export const Loading = LoadingTemplate.bind({});
Loading.args = {
}

View File

@@ -1,53 +0,0 @@
import { Hackathon } from "src/features/Hackathons/types"
import { IoLocationOutline } from 'react-icons/io5'
import Button from "src/Components/Button/Button"
import dayjs from "dayjs";
import advancedFormat from 'dayjs/plugin/advancedFormat'
import { trimText } from "src/utils/helperFunctions";
import { Override } from "src/utils/interfaces";
import { Tag } from "src/graphql";
dayjs.extend(advancedFormat)
export type HackathonCardType = Override<Hackathon,
{
tags: Pick<Tag,
| 'id'
| 'title'
| 'icon'>[]
}
>;
interface Props {
hackathon: HackathonCardType
}
export default function HackathonCard({ hackathon }: Props) {
return (
<div className="rounded-16 bg-white overflow-hidden border-2 flex flex-col">
<img className="w-full h-[120px] object-cover" src={hackathon.cover_image} alt="" />
<div className="p-16 grow flex flex-col">
<div className="flex flex-col gap-8">
<h3 className="text-body1 font-bold text-gray-900">
{hackathon.title}
</h3>
<p className="text-body3 font-medium text-gray-900">
{`${dayjs(hackathon.start_date).format('Do')} - ${dayjs(hackathon.end_date).format('Do MMMM, YYYY')}`}
</p>
<p className="text-body4 font-medium text-gray-600">
<IoLocationOutline className="mr-8" /> {hackathon.location}
</p>
<p className="text-body4 text-gray-600">
{trimText(hackathon.description, 110)}
</p>
</div>
<div className="mt-16 flex flex-wrap gap-8">
{hackathon.tags.map(tag => <div key={tag.id} className="p-8 bg-gray-50 rounded-8 text-body5">{tag.icon} {tag.title}</div>)}
</div>
<div className="mt-auto"></div>
<Button href={hackathon.website} newTab color="gray" fullWidth className="mt-16">
Learn more
</Button>
</div>
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import HackathonsList from './HackathonsList';
export default {
title: 'Hackathons/Components/HackathonsList',
component: HackathonsList,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonsList>;
const Template: ComponentStory<typeof HackathonsList> = (args) => <HackathonsList {...args} ></HackathonsList>
export const Default = Template.bind({});
Default.args = {
items: MOCK_DATA['hackathons']
}

View File

@@ -1,41 +0,0 @@
import { useReachedBottom } from "src/utils/hooks/useReachedBottom"
import { ListComponentProps } from "src/utils/interfaces"
import HackathonCard, { HackathonCardType } from "../HackathonCard/HackathonCard"
import HackathonCardSkeleton from "../HackathonCard/HackathonCard.Skeleton"
type Props = ListComponentProps<HackathonCardType> & {
currentFilter: null | string;
}
export default function HackathonsList(props: Props) {
const { ref } = useReachedBottom<HTMLDivElement>(props.onReachedBottom)
if (props.isLoading)
return <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))] gap-24">
{<>
<HackathonCardSkeleton />
<HackathonCardSkeleton />
<HackathonCardSkeleton />
<HackathonCardSkeleton />
</>
}
</div>
if (props.items?.length === 0)
return <div className="px-32 text-body3 text-gray-600 py-48 text-center relative">
<span className="bg-white px-16">No {props.currentFilter ? props.currentFilter : ""} Hackathons Currently...</span>
{/* <div className="bg-gray-400 w-full h-[2px] absolute top-1/2 left-0 -translate-y-1/2 z-[-1]"></div> */}
</div>
return (
<div ref={ref} className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))] gap-24">
{
props.items?.map(hackathon => <HackathonCard key={hackathon.id} hackathon={hackathon} />)
}
{props.isFetching && <HackathonCardSkeleton />}
</div>
)
}

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import SortBy from './SortByFilter';
export default {
title: 'Hackathons/Components/Filters/Sort By',
component: SortBy,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof SortBy>;
const Template: ComponentStory<typeof SortBy> = (args) => <div className="max-w-[326px]"><SortBy {...args as any} ></SortBy></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,55 +0,0 @@
import React, { useState } from 'react'
import { useMediaQuery } from 'src/utils/hooks';
import { MEDIA_QUERIES } from 'src/utils/theme';
const filters = [
{
text: "All",
value: null
},
{
text: "Upcoming",
value: 'Upcoming'
}, {
text: "Live",
value: 'Live'
}, {
text: "Finished",
value: 'Finished'
},
]
interface Props {
filterChanged?: (newFilter: string | null) => void
}
export default function SortByFilter({ filterChanged }: Props) {
const [selected, setSelected] = useState<string | null>(null);
const filterClicked = (_newValue: string | null) => {
const newValue = selected !== _newValue ? _newValue : null;
setSelected(newValue);
filterChanged?.(newValue);
}
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
return (
<ul className='flex flex-wrap gap-8'>
{filters.map((f, idx) => <li
key={f.value}
className={`
text-primary-600 rounded-48 px-16 py-8 cursor-pointer font-medium text-body5
active:scale-95 transition-transform
${f.value === selected ? 'bg-primary-100' : 'bg-gray-100 hover:bg-gray-200'}`}
onClick={() => filterClicked(f.value)}
role='button'
>
{f.text}
</li>)}
</ul>
)
}

View File

@@ -1,20 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import HackathonsPage from './HackathonsPage';
export default {
title: 'Hackathons/Hackathons Page/Page',
component: HackathonsPage,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonsPage>;
const Template: ComponentStory<typeof HackathonsPage> = (args) => <HackathonsPage {...args as any} ></HackathonsPage>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,86 +0,0 @@
import { useState } from 'react'
import Button from 'src/Components/Button/Button'
import { useGetHackathonsQuery } from 'src/graphql'
import HackathonsList from '../../Components/HackathonsList/HackathonsList'
import SortByFilter from '../../Components/SortByFilter/SortByFilter'
import styles from './styles.module.scss'
import { Helmet } from 'react-helmet'
import { Fulgur } from 'src/Components/Ads/Fulgur'
import { IoLocationOutline } from 'react-icons/io5'
import { Link } from 'react-router-dom'
import { createRoute } from 'src/utils/routing'
import { bannerData } from 'src/features/Projects/pages/ExplorePage/Header/Header'
export default function HackathonsPage() {
const [sortByFilter, setSortByFilter] = useState<string | null>(null)
const [tagFilter, setTagFilter] = useState<number | null>(null)
const hackathonsQuery = useGetHackathonsQuery({
variables: {
sortBy: sortByFilter,
tag: Number(tagFilter)
},
})
return (
<>
<Helmet>
<title>{'Hackathons'}</title>
<meta property="og:title" content={'Hackathons'} />
</Helmet>
<div
className={`page-container`}
>
<div className={`w-full`}>
<Link to={createRoute({ type: "tournament", id: 1 })}>
<div className="rounded-16 min-h-[280px] relative overflow-hidden p-16 md:p-24 flex flex-col items-start justify-end mb-24">
<img
className="w-full h-full object-cover object-center absolute top-0 left-0 z-[-2]"
src={bannerData.img}
alt=""
/>
<div className="w-full h-full object-cover bg-gradient-to-t from-gray-900 absolute top-0 left-0 z-[-1]"></div>
<div className="max-w-[90%]">
{bannerData.title}
</div>
</div>
</Link>
<div className="flex gap-16 flex-wrap my-24 justify-between">
<h1 id='title' className="text-body1 lg:text-h2 font-bolder">{sortByFilter ? sortByFilter : "All"} Events</h1>
<div className="self-center">
<SortByFilter
filterChanged={setSortByFilter}
/></div>
</div>
{/* <aside className='no-scrollbar'>
<div className="flex flex-col gap-24 md:overflow-y-scroll sticky-side-element">
<h1 id='title' className="text-body1 lg:text-h2 font-bolder">Hackathons 🏆</h1>
<SortByFilter
filterChanged={setSortByFilter}
/>
<Button
href='https://airtable.com/shrgXKynON8YWeyyE'
newTab
color='primary'
fullWidth
>
List Your Hackathon
</Button>
<div className="hidden md:block">
<Fulgur />
</div>
</div>
</aside> */}
<main className="self-start">
<HackathonsList
currentFilter={sortByFilter}
isLoading={hackathonsQuery.loading}
items={hackathonsQuery.data?.getAllHackathons} />
</main>
</div>
</div></>
)
}

View File

@@ -1,17 +0,0 @@
query getHackathons($sortBy: String, $tag: Int) {
getAllHackathons(sortBy: $sortBy, tag: $tag) {
id
title
description
cover_image
start_date
end_date
location
website
tags {
id
title
icon
}
}
}

View File

@@ -1,13 +0,0 @@
.grid {
display: grid;
grid-template-columns: 100%;
gap: 24px;
@media screen and (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
> main {
grid-column: 2/5;
}
}
}

View File

@@ -1,3 +0,0 @@
import { Hackathon as ApiHackathon, } from "src/graphql"
export type Hackathon = ApiHackathon

View File

@@ -1 +0,0 @@
export * from './hackathons.interface'

View File

@@ -1,22 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import AddComment from './AddComment';
export default {
title: 'Posts/Components/Comments/Add Comment',
component: AddComment,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof AddComment>;
const Template: ComponentStory<typeof AddComment> = (args) => <div className="max-w-[70ch]"><AddComment {...args} ></AddComment></div>
export const Default = Template.bind({});
Default.args = {
placeholder: "Leave a comment...",
avatar: "https://i.pravatar.cc/150?img=8"
}

View File

@@ -1,131 +0,0 @@
import 'remirror/styles/all.css';
import styles from './styles.module.scss'
import javascript from 'refractor/lang/javascript';
import typescript from 'refractor/lang/typescript';
import {
BoldExtension,
CodeBlockExtension,
CodeExtension,
HardBreakExtension,
ImageExtension,
LinkExtension,
MarkdownExtension,
PlaceholderExtension,
} from 'remirror/extensions';
import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import Toolbar from './Toolbar';
import Button from 'src/Components/Button/Button';
import { InvalidContentHandler } from 'remirror';
interface Props {
initialContent?: string;
placeholder?: string;
avatar: string;
autoFocus?: boolean
onSubmit?: (comment: string) => Promise<boolean>;
}
export default function AddComment({ initialContent, placeholder, avatar, autoFocus, onSubmit }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const linkExtension = useMemo(() => {
const extension = new LinkExtension({ autoLink: true });
extension.addHandler('onClick', (_, data) => {
window.open(data.href, '_blank')?.focus();
return true;
});
return extension;
}, []);
const [isLoading, setIsLoading] = useState(false)
const valueRef = useRef<string>("");
const extensions = useCallback(
() => [
new PlaceholderExtension({ placeholder }),
linkExtension,
new BoldExtension(),
new CodeExtension(),
new CodeBlockExtension({
supportedLanguages: [javascript, typescript]
}),
new ImageExtension({ enableResizing: true }),
new MarkdownExtension({ copyAsMarkdown: false }),
/**
* `HardBreakExtension` allows us to create a newline inside paragraphs.
* e.g. in a list item
*/
new HardBreakExtension(),
],
[linkExtension, placeholder],
);
const onError: InvalidContentHandler = useCallback(({ json, invalidContent, transformers }) => {
// Automatically remove all invalid nodes and marks.
return transformers.remove(json, invalidContent);
}, []);
const { manager, state, onChange, } = useRemirror({
extensions,
stringHandler: 'markdown',
content: initialContent ?? '',
onError,
});
useEffect(() => {
if (autoFocus)
containerRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
}, [autoFocus])
const submitComment = async () => {
setIsLoading(true);
const isSuccess = await onSubmit?.(valueRef.current);
if (isSuccess)
manager.view.updateState(manager.createState({ content: manager.createEmptyDoc() }))
setIsLoading(false);
}
return (
<div className={`remirror-theme ${styles.wrapper} p-24 border-2 border-gray-200 rounded-12 md:rounded-16`} ref={containerRef}>
<Remirror
manager={manager}
state={state}
onChange={e => {
const md = e.helpers.getMarkdown(e.state)
valueRef.current = md;
onChange(e);
}}
autoFocus={autoFocus}
>
<div className="flex gap-16 items-start pb-24 border-b border-gray-200 focus-within:border-primary-500">
<div className="hidden sm:block mt-24 shrink-0"><Avatar width={40} src={avatar} /></div>
<div className="flex-grow">
<EditorComponent
/>
</div>
</div>
<div className="flex flex-wrap gap-16 mt-16">
<Toolbar />
<Button
onClick={submitComment}
color='primary'
className='ml-auto'
isLoading={isLoading}
>
Submit
</Button>
</div>
</Remirror>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import TextEditorComponents from 'src/Components/Inputs/TextEditor';
interface Props {
}
export default function Toolbar() {
return (
<div className='flex gap-36'>
<div className="flex">
<TextEditorComponents.ToolButton cmd='bold' classes={{
button: "w-40 h-40 text-body3 ",
active: "bg-gray-100 text-gray-900",
icon: 'text-body1',
enabled: "text-gray-400 hover:bg-gray-100",
disabled: "text-gray-300"
}} />
<TextEditorComponents.ToolButton cmd='code' classes={{
button: "w-40 h-40 text-body3 ",
active: "bg-gray-100 text-gray-900",
icon: 'text-body1',
enabled: "text-gray-400 hover:bg-gray-100",
disabled: "text-gray-300"
}} />
<TextEditorComponents.ToolButton cmd='codeBlock' classes={{
button: "w-40 h-40 text-body3 ",
active: "bg-gray-100 text-gray-900",
icon: 'text-body1',
enabled: "text-gray-400 hover:bg-gray-100",
disabled: "text-gray-300"
}} />
</div>
</div>
)
}

View File

@@ -1,29 +0,0 @@
.wrapper {
:global{
.ProseMirror {
overflow: hidden;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
min-height: var(--rmr-space-5);
a{
color: rgb(54, 139, 236);
&:hover{
text-decoration: underline;
cursor: pointer;
}
}
}
.ProseMirror,
.ProseMirror:active,
.ProseMirror:focus{
box-shadow: none;
}
}
}

View File

@@ -1,29 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import Comment from './Comment';
export default {
title: 'Posts/Components/Comments/Comment with Replies',
component: Comment,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Comment>;
const Template: ComponentStory<typeof Comment> = (args) => <div className="max-w-[70ch]"><Comment {...args} ></Comment></div>
export const Default = Template.bind({});
Default.args = {
comment: {
...MOCK_DATA.generatePostComments(1)[0],
created_at: Date.now(),
replies: [
{ ...MOCK_DATA.generatePostComments(1)[0], replies: [], created_at: Date.now() },
{ ...MOCK_DATA.generatePostComments(1)[0], replies: [], created_at: Date.now() }
]
}
}

View File

@@ -1,95 +0,0 @@
import { useToggle } from "@react-hookz/web";
import { useEffect, useRef, useState } from "react";
import { FaChevronDown, FaChevronUp } from "react-icons/fa";
import Button from "src/Components/Button/Button";
import { useAppSelector } from "src/utils/hooks";
import AddComment from "../AddComment/AddComment";
import CommentCard from "../CommentCard/CommentCard";
import { CommentWithReplies } from "../types";
interface Props {
comment: CommentWithReplies
isRoot?: boolean;
canReply: boolean;
onClickedReply?: () => void;
onReply?: (text: string) => void
}
export default function Comment({ comment, canReply, isRoot, onClickedReply, onReply }: Props) {
const [replyOpen, setReplyOpen] = useState(false);
const [repliesCollapsed, toggleRepliesCollapsed] = useToggle(true)
const [scrollToLatestReply, setScrollToLatestReply] = useState(true);
const repliesContainer = useRef<HTMLDivElement>(null!)
const user = useAppSelector(s => s.user.me);
useEffect(() => {
if (repliesCollapsed)
setReplyOpen(false);
}, [repliesCollapsed])
useEffect(() => {
if (scrollToLatestReply) {
repliesContainer.current?.querySelector(`:scope > div:nth-child(${comment.replies.length})`)?.scrollIntoView({ behavior: 'smooth', block: "center" })
setScrollToLatestReply(false);
}
}, [comment.replies.length, scrollToLatestReply])
const clickReply = () => {
if (isRoot)
setReplyOpen(true);
else
onClickedReply?.()
}
const handleReply = async (text: string) => {
try {
await onReply?.(text);
toggleRepliesCollapsed(false);
setReplyOpen(false);
setScrollToLatestReply(true)
return true;
} catch (error) {
return false;
}
}
return (
<div >
<CommentCard canReply={canReply} comment={comment} onReply={clickReply} />
{(comment.replies.length > 0 || replyOpen) && <div className="flex mt-16 gap-8 md:gap-20 pl-8">
<div className="border-l-2 border-b-2 border-gray-200 w-16 md:w-24 h-40 rounded-bl-8 flex-shrink-0"></div>
<div className="flex flex-col w-full gap-16">
{comment.replies.length > 0 &&
<Button color="none" className="self-start mt-12 !px-0" onClick={() => toggleRepliesCollapsed()}>
{repliesCollapsed ?
<span className="text-gray-600"><span className="align-middle">Show {comment.replies.length} replies</span> <FaChevronDown className="ml-12" /></span>
:
<span className="text-gray-600"><span className="align-middle">Hide replies</span> <FaChevronUp className="ml-12" /> </span>
}
</Button>}
<div
className="flex flex-col gap-16 w-full"
ref={repliesContainer}
>
{!repliesCollapsed && comment.replies.map(reply => <Comment
key={reply.id}
comment={reply}
onClickedReply={clickReply}
canReply={!!isRoot}
/>)}
{replyOpen && <AddComment
avatar={user?.avatar!}
autoFocus
placeholder="Leave a reply..."
onSubmit={handleReply}
/>}
</div>
</div>
</div>}
</div>
)
}

View File

@@ -1,23 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import CommentCard from './CommentCard';
export default {
title: 'Posts/Components/Comments/CommentCard',
component: CommentCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof CommentCard>;
const Template: ComponentStory<typeof CommentCard> = (args) => <div className="max-w-[70ch]"><CommentCard {...args} ></CommentCard></div>
export const Default = Template.bind({});
Default.args = {
comment: MOCK_DATA.generatePostComments(1)[0]
}

View File

@@ -1,51 +0,0 @@
import { marked } from "marked";
import { BiComment } from "react-icons/bi";
import VoteButton from "src/Components/VoteButton/VoteButton";
import Header from "src/features/Posts/Components/PostCard/Header/Header";
import { Comment } from "../types";
import DOMPurify from 'dompurify';
import { Vote_Item_Type } from "src/graphql";
import { useVote } from "src/utils/hooks";
import { useState } from "react";
import Card from "src/Components/Card/Card";
interface Props {
comment: Comment
canReply?: boolean;
onReply?: () => void
}
export default function CommentCard({ comment, canReply, onReply }: Props) {
const [votesCount, setVotesCount] = useState(comment.votes_count);
const { vote } = useVote({
itemId: comment.id,
itemType: Vote_Item_Type.PostComment,
});
return (
<Card>
<Header author={comment.author} date={new Date(comment.created_at).toISOString()} />
<div
className="text-body4 mt-16 whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked.parse(comment.body)) }}
>
</div>
<div className="flex gap-24 mt-16 items-center">
<VoteButton
votes={votesCount}
onVote={vote}
onSuccess={(amount) => setVotesCount(s => s + amount)}
/>
{canReply && <button
className="text-gray-600 font-medium hover:bg-gray-100 py-8 px-12 rounded-8"
onClick={onReply}
>
<BiComment /> <span className="align-middle text-body5">Reply</span>
</button>}
</div>
</Card>
)
}

View File

@@ -1,21 +0,0 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import CommentsSection from './CommentsSection';
export default {
title: 'Posts/Components/Comments/CommentsSection',
component: CommentsSection,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof CommentsSection>;
const Template: ComponentStory<typeof CommentsSection> = (args) => <div className="max-w-[70ch]"><CommentsSection {...args} ></CommentsSection></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -1,93 +0,0 @@
import { useState } from 'react'
import CommentRoot from '../Comment/Comment'
import AddComment from '../AddComment/AddComment'
import { useAppSelector } from "src/utils/hooks";
import { Post_Type } from 'src/graphql'
import useComments from './useComments'
import IconButton from 'src/Components/IconButton/IconButton'
import { AiOutlineClose } from 'react-icons/ai'
import { Link, useLocation } from 'react-router-dom'
import { createRoute, PAGES_ROUTES } from 'src/utils/routing'
import Preferences from 'src/services/preferences.service'
import Card from 'src/Components/Card/Card';
// const createWorker = createWorkerFactory(() => import('./comments.worker'));
interface Props {
type: Post_Type,
id: number | string
};
export default function CommentsSection({ type, id }: Props) {
const user = useAppSelector(state => state.user.me);
const [showTooltip, setShowTooltip] = useState(Preferences.get('showNostrCommentsTooltip'));
const location = useLocation()
const { commentsTree, postComment, connectionStatus } = useComments({ type, id })
const handleNewComment = async (content: string, parentId?: string) => {
try {
await postComment({ content, parentId });
return true;
} catch (error) {
return false
}
}
const closeTooltip = () => {
Preferences.update('showNostrCommentsTooltip', false);
setShowTooltip(false);
}
return (
<Card onlyMd>
<div className="flex flex-wrap justify-between">
<h6 className="text-body2 font-bolder">Discussion</h6>
{connectionStatus.status === 'Connected' && <div className="bg-green-50 text-green-500 text-body5 font-medium py-4 px-12 rounded-48"> &#8226; <span className="hidden md:inline">Connected to {connectionStatus.connectedRelaysCount} relays</span> 📡</div>}
{connectionStatus.status === 'Connecting' && <div className="bg-amber-50 text-amber-500 text-body5 font-medium py-4 px-12 rounded-48"> &#8226; <span className="hidden md:inline">Connecting to relays</span> </div>}
{connectionStatus.status === 'Not Connected' && <div className="bg-red-50 text-red-500 text-body5 font-medium py-4 px-12 rounded-48"> &#8226; <span className="hidden md:inline">Not connected</span> 📡</div>}
</div>
{showTooltip && <div className="bg-gray-900 text-white p-16 rounded-12 my-24 flex items-center justify-between gap-8 md:gap-12">
<span>💬</span>
<p className="text-body4 font-medium">Learn about <Link to={createRoute({ type: "story", title: "Comments Powered By Nostr", id: 54 })} className='underline'>how your data is stored</Link> with Nostr comments and relays</p>
<IconButton className='shrink-0 self-start' onClick={closeTooltip}><AiOutlineClose className='text-gray-600' /></IconButton>
</div>}
{<div className="mt-24 relative">
<div className={!user ? "blur-[2px]" : ""}>
<AddComment
placeholder='Leave a comment...'
onSubmit={content => handleNewComment(content)}
avatar={user?.avatar ?? 'https://avatars.dicebear.com/api/bottts/Default.svg'}
/>
</div>
{!user && <div className="absolute inset-0 bg-gray-400 bg-opacity-50 rounded-12 flex flex-col justify-center items-center">
<Link
className='bg-white rounded-12 px-24 py-12'
to={PAGES_ROUTES.auth.login}
state={{
from: location.pathname
}}
>Connect with to comment</Link>
</div>}
</div>}
<div className='flex flex-col gap-16 mt-32'>
{commentsTree.map(comment =>
<CommentRoot
key={comment.id}
comment={comment}
isRoot
canReply={!!user}
onReply={content => handleNewComment(content, comment.nostr_id.toString())}
/>)}
</div>
</Card>
)
}

View File

@@ -1,246 +0,0 @@
import debounce from 'lodash.debounce';
import { relayPool } from 'nostr-tools'
import { Nullable } from 'remirror';
import { CONSTS } from 'src/utils';
import { Comment } from '../types';
const pool = relayPool();
export function connect() {
CONSTS.DEFAULT_RELAYS.forEach(url => {
pool.addRelay(url, { read: true, write: true })
})
pool.onNotice((notice: string, relay: any) => {
console.log(`${relay.url} says: ${notice}`)
})
};
let events: Record<string, Required<NostrEvent>> = {};
export function sub(filter: string, cb: (data: Comment[]) => void) {
const reconstructTree = debounce(async () => {
const newComments = await constructTree();
cb(newComments)
}, 1000)
let sub = pool.sub({
filter: {
"#r": [filter]
},
cb: async (event: Required<NostrEvent>) => {
//Got a new event
if (!event.id) return;
if (event.id in events) return;
events[event.id] = event
reconstructTree()
document.dispatchEvent(
new CustomEvent('nostr-event', {
detail: event
})
)
}
});
return () => {
sub.unsub();
events = {};
};
}
async function signEvent(event: any) {
const res = await fetch(CONSTS.apiEndpoint + '/nostr-sign-event', {
method: "post",
body: JSON.stringify({ event }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
const data = await res.json()
return data.event;
}
async function confirmPublishingEvent(event: any) {
const res = await fetch(CONSTS.apiEndpoint + '/nostr-confirm-event', {
method: "post",
body: JSON.stringify({ event }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
const data = await res.json()
return data.event;
}
async function getCommentsExtraData(ids: string[]) {
const res = await fetch(CONSTS.apiEndpoint + '/nostr-events-extra-data', {
method: "post",
body: JSON.stringify({ ids }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
type EventExtraData = {
id: number
nostr_id: string
votes_count: number
user: {
id: number,
avatar: string,
name: string,
}
}
const data = await res.json() as EventExtraData[];
const map = new Map<string, EventExtraData>()
data.forEach(item => {
map.set(item.nostr_id, item)
});
return map;
}
export async function post({ content, filter, parentId }: {
content: string,
filter: string,
parentId?: string
}) {
const tags = [];
tags.push(['r', filter]);
if (parentId)
tags.push(['e', `${parentId} ${CONSTS.DEFAULT_RELAYS[0]} reply`])
let event: NostrEvent;
try {
event = await signEvent({
// pubkey: globalKeys.pubkey,
// created_at: Math.round(Date.now() / 1000),
kind: 1,
tags,
content,
}) as NostrEvent;
} catch (error) {
alert("Couldn't sign the object successfully...")
return;
}
return new Promise<void>((resolve, reject) => {
pool.publish(event, (status: number, relay: string) => {
switch (status) {
case -1:
console.log(`failed to send ${JSON.stringify(event)} to ${relay}`)
break
case 1:
clearTimeout(publishTimeout)
console.log(`event ${event.id?.slice(0, 5)}… published to ${relay}.`)
break
}
});
const onEventFetched = (e: CustomEvent<NostrEvent>) => {
if (e.detail.id === event.id) {
document.removeEventListener<any>('nostr-event', onEventFetched);
confirmPublishingEvent(event)
resolve();
}
}
document.addEventListener<any>('nostr-event', onEventFetched);
const publishTimeout = setTimeout(() => {
document.removeEventListener<any>('nostr-event', onEventFetched);
reject("Failed to publish to any relay...");
}, 5000)
})
}
function extractParentId(event: NostrEvent): Nullable<string> {
for (const [identifier, value] of event.tags) {
if (identifier === 'e') {
const [eventId, , marker] = value.split(' ');
if (marker === 'reply') return eventId;
}
}
return null;
}
export async function constructTree() {
// This function is responsible for transforming the object shaped events into a tree of comments
// ----------------------------------------------------------------------------------------------
// Sort them chronologically from oldest to newest
let sortedEvenets = Object.values(events).sort((a, b) => a.created_at - b.created_at);
// Extract the pubkeys used
const pubkeysSet = new Set<string>();
sortedEvenets.forEach(e => pubkeysSet.add(e.pubkey));
// Make a request to api to get comments extra data
const commentsExtraData = await getCommentsExtraData(Object.keys(events));
let eventsTree: Record<string, Comment> = {}
// If event is a reply, connect it to parent
sortedEvenets.forEach(e => {
const parentId = extractParentId(e);
const extraData = commentsExtraData.get(e.id);
// if no extra data is here then that means this event wasn't done from our platform
if (!extraData) return;
if (parentId) {
eventsTree[parentId]?.replies.push({
id: extraData.id,
nostr_id: e.id,
body: e.content,
created_at: e.created_at * 1000,
pubkey: e.pubkey,
author: extraData.user,
replies: [],
votes_count: extraData.votes_count
});
} else {
eventsTree[e.id] = ({
id: extraData.id,
nostr_id: e.id,
body: e.content,
created_at: e.created_at * 1000,
pubkey: e.pubkey,
author: extraData.user,
replies: [],
votes_count: extraData.votes_count
});
}
})
// Run the censoring service
// (nothing for now -:-)
// Turn the top roots replies into a sorted array
const sortedTree = Object.values(eventsTree).sort((a, b) => b.created_at - a.created_at)
// Publish the new tree.
return sortedTree;
}

View File

@@ -1,293 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { relayPool } from 'nostr-tools'
import { Nullable } from 'remirror';
import { CONSTS } from 'src/utils';
import { Comment } from "../types";
import { useDebouncedState } from "@react-hookz/web";
import { Post_Type } from "src/graphql";
const pool = relayPool();
const useComments = (config: {
type: Post_Type,
id: string | number;
}) => {
const commentsEventsTemp = useRef<Record<string, Required<NostrEvent>>>({})
const [commentsEvents, setCommentsEvents] = useDebouncedState<Record<string, Required<NostrEvent>>>({}, 1000)
const pendingResolvers = useRef<Record<string, () => void>>({});
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>({ status: "Connecting", connectedRelaysCount: 0 })
const filter = useMemo(() => `boltfun ${config.type}_comment ${config.id}` + (process.env.NODE_ENV === 'development' ? ' dev' : ""), [config.id, config.type])
const [commentsTree, setCommentsTree] = useState<Comment[]>([])
useEffect(() => {
connect();
let sub = pool.sub({
filter: {
"#r": [filter]
},
cb: async (event: Required<NostrEvent>) => {
//Got a new event
if (!event.id) return;
if (event.id in commentsEventsTemp.current) return;
commentsEventsTemp.current[event.id] = event;
setCommentsEvents({ ...commentsEventsTemp.current })
}
});
return () => {
sub.unsub();
};
}, [filter, setCommentsEvents]);
useEffect(() => {
(async () => {
const newTree = await buildTree(commentsEvents);
setCommentsTree(newTree);
Object.entries(pendingResolvers.current).forEach(([id, resolve]) => {
if (id in commentsEvents) {
delete pendingResolvers.current[id];
resolve();
}
});
})();
}, [commentsEvents]);
useEffect(() => {
const interval = setInterval(() => {
const newStatus = getConnectionStatus();
if (newStatus.connectedRelaysCount !== connectionStatus.connectedRelaysCount || newStatus.status !== connectionStatus.status)
setConnectionStatus(newStatus);
}, 5000)
return () => {
clearInterval(interval)
}
}, [connectionStatus.connectedRelaysCount, connectionStatus.status])
const postComment = useCallback(async ({ content, parentId }: {
content: string,
parentId?: string
}) => {
const tags = [];
tags.push(['r', filter]);
if (parentId)
tags.push(['e', `${parentId} ${CONSTS.DEFAULT_RELAYS[0]} reply`])
let event: NostrEvent;
try {
event = await signEvent({
kind: 1,
tags,
content,
}) as NostrEvent;
} catch (error) {
alert("Couldn't sign the object successfully...")
return;
}
return new Promise<void>((resolve, reject) => {
let confirmationSent = false;
pool.publish(event, (status: number, relay: string) => {
switch (status) {
case -1:
console.log(`failed to send ${JSON.stringify(event)} to ${relay}`)
break
case 1:
clearTimeout(publishTimeout)
console.log(`event ${event.id?.slice(0, 5)}… published to ${relay}.`)
if (!confirmationSent) {
confirmPublishingEvent(event)
confirmationSent = true;
}
break
}
});
pendingResolvers.current[event.id!] = resolve;
const publishTimeout = setTimeout(() => {
delete pendingResolvers.current[event.id!]
reject("Failed to publish to any relay...");
}, 5000)
})
}, [filter]);
return { commentsTree, postComment, connectionStatus }
}
export default useComments;
function connect() {
CONSTS.DEFAULT_RELAYS.forEach(url => {
pool.addRelay(url, { read: true, write: true })
})
pool.onNotice((notice: string, relay: any) => {
console.log(`${relay.url} says: ${notice}`)
})
};
function extractParentId(event: NostrEvent): Nullable<string> {
for (const [identifier, value] of event.tags) {
if (identifier === 'e') {
const [eventId, , marker] = value.split(' ');
if (marker === 'reply') return eventId;
}
}
return null;
}
async function signEvent(event: any) {
const res = await fetch(CONSTS.apiEndpoint + '/nostr-sign-event', {
method: "post",
body: JSON.stringify({ event }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
const data = await res.json()
return data.event;
}
async function confirmPublishingEvent(event: any) {
await fetch(CONSTS.apiEndpoint + '/nostr-confirm-event', {
method: "post",
body: JSON.stringify({ event }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
}
async function getCommentsExtraData(ids: string[]) {
const res = await fetch(CONSTS.apiEndpoint + '/nostr-events-extra-data', {
method: "post",
body: JSON.stringify({ ids }),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
});
type EventExtraData = {
id: number
nostr_id: string
votes_count: number
user: {
id: number,
avatar: string,
name: string,
}
}
const data = await res.json() as EventExtraData[];
const map = new Map<string, EventExtraData>()
data.forEach(item => {
map.set(item.nostr_id, item)
});
return map;
}
async function buildTree(events: Record<string, Required<NostrEvent>>) {
// Sort them chronologically from oldest to newest
let sortedEvenets = Object.values(events).sort((a, b) => a.created_at - b.created_at);
// Extract the pubkeys used
const pubkeysSet = new Set<string>();
sortedEvenets.forEach(e => pubkeysSet.add(e.pubkey));
// Make a request to api to get comments extra data
const commentsExtraData = await getCommentsExtraData(Object.keys(events));
let eventsTree: Record<string, Comment> = {}
// If event is a reply, connect it to parent
sortedEvenets.forEach(e => {
const parentId = extractParentId(e);
const extraData = commentsExtraData.get(e.id);
// if no extra data is here then that means this event wasn't done from our platform
if (!extraData) return;
if (parentId) {
eventsTree[parentId]?.replies.push({
id: extraData.id,
nostr_id: e.id,
body: e.content,
created_at: e.created_at * 1000,
pubkey: e.pubkey,
author: extraData.user,
replies: [],
votes_count: extraData.votes_count
});
} else {
eventsTree[e.id] = ({
id: extraData.id,
nostr_id: e.id,
body: e.content,
created_at: e.created_at * 1000,
pubkey: e.pubkey,
author: extraData.user,
replies: [],
votes_count: extraData.votes_count
});
}
})
// Run the censoring service
// (nothing for now -:-)
// Turn the top roots replies into a sorted array
const sortedTree = Object.values(eventsTree).sort((a, b) => b.created_at - a.created_at)
return sortedTree;
}
type ConnectionStatus = {
status: 'Connected' | "Connecting" | "Not Connected",
connectedRelaysCount: number
}
function getConnectionStatus(): ConnectionStatus {
let openedCnt = 0, reconnectingCnt = 0;
for (const relayUrl in pool.relays) {
const relayStatus = pool.relays[relayUrl].relay.status;
if (relayStatus === 1) openedCnt += 1;
if (relayStatus === 0) reconnectingCnt += 1;
}
const finalStatus = openedCnt > 0 ?
"Connected" :
reconnectingCnt > 0 ?
"Connecting" :
"Not Connected";
return {
status: finalStatus,
connectedRelaysCount: openedCnt
}
}

View File

@@ -1,19 +0,0 @@
import { Comment, CommentWithReplies } from "./types";
// export function convertCommentsToTree(comments: Comment[]) {
// let tree: Record<Comment['id'], CommentWithReplies> = {};
// for (const comment of comments)
// tree[comment.id] = { ...comment, replies: [] }
// for (const comment of Object.values(tree)) {
// if (comment.parentId)
// tree[comment.parentId].replies = [...tree[comment.parentId].replies, comment]
// }
// // TODO
// // Sort the comments according to date
// return Object.values(tree).filter(node => !node.parentId);
// }

View File

@@ -1 +0,0 @@
export { default } from './CommentsSection/CommentsSection'

View File

@@ -1,18 +0,0 @@
import { Author } from "src/features/Posts/types";
export interface Comment {
id: number,
nostr_id: string;
pubkey: string;
author?: Pick<Author, 'id' | 'name' | 'avatar'>;
body: any;
created_at: number;
replies: Comment[]
votes_count: number
}
export interface CommentWithReplies extends Comment {
replies: CommentWithReplies[]
}

Some files were not shown because too many files have changed in this diff Show More