Attempt to at move app into app sub dir
This commit is contained in:
0
app/web/src/components/.keep
Normal file
0
app/web/src/components/.keep
Normal file
128
app/web/src/components/AdminParts/AdminParts.js
Normal file
128
app/web/src/components/AdminParts/AdminParts.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { QUERY } from 'src/components/AdminPartsCell'
|
||||
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: String!) {
|
||||
deletePart(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
|
||||
const truncate = (text) => {
|
||||
let output = text
|
||||
if (text && text.length > MAX_STRING_LENGTH) {
|
||||
output = output.substring(0, MAX_STRING_LENGTH) + '...'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const jsonTruncate = (obj) => {
|
||||
return truncate(JSON.stringify(obj, null, 2))
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const AdminParts = ({ parts }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
addMessage('Part deleted.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
// This refetches the query on the list page. Read more about other ways to
|
||||
// update the cache over here:
|
||||
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
})
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (confirm('Are you sure you want to delete part ' + id + '?')) {
|
||||
deletePart({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment rw-table-wrapper-responsive">
|
||||
<table className="rw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Code</th>
|
||||
<th>Main image</th>
|
||||
<th>Created at</th>
|
||||
<th>Updated at</th>
|
||||
<th>User id</th>
|
||||
<th>Deleted</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{truncate(part.id)}</td>
|
||||
<td>{truncate(part.title)}</td>
|
||||
<td>{truncate(part.description)}</td>
|
||||
<td>{truncate(part.code)}</td>
|
||||
<td>{truncate(part.mainImage)}</td>
|
||||
<td>{timeTag(part.createdAt)}</td>
|
||||
<td>{timeTag(part.updatedAt)}</td>
|
||||
<td>{truncate(part.userId)}</td>
|
||||
<td>{checkboxInputTag(part.deleted)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<Link
|
||||
to={routes.part({
|
||||
userName: part?.user?.userName,
|
||||
partTitle: part?.title,
|
||||
})}
|
||||
title={'Show part ' + part.id + ' detail'}
|
||||
className="rw-button rw-button-small"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editPart({
|
||||
userName: part?.user?.userName,
|
||||
partTitle: part?.title,
|
||||
})}
|
||||
title={'Edit part ' + part.id}
|
||||
className="rw-button rw-button-small rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<a
|
||||
href="#"
|
||||
title={'Delete part ' + part.id}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(part.id)}
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminParts
|
||||
39
app/web/src/components/AdminPartsCell/AdminPartsCell.js
Normal file
39
app/web/src/components/AdminPartsCell/AdminPartsCell.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import AdminParts from 'src/components/AdminParts'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS {
|
||||
parts {
|
||||
id
|
||||
title
|
||||
description
|
||||
code
|
||||
mainImage
|
||||
createdAt
|
||||
updatedAt
|
||||
userId
|
||||
deleted
|
||||
user {
|
||||
userName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No parts yet. '}
|
||||
<Link to={routes.newPart()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Success = ({ parts }) => {
|
||||
return <AdminParts parts={parts} />
|
||||
}
|
||||
42
app/web/src/components/Breadcrumb/Breadcrumb.js
Normal file
42
app/web/src/components/Breadcrumb/Breadcrumb.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import InputText from 'src/components/InputText'
|
||||
|
||||
const Breadcrumb = ({
|
||||
userName,
|
||||
partTitle,
|
||||
onPartTitleChange,
|
||||
className,
|
||||
isInvalid,
|
||||
}) => {
|
||||
return (
|
||||
<h3 className={getActiveClasses('text-2xl font-roboto', className)}>
|
||||
<div className="w-1 inline-block text-indigo-800 bg-indigo-800 mr-2">
|
||||
.
|
||||
</div>
|
||||
<span
|
||||
className={getActiveClasses({
|
||||
'text-gray-500': !onPartTitleChange,
|
||||
'text-gray-400': onPartTitleChange,
|
||||
})}
|
||||
>
|
||||
<Link to={routes.user({ userName })}>{userName}</Link>
|
||||
</span>
|
||||
<div className="w-1 inline-block bg-gray-400 text-gray-400 mx-3 transform -skew-x-20">
|
||||
.
|
||||
</div>
|
||||
<InputText
|
||||
value={partTitle}
|
||||
onChange={onPartTitleChange}
|
||||
isEditable={onPartTitleChange}
|
||||
className={getActiveClasses('text-indigo-800 text-2xl', {
|
||||
'-ml-2': !onPartTitleChange,
|
||||
})}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
export default Breadcrumb
|
||||
7
app/web/src/components/Breadcrumb/Breadcrumb.stories.js
Normal file
7
app/web/src/components/Breadcrumb/Breadcrumb.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
|
||||
export const generated = () => {
|
||||
return <Breadcrumb />
|
||||
}
|
||||
|
||||
export default { title: 'Components/Breadcrumb' }
|
||||
11
app/web/src/components/Breadcrumb/Breadcrumb.test.js
Normal file
11
app/web/src/components/Breadcrumb/Breadcrumb.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
|
||||
describe('Breadcrumb', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<Breadcrumb />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
37
app/web/src/components/Button/Button.js
Normal file
37
app/web/src/components/Button/Button.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
import Svg from 'src/components/Svg'
|
||||
|
||||
const Button = ({
|
||||
onClick,
|
||||
iconName,
|
||||
children,
|
||||
className,
|
||||
shouldAnimateHover,
|
||||
disabled,
|
||||
type,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={getActiveClasses(
|
||||
{
|
||||
'bg-gray-300 shadow-none hover:shadow-none': disabled,
|
||||
'text-red-600 bg-red-200 border border-red-600': type === 'danger',
|
||||
'text-indigo-600': !type,
|
||||
},
|
||||
'flex items-center bg-opacity-50 rounded-xl p-2 px-6',
|
||||
{
|
||||
'mx-px transform hover:-translate-y-px transition-all duration-150':
|
||||
shouldAnimateHover && !disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<Svg className="w-6 ml-4" name={iconName} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
12
app/web/src/components/Button/Button.stories.js
Normal file
12
app/web/src/components/Button/Button.stories.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Button from './Button'
|
||||
|
||||
export const generated = () => {
|
||||
return (
|
||||
<>
|
||||
button with icon
|
||||
<Button>click Me </Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default { title: 'Components/Button' }
|
||||
11
app/web/src/components/Button/Button.test.js
Normal file
11
app/web/src/components/Button/Button.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import Button from './Button'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<Button />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
38
app/web/src/components/ConfirmDialog/ConfirmDialog.js
Normal file
38
app/web/src/components/ConfirmDialog/ConfirmDialog.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import Button from 'src/components/Button'
|
||||
|
||||
const ConfirmDialog = ({ open, onClose, message, onConfirm }) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<div className="bg-gray-100 max-w-3xl rounded-lg shadow-lg">
|
||||
<div className="p-4">
|
||||
<span className="text-gray-600 text-center">{message}</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-indigo-200 relative z-20"
|
||||
shouldAnimateHover
|
||||
iconName={'save'}
|
||||
onClick={onClose}
|
||||
>
|
||||
Don't delete
|
||||
</Button>
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-red-200 relative z-20"
|
||||
shouldAnimateHover
|
||||
iconName={'trash'}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
onConfirm()
|
||||
}}
|
||||
type="danger"
|
||||
>
|
||||
Yes, Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
@@ -0,0 +1,7 @@
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
export const generated = () => {
|
||||
return <ConfirmDialog />
|
||||
}
|
||||
|
||||
export default { title: 'Components/ConfirmDialog' }
|
||||
11
app/web/src/components/ConfirmDialog/ConfirmDialog.test.js
Normal file
11
app/web/src/components/ConfirmDialog/ConfirmDialog.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ConfirmDialog />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import SubjectAccessRequestForm from 'src/components/SubjectAccessRequestForm'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_SUBJECT_ACCESS_REQUEST_BY_ID($id: String!) {
|
||||
subjectAccessRequest: subjectAccessRequest(id: $id) {
|
||||
id
|
||||
comment
|
||||
payload
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
const UPDATE_SUBJECT_ACCESS_REQUEST_MUTATION = gql`
|
||||
mutation UpdateSubjectAccessRequestMutation(
|
||||
$id: String!
|
||||
$input: UpdateSubjectAccessRequestInput!
|
||||
) {
|
||||
updateSubjectAccessRequest(id: $id, input: $input) {
|
||||
id
|
||||
comment
|
||||
payload
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Success = ({ subjectAccessRequest }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [updateSubjectAccessRequest, { loading, error }] = useMutation(
|
||||
UPDATE_SUBJECT_ACCESS_REQUEST_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
navigate(routes.subjectAccessRequests())
|
||||
addMessage('SubjectAccessRequest updated.', {
|
||||
classes: 'rw-flash-success',
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const onSave = (input, id) => {
|
||||
updateSubjectAccessRequest({ variables: { id, input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
Edit SubjectAccessRequest {subjectAccessRequest.id}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="rw-segment-main">
|
||||
<SubjectAccessRequestForm
|
||||
subjectAccessRequest={subjectAccessRequest}
|
||||
onSave={onSave}
|
||||
error={error}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
app/web/src/components/EditUserCell/EditUserCell.js
Normal file
58
app/web/src/components/EditUserCell/EditUserCell.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
|
||||
import UserProfile from 'src/components/UserProfile'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_USER_BY_ID($userName: String!) {
|
||||
user: userName(userName: $userName) {
|
||||
id
|
||||
userName
|
||||
name
|
||||
createdAt
|
||||
updatedAt
|
||||
image
|
||||
bio
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_USER_MUTATION = gql`
|
||||
mutation UpdateUserMutation($userName: String!, $input: UpdateUserInput!) {
|
||||
updateUserByUserName(userName: $userName, input: $input) {
|
||||
id
|
||||
userName
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div className="h-screen">Loading...</div>
|
||||
|
||||
export const Empty = () => <div className="h-full">Empty</div>
|
||||
|
||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||
|
||||
export const Success = ({ user, refetch, variables: { isEditable } }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [updateUser, { loading, error }] = useMutation(UPDATE_USER_MUTATION, {
|
||||
onCompleted: ({ updateUserByUserName }) => {
|
||||
navigate(routes.user({ userName: updateUserByUserName.userName }))
|
||||
addMessage('User updated.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
|
||||
const onSave = async (userName, input) => {
|
||||
await updateUser({ variables: { userName, input } })
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProfile
|
||||
user={user}
|
||||
onSave={onSave}
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
)
|
||||
}
|
||||
6
app/web/src/components/EditUserCell/EditUserCell.mock.js
Normal file
6
app/web/src/components/EditUserCell/EditUserCell.mock.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
editUser: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
20
app/web/src/components/EditUserCell/EditUserCell.stories.js
Normal file
20
app/web/src/components/EditUserCell/EditUserCell.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './EditUserCell'
|
||||
import { standard } from './EditUserCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/EditUserCell' }
|
||||
26
app/web/src/components/EditUserCell/EditUserCell.test.js
Normal file
26
app/web/src/components/EditUserCell/EditUserCell.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './EditUserCell'
|
||||
import { standard } from './EditUserCell.mock'
|
||||
|
||||
describe('EditUserCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Empty renders successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success editUser={standard().editUser} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
133
app/web/src/components/EmojiReaction/EmojiReaction.js
Normal file
133
app/web/src/components/EmojiReaction/EmojiReaction.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
import Popover from '@material-ui/core/Popover'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
|
||||
import Svg from 'src/components/Svg'
|
||||
|
||||
const emojiMenu = ['❤️', '👍', '😄', '🙌']
|
||||
// const emojiMenu = ['🏆', '❤️', '👍', '😊', '😄', '🚀', '👏', '🙌']
|
||||
const noEmotes = [
|
||||
{
|
||||
emoji: '❤️',
|
||||
count: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const textShadow = { textShadow: '0 4px 6px rgba(0, 0, 0, 0.3)' }
|
||||
|
||||
const EmojiReaction = ({
|
||||
emotes,
|
||||
userEmotes,
|
||||
onEmote = () => {},
|
||||
onShowPartReactions,
|
||||
className,
|
||||
}) => {
|
||||
const { currentUser } = useAuth()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [popoverId, setPopoverId] = useState(undefined)
|
||||
|
||||
const openPopover = (target) => {
|
||||
setAnchorEl(target)
|
||||
setPopoverId('simple-popover')
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const closePopover = () => {
|
||||
setAnchorEl(null)
|
||||
setPopoverId(undefined)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const togglePopover = ({ currentTarget }) => {
|
||||
if (isOpen) {
|
||||
return closePopover()
|
||||
}
|
||||
|
||||
openPopover(currentTarget)
|
||||
}
|
||||
|
||||
const handleEmojiClick = (emoji) => {
|
||||
// TODO handle user not signed in better, maybe open up a modal, I danno think about it.
|
||||
currentUser && onEmote(emoji)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={getActiveClasses(
|
||||
'h-10 relative overflow-hidden py-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-0 w-8 inset-y-0 z-10 flex items-center bg-gray-100">
|
||||
<div
|
||||
className="h-8 w-8 relative"
|
||||
aria-describedby={popoverId}
|
||||
onClick={togglePopover}
|
||||
>
|
||||
<button className="bg-gray-200 border-2 m-px w-full h-full border-gray-300 rounded-full flex justify-center items-center shadow-md hover:shadow-lg hover:border-indigo-200 transform hover:-translate-y-px transition-all duration-150">
|
||||
<Svg
|
||||
className="h-8 w-8 pt-px mt-px text-gray-500"
|
||||
name="dots-vertical"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="whitespace-no-wrap absolute right-0 inset-y-0 flex items-center flex-row-reverse">
|
||||
{(emotes.length ? emotes : noEmotes).map((emote, i) => (
|
||||
<span
|
||||
className={getActiveClasses(
|
||||
'rounded-full tracking-wide hover:bg-indigo-100 p-1 mx-px transform hover:-translate-y-px transition-all duration-150 border-indigo-400',
|
||||
{ border: currentUser && userEmotes?.includes(emote.emoji) }
|
||||
)}
|
||||
style={textShadow}
|
||||
key={`${emote.emoji}--${i}`}
|
||||
onClick={() => handleEmojiClick(emote.emoji)}
|
||||
>
|
||||
<span className="text-lg pr-1">{emote.emoji}</span>
|
||||
<span className="text-sm font-ropa-sans">{emote.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Popover
|
||||
id={popoverId}
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={closePopover}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<div className="p-2 pr-3 flex flex-col">
|
||||
<div className="inline-flex">
|
||||
{emojiMenu.map((emoji, i) => (
|
||||
<button
|
||||
className="p-2"
|
||||
style={textShadow}
|
||||
key={`${emoji}-${i}}`}
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="text-gray-700" onClick={onShowPartReactions}>
|
||||
View Reactions
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmojiReaction
|
||||
@@ -0,0 +1,7 @@
|
||||
import EmojiReaction from './EmojiReaction'
|
||||
|
||||
export const generated = () => {
|
||||
return <EmojiReaction />
|
||||
}
|
||||
|
||||
export default { title: 'Components/EmojiReaction' }
|
||||
11
app/web/src/components/EmojiReaction/EmojiReaction.test.js
Normal file
11
app/web/src/components/EmojiReaction/EmojiReaction.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import EmojiReaction from './EmojiReaction'
|
||||
|
||||
describe('EmojiReaction', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<EmojiReaction />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
23
app/web/src/components/Footer/Footer.js
Normal file
23
app/web/src/components/Footer/Footer.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import OutBound from 'src/components/OutBound'
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<div className="bg-indigo-900 text-indigo-200 font-roboto mt-20 text-sm">
|
||||
<div className="flex h-16 justify-end items-center mx-16">
|
||||
<OutBound
|
||||
className="mr-8"
|
||||
to="https://github.com/Irev-Dev/cadhub/discussions/212"
|
||||
>
|
||||
Road Map
|
||||
</OutBound>
|
||||
<Link className="mr-8" to={routes.codeOfConduct()}>
|
||||
Code of Conduct
|
||||
</Link>
|
||||
<Link to={routes.privacyPolicy()}>Privacy Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
7
app/web/src/components/Footer/Footer.stories.js
Normal file
7
app/web/src/components/Footer/Footer.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Footer from './Footer'
|
||||
|
||||
export const generated = () => {
|
||||
return <Footer />
|
||||
}
|
||||
|
||||
export default { title: 'Components/Footer' }
|
||||
11
app/web/src/components/Footer/Footer.test.js
Normal file
11
app/web/src/components/Footer/Footer.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import Footer from './Footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<Footer />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
126
app/web/src/components/IdeCascadeStudio/IdeCascadeStudio.js
Normal file
126
app/web/src/components/IdeCascadeStudio/IdeCascadeStudio.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import CascadeController from 'src/helpers/cascadeController'
|
||||
import IdeToolbar from 'src/components/IdeToolbar'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { threejsViewport } from 'src/cascade/js/MainPage/CascadeState'
|
||||
import {
|
||||
uploadToCloudinary,
|
||||
captureAndSaveViewport,
|
||||
} from 'src/helpers/cloudinary'
|
||||
|
||||
const defaultExampleCode = `// Welcome to Cascade Studio! Here are some useful functions:
|
||||
// Translate(), Rotate(), Scale(), Union(), Difference(), Intersection()
|
||||
// Box(), Sphere(), Cylinder(), Cone(), Text3D(), Polygon()
|
||||
// Offset(), Extrude(), RotatedExtrude(), Revolve(), Pipe(), Loft(),
|
||||
// FilletEdges(), ChamferEdges(),
|
||||
// Slider(), Button(), Checkbox()
|
||||
|
||||
let holeRadius = Slider("Radius", 30 , 20 , 40);
|
||||
|
||||
let sphere = Sphere(50);
|
||||
let cylinderZ = Cylinder(holeRadius, 200, true);
|
||||
let cylinderY = Rotate([0,1,0], 90, Cylinder(holeRadius, 200, true));
|
||||
let cylinderX = Rotate([1,0,0], 90, Cylinder(holeRadius, 200, true));
|
||||
|
||||
Translate([0, 0, 50], Difference(sphere, [cylinderX, cylinderY, cylinderZ]));
|
||||
|
||||
Translate([-130, 0, 100], Text3D("Start Hacking"));
|
||||
|
||||
// Don't forget to push imported or oc-defined shapes into sceneShapes to add them to the workspace!`
|
||||
|
||||
const IdeCascadeStudio = ({ part, saveCode, loading }) => {
|
||||
const isDraft = !part
|
||||
const [code, setCode] = useState(isDraft ? defaultExampleCode : part.code)
|
||||
const { currentUser } = useAuth()
|
||||
const canEdit = currentUser?.sub === part?.user?.id
|
||||
useEffect(() => {
|
||||
// Cascade studio attaches "cascade-container" a div outside the react app in 'web/src/index.html', and so we are
|
||||
// "opening" and "closing" it for the ide part of the app by displaying none or block. Which is why this useEffect
|
||||
// returns a clean up function that hides the div again.
|
||||
setCode(part?.code || '')
|
||||
const onCodeChange = (code) => setCode(code)
|
||||
CascadeController.initialise(onCodeChange, code || '')
|
||||
const element = document.getElementById('cascade-container')
|
||||
element.setAttribute('style', 'display: block; opacity: 100%; overflow: hidden; height: calc(100vh - 8rem)') // eslint-disable-line
|
||||
return () => {
|
||||
element.setAttribute('style', 'display: none; overflow: hidden; height: calc(100vh - 8rem)') // eslint-disable-line
|
||||
}
|
||||
}, [part?.code])
|
||||
const isChanges = code !== part?.code
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<IdeToolbar
|
||||
canEdit={canEdit}
|
||||
isChanges={isChanges && !loading}
|
||||
isDraft={isDraft}
|
||||
code={code}
|
||||
onSave={async () => {
|
||||
const input = {
|
||||
code,
|
||||
title: part?.title,
|
||||
userId: currentUser?.sub,
|
||||
description: part?.description,
|
||||
}
|
||||
const isFork = !canEdit
|
||||
if (isFork) {
|
||||
const { publicId } = await captureAndSaveViewport()
|
||||
input.mainImage = publicId
|
||||
}
|
||||
saveCode({
|
||||
input,
|
||||
id: part.id,
|
||||
isFork,
|
||||
})
|
||||
}}
|
||||
onExport={(type) => threejsViewport[`saveShape${type}`]()}
|
||||
userNamePart={{
|
||||
userName: part?.user?.userName,
|
||||
partTitle: part?.title,
|
||||
image: part?.user?.image,
|
||||
}}
|
||||
onCapture={async () => {
|
||||
const config = {
|
||||
currImage: part?.mainImage,
|
||||
callback: uploadAndUpdateImage,
|
||||
cloudinaryImgURL: '',
|
||||
updated: false,
|
||||
}
|
||||
// Get the canvas image as a Data URL
|
||||
config.image = await CascadeController.capture(
|
||||
threejsViewport.environment
|
||||
)
|
||||
config.imageObjectURL = window.URL.createObjectURL(config.image)
|
||||
|
||||
async function uploadAndUpdateImage() {
|
||||
// Upload the image to Cloudinary
|
||||
const cloudinaryImgURL = await uploadToCloudinary(config.image)
|
||||
|
||||
// Save the screenshot as the mainImage
|
||||
saveCode({
|
||||
input: {
|
||||
mainImage: cloudinaryImgURL.public_id,
|
||||
},
|
||||
id: part?.id,
|
||||
isFork: !canEdit,
|
||||
})
|
||||
|
||||
return cloudinaryImgURL
|
||||
}
|
||||
|
||||
// if there isn't a screenshot saved yet, just go ahead and save right away
|
||||
if (!part || !part.mainImage) {
|
||||
config.cloudinaryImgURL = await uploadAndUpdateImage().public_id
|
||||
config.updated = true
|
||||
}
|
||||
|
||||
return config
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeCascadeStudio
|
||||
@@ -0,0 +1,7 @@
|
||||
import IdeCascadeStudio from './IdeCascadeStudio'
|
||||
|
||||
export const generated = () => {
|
||||
return <IdeCascadeStudio />
|
||||
}
|
||||
|
||||
export default { title: 'Components/IdeCascadeStudio' }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import IdeCascadeStudio from './IdeCascadeStudio'
|
||||
|
||||
describe('IdeCascadeStudio', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<IdeCascadeStudio />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
39
app/web/src/components/IdeConsole/IdeConsole.js
Normal file
39
app/web/src/components/IdeConsole/IdeConsole.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useContext, useEffect } from 'react'
|
||||
import { IdeContext } from 'src/components/IdeToolbarNew'
|
||||
import { matchEditorVsDarkTheme } from 'src/components/IdeEditor'
|
||||
|
||||
const IdeConsole = () => {
|
||||
const { state } = useContext(IdeContext)
|
||||
useEffect(() => {
|
||||
const element = document.querySelector('.console-tile .mosaic-window-body')
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight
|
||||
}
|
||||
}, [state.consoleMessages])
|
||||
|
||||
return (
|
||||
<div className="p-2 px-4 min-h-full" style={matchEditorVsDarkTheme.Bg}>
|
||||
<div>
|
||||
{state.consoleMessages?.map(({ type, message, time }, index) => (
|
||||
<pre
|
||||
className="font-mono text-sm"
|
||||
style={matchEditorVsDarkTheme.Text}
|
||||
key={message + index}
|
||||
>
|
||||
<div
|
||||
className="text-xs font-bold pt-2"
|
||||
style={matchEditorVsDarkTheme.TextBrown}
|
||||
>
|
||||
{time?.toLocaleString()}
|
||||
</div>
|
||||
<div className={(type === 'error' ? 'text-red-400' : '') + ' pl-4'}>
|
||||
{message}
|
||||
</div>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeConsole
|
||||
98
app/web/src/components/IdeContainer/IdeContainer.js
Normal file
98
app/web/src/components/IdeContainer/IdeContainer.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useContext, useRef, useEffect } from 'react'
|
||||
import { Mosaic, MosaicWindow } from 'react-mosaic-component'
|
||||
import { IdeContext } from 'src/components/IdeToolbarNew'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
import IdeEditor, { matchEditorVsDarkTheme } from 'src/components/IdeEditor'
|
||||
import IdeViewer from 'src/components/IdeViewer'
|
||||
import IdeConsole from 'src/components/IdeConsole'
|
||||
import 'react-mosaic-component/react-mosaic-component.css'
|
||||
|
||||
const ELEMENT_MAP = {
|
||||
Editor: <IdeEditor />,
|
||||
Viewer: <IdeViewer />,
|
||||
Console: <IdeConsole />,
|
||||
}
|
||||
|
||||
const IdeContainer = () => {
|
||||
const { state, thunkDispatch } = useContext(IdeContext)
|
||||
const viewerDOM = useRef(null)
|
||||
const debounceTimeoutId = useRef
|
||||
|
||||
useEffect(handleViewerSizeUpdate, [viewerDOM])
|
||||
|
||||
function handleViewerSizeUpdate() {
|
||||
if (viewerDOM !== null && viewerDOM.current) {
|
||||
const { width, height } = viewerDOM.current.getBoundingClientRect()
|
||||
thunkDispatch({
|
||||
type: 'updateViewerSize',
|
||||
payload: { viewerSize: { width, height } },
|
||||
})
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
if (state.ideType === 'openScad') {
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
code: state.code,
|
||||
viewerSize: { width, height },
|
||||
camera: state.camera,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedViewerSizeUpdate = () => {
|
||||
clearTimeout(debounceTimeoutId.current)
|
||||
debounceTimeoutId.current = setTimeout(() => {
|
||||
handleViewerSizeUpdate()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', debouncedViewerSizeUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedViewerSizeUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="cadhub-ide" className="mosaic-toolbar-overrides flex-auto h-full">
|
||||
<Mosaic
|
||||
renderTile={(id, path) => {
|
||||
return (
|
||||
<MosaicWindow
|
||||
path={path}
|
||||
renderToolbar={() => (
|
||||
<div
|
||||
className="text-xs text-gray-400 pl-4 w-full py-px font-bold leading-loose border-b border-gray-700"
|
||||
style={matchEditorVsDarkTheme.lighterBg}
|
||||
>
|
||||
{id}
|
||||
{id === 'Editor' && ` (${state.ideType})`}
|
||||
</div>
|
||||
)}
|
||||
className={`${id.toLowerCase()} ${id.toLowerCase()}-tile`}
|
||||
>
|
||||
{id === 'Viewer' ? (
|
||||
<div id="view-wrapper" className="h-full" ref={viewerDOM}>
|
||||
{ELEMENT_MAP[id]}
|
||||
</div>
|
||||
) : (
|
||||
ELEMENT_MAP[id]
|
||||
)}
|
||||
</MosaicWindow>
|
||||
)
|
||||
}}
|
||||
value={state.layout}
|
||||
onChange={(newLayout) =>
|
||||
thunkDispatch({ type: 'setLayout', payload: { message: newLayout } })
|
||||
}
|
||||
onRelease={handleViewerSizeUpdate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeContainer
|
||||
75
app/web/src/components/IdeEditor/IdeEditor.js
Normal file
75
app/web/src/components/IdeEditor/IdeEditor.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useContext, Suspense, lazy } from 'react'
|
||||
import { IdeContext } from 'src/components/IdeToolbarNew'
|
||||
import { codeStorageKey } from 'src/helpers/hooks/useIdeState'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
const Editor = lazy(() => import('@monaco-editor/react'))
|
||||
|
||||
export const matchEditorVsDarkTheme = {
|
||||
// Some colors to roughly match the vs-dark editor theme
|
||||
Bg: { backgroundColor: 'rgb(30,30,30)' },
|
||||
lighterBg: { backgroundColor: 'rgb(55,55,55)' },
|
||||
Text: { color: 'rgb(212,212,212)' },
|
||||
TextBrown: { color: 'rgb(206,144,120)' },
|
||||
}
|
||||
|
||||
const IdeEditor = () => {
|
||||
const { state, thunkDispatch } = useContext(IdeContext)
|
||||
const ideTypeToLanguageMap = {
|
||||
cadQuery: 'python',
|
||||
openScad: 'cpp',
|
||||
}
|
||||
|
||||
function handleCodeChange(value, _event) {
|
||||
thunkDispatch({ type: 'updateCode', payload: value })
|
||||
}
|
||||
function handleSaveHotkey(event) {
|
||||
//ctrl|meta + s is very intuitive for most devs
|
||||
const { key, ctrlKey, metaKey } = event
|
||||
if (key === 's' && (ctrlKey || metaKey)) {
|
||||
event.preventDefault()
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
code: state.code,
|
||||
viewerSize: state.viewerSize,
|
||||
camera: state.camera,
|
||||
})
|
||||
})
|
||||
localStorage.setItem(codeStorageKey, state.code)
|
||||
}
|
||||
}
|
||||
const loading = (
|
||||
<div
|
||||
className="text-gray-700 font-ropa-sans relative"
|
||||
style={{ backgroundColor: 'red' }}
|
||||
>
|
||||
<div className="absolute inset-0 text-center flex items-center w-32">
|
||||
. . . loading
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
|
||||
className="h-full"
|
||||
onKeyDown={handleSaveHotkey}
|
||||
>
|
||||
<Suspense fallback={loading}>
|
||||
<Editor
|
||||
defaultValue={state.code}
|
||||
value={state.code}
|
||||
theme="vs-dark"
|
||||
loading={loading}
|
||||
// TODO #247 cpp seems better than js for the time being
|
||||
defaultLanguage={ideTypeToLanguageMap[state.ideType] || 'cpp'}
|
||||
language={ideTypeToLanguageMap[state.ideType] || 'cpp'}
|
||||
onChange={handleCodeChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeEditor
|
||||
90
app/web/src/components/IdePartCell/IdePartCell.js
Normal file
90
app/web/src/components/IdePartCell/IdePartCell.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import IdeCascadeStudio from 'src/components/IdeCascadeStudio'
|
||||
import { QUERY as UsersPartsQuery } from 'src/components/PartsOfUserCell'
|
||||
import useUser from 'src/helpers/hooks/useUser'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_PART_BY_USENAME_TITLE($partTitle: String!, $userName: String!) {
|
||||
part: partByUserAndTitle(partTitle: $partTitle, userName: $userName) {
|
||||
id
|
||||
title
|
||||
description
|
||||
code
|
||||
mainImage
|
||||
createdAt
|
||||
user {
|
||||
id
|
||||
userName
|
||||
image
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_PART_MUTATION = gql`
|
||||
mutation UpdatePartMutation($id: String!, $input: UpdatePartInput!) {
|
||||
updatePart(id: $id, input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
export const FORK_PART_MUTATION = gql`
|
||||
mutation ForkPartMutation($input: CreatePartInput!) {
|
||||
forkPart(input: $input) {
|
||||
id
|
||||
title
|
||||
user {
|
||||
userName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => <div>Part not found</div>
|
||||
|
||||
export const Success = ({ part, refetch }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const { user } = useUser()
|
||||
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
|
||||
onCompleted: () => {
|
||||
addMessage('Part updated.', { classes: 'rw-flash-success fixed w-screen z-10' })
|
||||
},
|
||||
})
|
||||
const [forkPart] = useMutation(FORK_PART_MUTATION, {
|
||||
refetchQueries: [
|
||||
{
|
||||
query: UsersPartsQuery,
|
||||
variables: { userName: user?.userName },
|
||||
},
|
||||
],
|
||||
onCompleted: ({ forkPart }) => {
|
||||
navigate(
|
||||
routes.ide({
|
||||
userName: forkPart?.user?.userName,
|
||||
partTitle: forkPart?.title,
|
||||
})
|
||||
)
|
||||
addMessage('Part Forked.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
|
||||
const saveCode = async ({ input, id, isFork }) => {
|
||||
if (!isFork) {
|
||||
await updatePart({ variables: { id, input } })
|
||||
refetch()
|
||||
return
|
||||
}
|
||||
forkPart({ variables: { input } })
|
||||
}
|
||||
return (
|
||||
<IdeCascadeStudio
|
||||
part={part}
|
||||
saveCode={saveCode}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
6
app/web/src/components/IdePartCell/IdePartCell.mock.js
Normal file
6
app/web/src/components/IdePartCell/IdePartCell.mock.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
idePart: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
20
app/web/src/components/IdePartCell/IdePartCell.stories.js
Normal file
20
app/web/src/components/IdePartCell/IdePartCell.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './IdePartCell'
|
||||
import { standard } from './IdePartCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/IdePartCell' }
|
||||
26
app/web/src/components/IdePartCell/IdePartCell.test.js
Normal file
26
app/web/src/components/IdePartCell/IdePartCell.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './IdePartCell'
|
||||
import { standard } from './IdePartCell.mock'
|
||||
|
||||
describe('IdePartCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Empty renders successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success idePart={standard().idePart} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
424
app/web/src/components/IdeToolbar/IdeToolbar.js
Normal file
424
app/web/src/components/IdeToolbar/IdeToolbar.js
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useState } from 'react'
|
||||
import Popover from '@material-ui/core/Popover'
|
||||
import OutBound from 'src/components/OutBound'
|
||||
import ReactGA from 'react-ga'
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
|
||||
import Button from 'src/components/Button'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
import Svg from '../Svg/Svg'
|
||||
import LoginModal from 'src/components/LoginModal'
|
||||
import { FORK_PART_MUTATION } from 'src/components/IdePartCell'
|
||||
import { QUERY as UsersPartsQuery } from 'src/components/PartsOfUserCell'
|
||||
import useUser from 'src/helpers/hooks/useUser'
|
||||
import useKeyPress from 'src/helpers/hooks/useKeyPress'
|
||||
import { captureAndSaveViewport } from 'src/helpers/cloudinary'
|
||||
|
||||
const IdeToolbar = ({
|
||||
canEdit,
|
||||
isChanges,
|
||||
onSave,
|
||||
onExport,
|
||||
userNamePart,
|
||||
isDraft,
|
||||
code,
|
||||
onCapture,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [whichPopup, setWhichPopup] = useState(null)
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false)
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const showForkButton = !(canEdit || isDraft)
|
||||
const [title, setTitle] = useState('untitled-part')
|
||||
const [captureState, setCaptureState] = useState(false)
|
||||
const { user } = useUser()
|
||||
useKeyPress((e) => {
|
||||
const rx = /INPUT|SELECT|TEXTAREA/i
|
||||
const didPressBackspaceOutsideOfInput =
|
||||
(e.key == 'Backspace' || e.keyCode == 8) && !rx.test(e.target.tagName)
|
||||
if (didPressBackspaceOutsideOfInput) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
const { addMessage } = useFlash()
|
||||
const [forkPart] = useMutation(FORK_PART_MUTATION, {
|
||||
refetchQueries: [
|
||||
{
|
||||
query: UsersPartsQuery,
|
||||
variables: { userName: userNamePart?.userName || user?.userName },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const handleClick = ({ event, whichPopup }) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
setWhichPopup(whichPopup)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null)
|
||||
setWhichPopup(null)
|
||||
}
|
||||
|
||||
const saveFork = async () => {
|
||||
const { publicId } = await captureAndSaveViewport()
|
||||
return forkPart({
|
||||
variables: {
|
||||
input: {
|
||||
userId: currentUser.sub,
|
||||
title,
|
||||
code,
|
||||
mainImage: publicId,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isDraft && isAuthenticated) {
|
||||
const { data } = await saveFork()
|
||||
navigate(
|
||||
routes.ide({
|
||||
userName: data?.forkPart?.user?.userName,
|
||||
partTitle: data?.forkPart?.title,
|
||||
})
|
||||
)
|
||||
addMessage(`Part created with title: ${data?.forkPart?.title}.`, {
|
||||
classes: 'rw-flash-success',
|
||||
})
|
||||
} else if (isAuthenticated) onSave()
|
||||
else recordedLogin()
|
||||
}
|
||||
|
||||
const handleSaveAndEdit = async () => {
|
||||
const { data } = await saveFork()
|
||||
const {
|
||||
user: { userName },
|
||||
title: partTitle,
|
||||
} = data?.forkPart || { user: {} }
|
||||
navigate(routes.part({ userName, partTitle }))
|
||||
}
|
||||
|
||||
const recordedLogin = async () => {
|
||||
ReactGA.event({
|
||||
category: 'login',
|
||||
action: 'ideToolbar signup prompt from fork',
|
||||
})
|
||||
setIsLoginModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDownload = (url) => {
|
||||
const aTag = document.createElement('a')
|
||||
document.body.appendChild(aTag)
|
||||
aTag.href = url
|
||||
aTag.style.display = 'none'
|
||||
aTag.download = `CadHub_${Date.now()}.jpg`
|
||||
aTag.click()
|
||||
document.body.removeChild(aTag)
|
||||
}
|
||||
|
||||
const anchorOrigin = {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}
|
||||
const transformOrigin = {
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}
|
||||
|
||||
const id = open ? 'simple-popover' : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
id="cadhub-ide-toolbar"
|
||||
className="flex bg-gradient-to-r from-gray-900 to-indigo-900 pt-1"
|
||||
>
|
||||
{!isDraft && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 ml-4">
|
||||
<ImageUploader
|
||||
className="rounded-full object-cover"
|
||||
aspectRatio={1}
|
||||
imageUrl={userNamePart?.image}
|
||||
width={80}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-indigo-400 ml-2 mr-8">
|
||||
<Link to={routes.user({ userName: userNamePart?.userName })}>
|
||||
{userNamePart?.userName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
iconName="arrow-left"
|
||||
className="ml-3 shadow-md hover:shadow-lg border-indigo-600 border-2 border-opacity-0 hover:border-opacity-100 bg-indigo-800 text-indigo-200"
|
||||
shouldAnimateHover
|
||||
onClick={() => {
|
||||
navigate(routes.part(userNamePart))
|
||||
}}
|
||||
>
|
||||
Part Profile
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
iconName={showForkButton ? 'fork' : 'save'}
|
||||
className="ml-3 shadow-md hover:shadow-lg border-indigo-600 border-2 border-opacity-0 hover:border-opacity-100 bg-indigo-800 text-indigo-200"
|
||||
shouldAnimateHover
|
||||
onClick={handleSave}
|
||||
>
|
||||
{showForkButton ? 'Fork' : 'Save'}
|
||||
{isChanges && !isDraft && (
|
||||
<span className="relative h-4">
|
||||
<span className="text-pink-400 text-2xl absolute transform -translate-y-3">
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{isDraft && isAuthenticated && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
iconName={'save'}
|
||||
className="ml-3 shadow-md hover:shadow-lg border-indigo-600 border-2 border-opacity-0 hover:border-opacity-100 bg-indigo-800 text-indigo-200 mr-"
|
||||
shouldAnimateHover
|
||||
onClick={handleSaveAndEdit}
|
||||
>
|
||||
Save & Edit Profile
|
||||
</Button>
|
||||
<div className="ml-4 text-indigo-300">title:</div>
|
||||
<input
|
||||
className="rounded ml-4 px-2"
|
||||
value={title}
|
||||
onChange={({ target }) =>
|
||||
setTitle(target?.value.replace(/([^a-zA-Z\d_:])/g, '-'))
|
||||
}
|
||||
/>
|
||||
<div className="w-px ml-4 bg-pink-400 h-10"></div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
iconName="logout"
|
||||
className="ml-3 shadow-md hover:shadow-lg border-indigo-600 border-2 border-opacity-0 hover:border-opacity-100 bg-indigo-800 text-indigo-200"
|
||||
shouldAnimateHover
|
||||
aria-describedby={id}
|
||||
onClick={(event) => handleClick({ event, whichPopup: 'export' })}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={whichPopup === 'export'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
className="material-ui-overrides transform translate-y-4"
|
||||
>
|
||||
<ul className="text-sm py-2 text-gray-500">
|
||||
{['STEP', 'STL', 'OBJ'].map((exportType) => (
|
||||
<li key={exportType} className="px-4 py-2 hover:bg-gray-200">
|
||||
<button onClick={() => onExport(exportType)}>
|
||||
export
|
||||
<span className="pl-1 text-base text-indigo-600">
|
||||
{exportType}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
{/* Capture Screenshot link. Should only appear if part has been saved and is editable. */}
|
||||
{!isDraft && canEdit && (
|
||||
<div>
|
||||
<button
|
||||
onClick={async (event) => {
|
||||
handleClick({ event, whichPopup: 'capture' })
|
||||
setCaptureState(await onCapture())
|
||||
}}
|
||||
className="text-indigo-300 flex items-center pr-6"
|
||||
>
|
||||
Save Part Image <Svg name="camera" className="pl-2 w-8" />
|
||||
</button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={whichPopup === 'capture'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
className="material-ui-overrides transform translate-y-4"
|
||||
>
|
||||
<div className="text-sm p-2 text-gray-500">
|
||||
{!captureState ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<div
|
||||
className="rounded m-auto"
|
||||
style={{ width: 'fit-content', overflow: 'hidden' }}
|
||||
>
|
||||
<img src={captureState.imageObjectURL} className="w-32" />
|
||||
</div>
|
||||
<div className="p-2 text-indigo-800">
|
||||
{captureState.currImage && !captureState.updated ? (
|
||||
<button
|
||||
className="flex justify-center mb-4"
|
||||
onClick={async () => {
|
||||
const cloudinaryImg = await captureState.callback()
|
||||
setCaptureState({
|
||||
...captureState,
|
||||
currImage: cloudinaryImg.public_id,
|
||||
updated: true,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Svg
|
||||
name="refresh"
|
||||
className="mr-2 w-4 text-indigo-600"
|
||||
/>{' '}
|
||||
Update Part Image
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex justify-center mb-4">
|
||||
<Svg
|
||||
name="checkmark"
|
||||
className="mr-2 w-6 text-indigo-600"
|
||||
/>{' '}
|
||||
Part Image Updated
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
iconName="save"
|
||||
className="shadow-md hover:shadow-lg border-indigo-600 border-2 border-opacity-0 hover:border-opacity-100 bg-indigo-800 text-indigo-100 text-opacity-100 bg-opacity-80"
|
||||
shouldAnimateHover
|
||||
onClick={() =>
|
||||
handleDownload(captureState.imageObjectURL)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
onClick={(event) => handleClick({ event, whichPopup: 'tips' })}
|
||||
className="text-indigo-300 flex items-center pr-6"
|
||||
>
|
||||
Tips <Svg name="lightbulb" className="pl-2 w-8" />
|
||||
</button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={whichPopup === 'tips'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
className="material-ui-overrides transform translate-y-4"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="text-sm p-2 text-gray-500">
|
||||
Press F5 to regenerate model
|
||||
</div>
|
||||
<OutBound
|
||||
className="text-gray-600 underline p-2"
|
||||
to="https://ronie.medium.com/cascade-studio-tutorial-ee2f1c42c829"
|
||||
>
|
||||
See the tutorial
|
||||
</OutBound>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={(event) => handleClick({ event, whichPopup: 'feedback' })}
|
||||
className="text-indigo-300 flex items-center pr-6"
|
||||
>
|
||||
Feedback <Svg name="flag" className="pl-2 w-8" />
|
||||
</button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={whichPopup === 'feedback'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
className="material-ui-overrides transform translate-y-4"
|
||||
>
|
||||
<div className="text-sm p-2 text-gray-500 max-w-md">
|
||||
If there's a feature you really want or you found a bug, either
|
||||
make a{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/Irev-Dev/cadhub/issues"
|
||||
>
|
||||
github issue
|
||||
</OutBound>{' '}
|
||||
or swing by the{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://discord.gg/SD7zFRNjGH"
|
||||
>
|
||||
discord server
|
||||
</OutBound>
|
||||
.
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={(event) => handleClick({ event, whichPopup: 'issues' })}
|
||||
className="text-indigo-300 flex items-center pr-6"
|
||||
>
|
||||
Known issues <Svg name="exclamation-circle" className="pl-2 w-8" />
|
||||
</button>
|
||||
<Popover
|
||||
id={id}
|
||||
open={whichPopup === 'issues'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
className="material-ui-overrides transform translate-y-4"
|
||||
>
|
||||
<div className="text-sm p-4 text-gray-500 max-w-md">
|
||||
<div className="text-base text-gray-700 py-2">
|
||||
Can't export stl/obj/STEP?
|
||||
</div>
|
||||
Currently exports are only working for chrome and edge browsers
|
||||
<p>
|
||||
If this problem is frustrating to you, leave a comment on its{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/zalo/CascadeStudio/pull/39#issuecomment-766206091"
|
||||
>
|
||||
github issue
|
||||
</OutBound>{' '}
|
||||
to help prioritize it.
|
||||
</p>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
shouldStartWithSignup
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeToolbar
|
||||
20
app/web/src/components/IdeToolbar/IdeToolbar.stories.js
Normal file
20
app/web/src/components/IdeToolbar/IdeToolbar.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import IdeToolbar from './IdeToolbar'
|
||||
|
||||
export const generated = () => {
|
||||
return (
|
||||
<div>
|
||||
{[
|
||||
<IdeToolbar canEdit />,
|
||||
<IdeToolbar canEdit isChanges />,
|
||||
<IdeToolbar />,
|
||||
<IdeToolbar isChanges />,
|
||||
].map((toolbar, index) => (
|
||||
<div key={index} className="pb-2">
|
||||
{toolbar}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default { title: 'Components/IdeToolbar' }
|
||||
11
app/web/src/components/IdeToolbar/IdeToolbar.test.js
Normal file
11
app/web/src/components/IdeToolbar/IdeToolbar.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import IdeToolbar from './IdeToolbar'
|
||||
|
||||
describe('IdeToolbar', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<IdeToolbar />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
75
app/web/src/components/IdeToolbarNew/IdeToolbarNew.js
Normal file
75
app/web/src/components/IdeToolbarNew/IdeToolbarNew.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useEffect } from 'react'
|
||||
import IdeContainer from 'src/components/IdeContainer'
|
||||
import { isBrowser } from '@redwoodjs/prerender/browserUtils'
|
||||
import { useIdeState, codeStorageKey } from 'src/helpers/hooks/useIdeState'
|
||||
import { copyTextToClipboard } from 'src/helpers/clipboard'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
|
||||
export const IdeContext = createContext()
|
||||
const IdeToolbarNew = ({ cadPackage }) => {
|
||||
const [state, thunkDispatch] = useIdeState()
|
||||
const scriptKey = 'encoded_script'
|
||||
useEffect(() => {
|
||||
thunkDispatch({
|
||||
type: 'initIde',
|
||||
payload: { cadPackage },
|
||||
})
|
||||
// load code from hash if it's there
|
||||
let hash
|
||||
if (isBrowser) {
|
||||
hash = window.location.hash
|
||||
}
|
||||
const [key, scriptBase64] = hash.slice(1).split('=')
|
||||
if (key === scriptKey) {
|
||||
const script = atob(scriptBase64)
|
||||
thunkDispatch({ type: 'updateCode', payload: script })
|
||||
}
|
||||
window.location.hash = ''
|
||||
setTimeout(() => handleRender()) // definitely a little hacky, timeout with no delay is just to push it into the next event loop.
|
||||
}, [cadPackage])
|
||||
function handleRender() {
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
code: state.code,
|
||||
viewerSize: state.viewerSize,
|
||||
camera: state.camera,
|
||||
})
|
||||
})
|
||||
localStorage.setItem(codeStorageKey, state.code)
|
||||
}
|
||||
function handleMakeLink() {
|
||||
if (isBrowser) {
|
||||
const scriptBase64 = btoa(state.code)
|
||||
window.location.hash = `encoded_script=${scriptBase64}`
|
||||
copyTextToClipboard(window.location.href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IdeContext.Provider value={{ state, thunkDispatch }}>
|
||||
<div className="h-full flex flex-col">
|
||||
<nav className="flex">
|
||||
<button
|
||||
onClick={handleRender}
|
||||
className="border-2 px-2 text-gray-700 text-sm m-1"
|
||||
>
|
||||
Render
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMakeLink}
|
||||
className="border-2 text-gray-700 px-2 text-sm m-1 ml-2"
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</nav>
|
||||
<IdeContainer />
|
||||
</div>
|
||||
</IdeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeToolbarNew
|
||||
248
app/web/src/components/IdeViewer/IdeViewer.js
Normal file
248
app/web/src/components/IdeViewer/IdeViewer.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import { IdeContext } from 'src/components/IdeToolbarNew'
|
||||
import { useRef, useState, useEffect, useContext } from 'react'
|
||||
import {
|
||||
Canvas,
|
||||
extend,
|
||||
useFrame,
|
||||
useThree,
|
||||
useUpdate,
|
||||
} from 'react-three-fiber'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { Vector3 } from 'three'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
|
||||
extend({ OrbitControls })
|
||||
|
||||
function Asset({ stlData }) {
|
||||
const [loadedGeometry, setLoadedGeometry] = useState()
|
||||
const mesh = useRef()
|
||||
const ref = useUpdate((geometry) => {
|
||||
geometry.attributes = loadedGeometry.attributes
|
||||
})
|
||||
useEffect(() => {
|
||||
if (stlData) {
|
||||
const decoded = atob(stlData)
|
||||
const loader = new STLLoader()
|
||||
setLoadedGeometry(loader.parse(decoded))
|
||||
}
|
||||
}, [stlData])
|
||||
if (!loadedGeometry) return null
|
||||
return (
|
||||
<mesh ref={mesh} scale={[1, 1, 1]}>
|
||||
<bufferGeometry attach="geometry" ref={ref} />
|
||||
<meshStandardMaterial color="#F472B6" />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
let debounceTimeoutId
|
||||
function Controls({ onCameraChange, onDragStart }) {
|
||||
const controls = useRef()
|
||||
const { camera, gl } = useThree()
|
||||
useEffect(() => {
|
||||
// init camera position
|
||||
camera.position.x = 200
|
||||
camera.position.y = 140
|
||||
camera.position.z = 20
|
||||
camera.far = 10000
|
||||
camera.fov = 22.5 // matches default openscad fov
|
||||
|
||||
// Order matters with Euler rotations
|
||||
// We want it to rotate around the z or vertical axis first then the x axis to match openscad
|
||||
// in Three.js Y is the vertical axis (Z for openscad)
|
||||
camera.rotation._order = 'YXZ'
|
||||
const getRotations = () => {
|
||||
const { x, y, z } = camera.rotation
|
||||
const rad2Deg = 180 / Math.PI
|
||||
const scadX = (x + Math.PI / 2) * rad2Deg
|
||||
const scadZ = y * rad2Deg
|
||||
const scadY = z * rad2Deg
|
||||
return [scadX, scadY, scadZ]
|
||||
}
|
||||
const getPositions = () => {
|
||||
// Difficult to make this clean since I'm not sure why it works
|
||||
// The OpenSCAD camera seems hard to work with but maybe it's just me
|
||||
|
||||
// this gives us a vector the same length as the camera.position
|
||||
const cameraViewVector = new Vector3(0, 0, 1)
|
||||
.applyQuaternion(camera.quaternion) // make unit vector of the camera
|
||||
.multiplyScalar(camera.position.length()) // make it the same length as the position vector
|
||||
|
||||
// make a vector from the position vector to the cameraView vector
|
||||
const head2Head = new Vector3().subVectors(
|
||||
camera.position,
|
||||
cameraViewVector
|
||||
)
|
||||
return {
|
||||
// I can't seem to get normal vector addition to work
|
||||
// but this works
|
||||
position: {
|
||||
x: camera.position.x + head2Head.x,
|
||||
y: -camera.position.z - head2Head.z,
|
||||
z: camera.position.y + head2Head.y,
|
||||
},
|
||||
dist: camera.position.length(),
|
||||
}
|
||||
}
|
||||
|
||||
if (controls.current) {
|
||||
const dragCallback = () => {
|
||||
clearTimeout(debounceTimeoutId)
|
||||
debounceTimeoutId = setTimeout(() => {
|
||||
const [x, y, z] = getRotations()
|
||||
const { position, dist } = getPositions()
|
||||
|
||||
onCameraChange({
|
||||
position,
|
||||
rotation: { x, y, z },
|
||||
dist,
|
||||
})
|
||||
}, 400)
|
||||
}
|
||||
const dragStart = () => {
|
||||
onDragStart()
|
||||
clearTimeout(debounceTimeoutId)
|
||||
}
|
||||
controls.current.addEventListener('end', dragCallback)
|
||||
controls.current.addEventListener('start', dragStart)
|
||||
const oldCurrent = controls.current
|
||||
dragCallback()
|
||||
return () => {
|
||||
oldCurrent.removeEventListener('end', dragCallback)
|
||||
oldCurrent.removeEventListener('start', dragStart)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => controls.current?.update())
|
||||
return (
|
||||
<orbitControls
|
||||
ref={controls}
|
||||
args={[camera, gl.domElement]}
|
||||
rotateSpeed={0.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Box(props) {
|
||||
// This reference will give us direct access to the mesh
|
||||
const mesh = useRef()
|
||||
|
||||
return (
|
||||
<mesh {...props} ref={mesh} scale={[1, 1, 1]}>
|
||||
<boxBufferGeometry args={props.size} />
|
||||
<meshStandardMaterial color={props.color} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
function Sphere(props) {
|
||||
const mesh = useRef()
|
||||
return (
|
||||
<mesh {...props} ref={mesh} scale={[1, 1, 1]}>
|
||||
<sphereBufferGeometry args={[2, 30, 30]} />
|
||||
<meshStandardMaterial color={props.color} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
const IdeViewer = () => {
|
||||
const { state, thunkDispatch } = useContext(IdeContext)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [image, setImage] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
setImage(
|
||||
state.objectData?.type === 'png' &&
|
||||
'data:image/png;base64,' + state.objectData?.data
|
||||
)
|
||||
setIsDragging(false)
|
||||
}, [state.objectData?.type, state.objectData?.data])
|
||||
|
||||
const openSCADDeepOceanThemeBackground = '#323232'
|
||||
// the following are tailwind colors in hex, can't use these classes to color three.js meshes.
|
||||
const pink400 = '#F472B6'
|
||||
const indigo300 = '#A5B4FC'
|
||||
const indigo900 = '#312E81'
|
||||
return (
|
||||
<div
|
||||
className="relative h-full"
|
||||
style={{ backgroundColor: openSCADDeepOceanThemeBackground }}
|
||||
>
|
||||
{state.isLoading && (
|
||||
<div className="inset-0 absolute flex items-center justify-center">
|
||||
<div className="h-16 w-16 bg-pink-600 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
)}
|
||||
{image && (
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-500 ${
|
||||
isDragging ? 'opacity-25' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img alt="code-cad preview" src={image} className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
|
||||
className={`opacity-0 absolute inset-0 transition-opacity duration-500 ${
|
||||
!(isDragging || state.ideType !== 'openScad')
|
||||
? 'hover:opacity-50'
|
||||
: 'opacity-100'
|
||||
}`}
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
>
|
||||
<Canvas>
|
||||
<Controls
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onCameraChange={(camera) => {
|
||||
thunkDispatch({
|
||||
type: 'updateCamera',
|
||||
payload: { camera },
|
||||
})
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
if (state.ideType === 'openScad') {
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
code: state.code,
|
||||
viewerSize: state.viewerSize,
|
||||
camera,
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ambientLight />
|
||||
<pointLight position={[15, 5, 10]} />
|
||||
{state.ideType === 'openScad' && (
|
||||
<>
|
||||
<Sphere position={[0, 0, 0]} color={pink400} />
|
||||
<Box position={[0, 50, 0]} size={[1, 100, 1]} color={indigo900} />
|
||||
<Box
|
||||
position={[0, 0, -50]}
|
||||
size={[1, 1, 100]}
|
||||
color={indigo300}
|
||||
/>
|
||||
<Box position={[50, 0, 0]} size={[100, 1, 1]} color={pink400} />
|
||||
</>
|
||||
)}
|
||||
{state.ideType === 'cadQuery' && (
|
||||
<Asset
|
||||
stlData={
|
||||
state.objectData?.type === 'stl' && state.objectData?.data
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Canvas>
|
||||
</div>
|
||||
{state.isLoading && (
|
||||
<div className="inset-0 absolute flex items-center justify-center">
|
||||
<div className="h-16 w-16 bg-pink-600 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeViewer
|
||||
155
app/web/src/components/ImageUploader/ImageUploader.js
Normal file
155
app/web/src/components/ImageUploader/ImageUploader.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import axios from 'axios'
|
||||
import ReactCrop from 'react-image-crop'
|
||||
import { Dialog } from '@material-ui/core'
|
||||
import { Image as CloudinaryImage } from 'cloudinary-react'
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import Svg from 'src/components/Svg/Svg.js'
|
||||
|
||||
const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images'
|
||||
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload'
|
||||
|
||||
export default function ImageUploader({
|
||||
onImageUpload = () => {},
|
||||
imageUrl,
|
||||
aspectRatio,
|
||||
className,
|
||||
isEditable,
|
||||
width = 600,
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [file, setFile] = useState()
|
||||
const [cloudinaryId, setCloudinaryId] = useState(imageUrl)
|
||||
const [imageObj, setImageObj] = useState()
|
||||
const [crop, setCrop] = useState({
|
||||
aspect: aspectRatio,
|
||||
unit: '%',
|
||||
width: 100,
|
||||
})
|
||||
async function handleImageUpload() {
|
||||
const croppedFile = await getCroppedImg(imageObj, crop, 'avatar')
|
||||
const imageData = new FormData()
|
||||
imageData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET)
|
||||
imageData.append('file', croppedFile)
|
||||
let upload = axios.post(CLOUDINARY_UPLOAD_URL, imageData)
|
||||
try {
|
||||
const { data } = await upload
|
||||
if (data && data.public_id !== '') {
|
||||
onImageUpload({ cloudinaryPublicId: data.public_id })
|
||||
setCloudinaryId(data.public_id)
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ERROR', e)
|
||||
}
|
||||
}
|
||||
// Drag and Drop
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
setIsModalOpen(true)
|
||||
const fileReader = new FileReader()
|
||||
fileReader.onload = () => {
|
||||
setFile(fileReader.result)
|
||||
}
|
||||
fileReader.readAsDataURL(acceptedFiles[0])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'relative overflow-hidden ' +
|
||||
(!imageUrl && isEditable ? 'border ' : '') +
|
||||
className
|
||||
}
|
||||
style={{ paddingBottom: `${(1 / aspectRatio) * 100}%` }}
|
||||
>
|
||||
<div className="absolute w-full h-full" {...getRootProps()}>
|
||||
{cloudinaryId && isEditable && (
|
||||
<button className="absolute z-10 bg-indigo-900 opacity-75 bottom-0 right-0 flex items-center p-1 mb-6 mr-2 rounded-lg">
|
||||
<span className="text-gray-100 pr-2">Update</span>
|
||||
<Svg
|
||||
name="pencil"
|
||||
strokeWidth={2}
|
||||
className=" text-gray-100 h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{isEditable && <input {...getInputProps()} />}
|
||||
{(cloudinaryId || !isEditable) && (
|
||||
<div className="relative overflow-hidden w-full h-full">
|
||||
<CloudinaryImage
|
||||
className="object-cover w-full h-full shadow overflow-hidden"
|
||||
cloudName="irevdev"
|
||||
publicId={cloudinaryId || 'CadHub/eia1kwru54g2kf02s2xx'}
|
||||
width={width}
|
||||
crop="scale"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!cloudinaryId && <button className="absolute inset-0"></button>}
|
||||
{!cloudinaryId && isEditable && (
|
||||
<div className="text-indigo-500 flex items-center justify-center rounded-lg w-full h-full">
|
||||
<div className="px-6 text-center">
|
||||
Drop files here ... or{' '}
|
||||
<span className="group flex w-full items-center justify-center py-2">
|
||||
<span className="bg-indigo-500 shadow rounded text-gray-200 cursor-pointer p-2 hover:shadow-lg transform hover:-translate-y-1 transition-all duration-150">
|
||||
upload
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={isModalOpen} onClose={() => setIsModalOpen(false)}>
|
||||
<div className="p-4">
|
||||
<ReactCrop
|
||||
src={file}
|
||||
crop={crop}
|
||||
onImageLoaded={(image) => setImageObj(image)}
|
||||
onChange={(newCrop) => setCrop(newCrop)}
|
||||
/>
|
||||
<Button onClick={handleImageUpload} variant="outlined">
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCroppedImg(image, crop, fileName) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const scaleX = image.naturalWidth / image.width
|
||||
const scaleY = image.naturalHeight / image.height
|
||||
canvas.width = crop.width
|
||||
canvas.height = crop.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(
|
||||
image,
|
||||
crop.x * scaleX,
|
||||
crop.y * scaleY,
|
||||
crop.width * scaleX,
|
||||
crop.height * scaleY,
|
||||
0,
|
||||
0,
|
||||
crop.width,
|
||||
crop.height
|
||||
)
|
||||
|
||||
// As Base64 string
|
||||
// const base64Image = canvas.toDataURL('image/jpeg');
|
||||
|
||||
// As a blob
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
blob.name = fileName
|
||||
resolve(blob)
|
||||
},
|
||||
'image/jpeg',
|
||||
1
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import ImageUploader from './ImageUploader'
|
||||
|
||||
export const generated = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>AspectRatio:1, no initial image, editable</h3>
|
||||
<ImageUploader
|
||||
onImageUpload={({ cloudinaryPublicId }) =>
|
||||
console.log(cloudinaryPublicId)
|
||||
}
|
||||
aspectRatio={1}
|
||||
isEditable={true}
|
||||
className={'bg-red-400 rounded-half rounded-br-xl'}
|
||||
/>
|
||||
<h3>AspectRatio 16:9, no initial image, editable</h3>
|
||||
<ImageUploader
|
||||
onImageUpload={({ cloudinaryPublicId }) =>
|
||||
console.log(cloudinaryPublicId)
|
||||
}
|
||||
aspectRatio={16 / 9}
|
||||
isEditable={true}
|
||||
className={'bg-red-400 rounded-xl'}
|
||||
imageUrl="CadHub/inakek2urbreynblzhgt"
|
||||
/>
|
||||
<h3>AspectRatio:1, no initial image, NOT editable</h3>
|
||||
<ImageUploader
|
||||
onImageUpload={({ cloudinaryPublicId }) =>
|
||||
console.log(cloudinaryPublicId)
|
||||
}
|
||||
aspectRatio={1}
|
||||
className={'rounded-half rounded-br-xl'}
|
||||
/>
|
||||
<h3>AspectRatio ,16:9 no initial image, NOT editable</h3>
|
||||
<ImageUploader
|
||||
onImageUpload={({ cloudinaryPublicId }) =>
|
||||
console.log(cloudinaryPublicId)
|
||||
}
|
||||
aspectRatio={16 / 9}
|
||||
className={'rounded-xl'}
|
||||
imageUrl="CadHub/inakek2urbreynblzhgt"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default { title: 'Components/ImageUploader' }
|
||||
11
app/web/src/components/ImageUploader/ImageUploader.test.js
Normal file
11
app/web/src/components/ImageUploader/ImageUploader.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import ImageUploader from './ImageUploader'
|
||||
|
||||
describe('ImageUploader', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ImageUploader />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
46
app/web/src/components/InputText/InputText.js
Normal file
46
app/web/src/components/InputText/InputText.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
|
||||
const InputText = ({
|
||||
value,
|
||||
isEditable,
|
||||
onChange,
|
||||
className,
|
||||
isInvalid = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={getActiveClasses(
|
||||
'relative inline-block',
|
||||
{ hidden: !isEditable },
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={getActiveClasses(
|
||||
'absolute inset-0 mb-2 rounded bg-gray-200 shadow-inner',
|
||||
{ 'border border-red-500': isInvalid }
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
className="pl-2 pt-1 text-indigo-800 font-medium mb-px pb-px bg-transparent relative"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
readOnly={!onChange}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={getActiveClasses(
|
||||
'pl-2 text-indigo-800 font-medium mb-px pb-px',
|
||||
{ hidden: isEditable },
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputText
|
||||
7
app/web/src/components/InputText/InputText.stories.js
Normal file
7
app/web/src/components/InputText/InputText.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import InputText from './InputText'
|
||||
|
||||
export const generated = () => {
|
||||
return <InputText />
|
||||
}
|
||||
|
||||
export default { title: 'Components/InputText' }
|
||||
11
app/web/src/components/InputText/InputText.test.js
Normal file
11
app/web/src/components/InputText/InputText.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import InputText from './InputText'
|
||||
|
||||
describe('InputText', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<InputText />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
34
app/web/src/components/InputTextForm/InputTextForm.js
Normal file
34
app/web/src/components/InputTextForm/InputTextForm.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
import { TextField, FieldError } from '@redwoodjs/forms'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
const InputText = ({ type = 'text', className, name, validation }) => {
|
||||
const { errors } = useFormContext()
|
||||
return (
|
||||
<>
|
||||
<div className={getActiveClasses('relative inline-block', className)}>
|
||||
<FieldError
|
||||
className="absolute -my-4 text-sm text-red-500 font-ropa-sans"
|
||||
name={name}
|
||||
/>
|
||||
<div
|
||||
className={getActiveClasses(
|
||||
'absolute inset-0 mb-2 rounded bg-gray-200 shadow-inner',
|
||||
{ 'border border-red-500': errors[name] }
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
className={getActiveClasses(
|
||||
'pl-2 pt-1 text-indigo-800 font-medium mb-px pb-px bg-transparent relative w-full'
|
||||
)}
|
||||
name={name}
|
||||
readOnly={false}
|
||||
type={type}
|
||||
validation={validation}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputText
|
||||
@@ -0,0 +1,7 @@
|
||||
import InputTextForm from './InputTextForm'
|
||||
|
||||
export const generated = () => {
|
||||
return <InputTextForm />
|
||||
}
|
||||
|
||||
export default { title: 'Components/InputTextForm' }
|
||||
11
app/web/src/components/InputTextForm/InputTextForm.test.js
Normal file
11
app/web/src/components/InputTextForm/InputTextForm.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import InputTextForm from './InputTextForm'
|
||||
|
||||
describe('InputTextForm', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<InputTextForm />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
215
app/web/src/components/LandingSection/LandingSection.js
Normal file
215
app/web/src/components/LandingSection/LandingSection.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
topLeftFrame,
|
||||
bottomRightFrame,
|
||||
resizer,
|
||||
abstractCode,
|
||||
involuteDonut,
|
||||
} from './mockEditorParts'
|
||||
import Svg from 'src/components/Svg'
|
||||
import OutBound from 'src/components/OutBound'
|
||||
import LoginModal from 'src/components/LoginModal'
|
||||
import { useState } from 'react'
|
||||
import { routes, Link } from '@redwoodjs/router'
|
||||
|
||||
const LandingSection = () => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false)
|
||||
return (
|
||||
<div className="mt-16">
|
||||
<div className="relative p-4 shadow-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-bl from-pink-200 via-pink-200 to-red-400 landing-bg-clip-path" />
|
||||
<div className="max-w-6xl px-2 mx-auto font-roboto">
|
||||
<div className="relative">
|
||||
<div className="bg-pink-200 rounded-tr-2xl absolute top-0 right-0 h-24 w-32 hidden lg:block" />
|
||||
<div className="bg-pink-300 rounded-tr-2xl absolute bottom-0 right-0 h-24 w-32 mr-8 hidden lg:block" />
|
||||
<div className="inline-block relative z-10">
|
||||
<div className="flex-col flex">
|
||||
<div className="text-indigo-600 pb-3 tracking-widest">
|
||||
Here's a thought,
|
||||
</div>
|
||||
<h1 className="font-bold text-indigo-800 text-5xl inline-block tracking-wider">
|
||||
<span className="text-6xl">C</span>
|
||||
ode is the future of <span className="text-6xl">C</span>
|
||||
AD
|
||||
</h1>
|
||||
<div className="text-indigo-600 text-base font-normal self-end pt-3 tracking-widest">
|
||||
No more click-n-drool.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-11 grid-rows-5 grid-flow-row-dense grid-flow-col-dense mt-32">
|
||||
<div className=" col-start-1 col-span-5 row-start-1 row-span-3">
|
||||
{topLeftFrame}
|
||||
</div>
|
||||
<div className="col-start-2 col-span-4 row-start-2 row-span-4 pt-8 animate-bounce-sm-slow">
|
||||
{abstractCode}
|
||||
</div>
|
||||
<div className="col-end-11 col-span-4 row-end-5 row-span-5 pt-12 animate-twist-sm-slow">
|
||||
{involuteDonut}
|
||||
</div>
|
||||
<div className="col-start-5 col-span-2 row-start-2 row-span-4">
|
||||
{resizer}
|
||||
</div>
|
||||
<div className="col-end-12 row-end-7 col-span-4 row-span-3">
|
||||
{bottomRightFrame}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto px-2">
|
||||
<h2 className="text-indigo-700 text-5xl font-roboto my-16 tracking-widest font-light">
|
||||
What's the potential of code-cad?
|
||||
</h2>
|
||||
<MarketingPoint
|
||||
leadingPoint="Communication"
|
||||
title="Tech-drawing and CAD as communication medium"
|
||||
>
|
||||
<p className="max-w-2xl">
|
||||
Have you ever started frantically reaching for a pen when trying to
|
||||
explain an idea?
|
||||
</p>
|
||||
<p className="pt-4">
|
||||
Engineers love drawings and CAD extends that, though now
|
||||
communicating with machines is just as important as with colleagues.
|
||||
What better way to do that than with a deterministic, expressive and
|
||||
auditable script?
|
||||
</p>
|
||||
</MarketingPoint>
|
||||
<div className="mt-24">
|
||||
<div className="text-2xl text-pink-400 font-bold tracking-widest">
|
||||
Extensible
|
||||
</div>
|
||||
<h3 className="text-indigo-700 text-4xl mt-4">
|
||||
If <span className="line-through">it bleeds</span> it's text, we can{' '}
|
||||
<span className="line-through">kill</span> hack it
|
||||
</h3>
|
||||
<div className="text-gray-600 max-w-3xl text-2xl font-light mt-4">
|
||||
<ul className="list-disc pl-6">
|
||||
<li>Build your own helper functions and abstractions</li>
|
||||
<li>
|
||||
Trigger{' '}
|
||||
<QuickLink to="https://en.wikipedia.org/wiki/Finite_element_method">
|
||||
FEM
|
||||
</QuickLink>{' '}
|
||||
or regenerate tool paths with a{' '}
|
||||
<QuickLink to="https://www.redhat.com/en/topics/devops/what-is-ci-cd">
|
||||
CI/CD
|
||||
</QuickLink>{' '}
|
||||
process
|
||||
</li>
|
||||
<li>
|
||||
Auto-generate a{' '}
|
||||
<QuickLink to="https://en.wikipedia.org/wiki/Bill_of_materials">
|
||||
BOM
|
||||
</QuickLink>
|
||||
</li>
|
||||
<li>
|
||||
Integrate it into your{' '}
|
||||
<QuickLink to="https://www.ptc.com/en/technologies/plm">
|
||||
PLM
|
||||
</QuickLink>{' '}
|
||||
tools
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<MarketingPoint
|
||||
leadingPoint="Git Good"
|
||||
title="All of the benefits of version control"
|
||||
>
|
||||
<p>
|
||||
Team coordination doesn't get any better than git. Multiple people
|
||||
working on a complex assembly without treading on each other -- what
|
||||
else is there to say?
|
||||
</p>
|
||||
</MarketingPoint>
|
||||
<MarketingPoint
|
||||
leadingPoint="Rise of the developer"
|
||||
title="Leverage a growing industry"
|
||||
>
|
||||
<p>
|
||||
Software is taking over the world, and so are developers. In the
|
||||
U.S. developers are 1.4M strong and are predicted to increase their{' '}
|
||||
<QuickLink to="https://www.bls.gov/ooh/computer-and-information-technology/software-developers.htm">
|
||||
ranks by 22%
|
||||
</QuickLink>{' '}
|
||||
over the next 10 years. As coders proliferate, so will the number of
|
||||
areas in which they operate, including CAD.
|
||||
</p>
|
||||
</MarketingPoint>
|
||||
</div>
|
||||
<div className="w-3/4 mx-auto h-px bg-pink-400 mt-32" />
|
||||
<div className="mt-24">
|
||||
<p className="text-center text-pink-400 max-w-xl text-2xl mx-auto font-medium">
|
||||
CadHub is a space to share cad projects and it’s our gift to the
|
||||
code-cad community. Let’s celebrate and promote code-cad together.
|
||||
</p>
|
||||
<div className="rounded-md shadow-md max-w-lg mx-auto border border-gray-300 mt-16">
|
||||
<p className="text-2xl font-medium text-gray-600 p-8">
|
||||
Projects use the excellent{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/zalo/CascadeStudio"
|
||||
>
|
||||
CascadeStudio
|
||||
</OutBound>{' '}
|
||||
with more integrations{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/Irev-Dev/cadhub/discussions/212"
|
||||
>
|
||||
coming soon
|
||||
</OutBound>
|
||||
.
|
||||
</p>
|
||||
<Link to={routes.draftPart()}>
|
||||
<div className="bg-texture bg-purple-800 text-center w-full py-6 rounded-b-md border border-indigo-300 border-opacity-0 hover:border-opacity-100 hover:shadow-xl">
|
||||
<span className="font-bold text-2xl text-indigo-200">
|
||||
Start Hacking Now
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-64 pt-20 mb-32">
|
||||
<div className="flex text-2xl text-gray-500">
|
||||
See what other's have created
|
||||
<Svg
|
||||
name="arrow-down"
|
||||
className="h-12 w-12 animate-bounce text-indigo-300 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
shouldStartWithSignup
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LandingSection
|
||||
|
||||
function MarketingPoint({ leadingPoint, title, children }) {
|
||||
return (
|
||||
<div className="mt-24">
|
||||
<div className="text-2xl text-pink-400 font-bold tracking-widest">
|
||||
{leadingPoint}
|
||||
</div>
|
||||
<h3 className="text-indigo-700 text-4xl mt-4">{title}</h3>
|
||||
<div className="text-gray-600 max-w-3xl text-2xl font-light mt-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickLink({ to, children }) {
|
||||
return (
|
||||
<OutBound className="text-gray-500 font-medium" to={to}>
|
||||
{children}
|
||||
</OutBound>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import LandingSection from './LandingSection'
|
||||
|
||||
export const generated = () => {
|
||||
return <LandingSection />
|
||||
}
|
||||
|
||||
export default { title: 'Components/LandingSection' }
|
||||
11
app/web/src/components/LandingSection/LandingSection.test.js
Normal file
11
app/web/src/components/LandingSection/LandingSection.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import LandingSection from './LandingSection'
|
||||
|
||||
describe('LandingSection', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<LandingSection />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
114
app/web/src/components/LandingSection/mockEditorParts.js
Normal file
114
app/web/src/components/LandingSection/mockEditorParts.js
Normal file
File diff suppressed because one or more lines are too long
208
app/web/src/components/LoginModal/LoginModal.js
Normal file
208
app/web/src/components/LoginModal/LoginModal.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import Tab from '@material-ui/core/Tab'
|
||||
import Tabs from '@material-ui/core/Tabs'
|
||||
import InputTextForm from 'src/components/InputTextForm'
|
||||
import OutBound from 'src/components/OutBound'
|
||||
import { Form, Submit } from '@redwoodjs/forms'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import { useFlash } from '@redwoodjs/web'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { subscribe } from 'src/helpers/subscribe'
|
||||
|
||||
const LoginModal = ({ open, onClose, shouldStartWithSignup = false }) => {
|
||||
const { logIn, signUp } = useAuth()
|
||||
const { addMessage } = useFlash()
|
||||
|
||||
const [tab, setTab] = useState(shouldStartWithSignup ? 0 : 1)
|
||||
const onTabChange = (_, newValue) => {
|
||||
setTab(newValue)
|
||||
setError('')
|
||||
}
|
||||
const [checkBox, setCheckBox] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const onSubmitSignUp = async ({ email, password, name, userName }) => {
|
||||
try {
|
||||
setError('')
|
||||
if (checkBox) {
|
||||
subscribe({ email, addMessage })
|
||||
}
|
||||
await signUp({
|
||||
email,
|
||||
password,
|
||||
remember: { full_name: name, userName },
|
||||
})
|
||||
onClose()
|
||||
} catch (errorEvent) {
|
||||
setError(errorEvent?.json?.error_description)
|
||||
}
|
||||
}
|
||||
const onSubmitSignIn = async ({ email, password }) => {
|
||||
try {
|
||||
setError('')
|
||||
await logIn({ email, password, remember: true })
|
||||
onClose()
|
||||
} catch (errorEvent) {
|
||||
setError(errorEvent?.json?.error_description)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<div className="bg-gray-100 max-w-2xl rounded-lg shadow-lg">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={onTabChange}
|
||||
centered
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
<Tab label="Sign Up" />
|
||||
<Tab label="Sign In" />
|
||||
</Tabs>
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 font-ropa-sans pt-4 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{tab === 0 ? (
|
||||
<SignUpForm
|
||||
onSubmitSignUp={onSubmitSignUp}
|
||||
checkBox={checkBox}
|
||||
setCheckBox={setCheckBox}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<SignInForm onSubmitSignIn={onSubmitSignIn} />
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const Field = ({ name, type = 'text', validation }) => (
|
||||
<>
|
||||
<span className="capitalize text-gray-500 text-sm align-middle my-3">
|
||||
{name}:
|
||||
</span>
|
||||
<InputTextForm
|
||||
type={type}
|
||||
className="text-xl"
|
||||
name={name}
|
||||
validation={validation}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const HeroButton = ({ text }) => (
|
||||
<Submit className="bg-texture bg-purple-800 py-6 w-full flex items-center justify-center rounded-b border border-indigo-300 border-opacity-0 hover:border-opacity-100 hover:shadow-xl">
|
||||
<span className="font-bold text-2xl text-indigo-200">{text}</span>
|
||||
</Submit>
|
||||
)
|
||||
|
||||
const SignInForm = ({ onSubmitSignIn }) => (
|
||||
<Form className="w-full" onSubmit={onSubmitSignIn}>
|
||||
<div className="p-8">
|
||||
<div
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: 'auto 1fr' }}
|
||||
>
|
||||
<Field
|
||||
name="email"
|
||||
validation={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /[^@]+@[^.]+\..+/,
|
||||
message: 'please enter a valid email address',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
to={routes.accountRecovery()}
|
||||
className="underline text-sm text-gray-500 block text-center"
|
||||
>
|
||||
forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
<HeroButton text="Sign In" />
|
||||
</Form>
|
||||
)
|
||||
|
||||
const SignUpForm = ({ onSubmitSignUp, checkBox, setCheckBox, onClose }) => (
|
||||
<Form className="w-full" onSubmit={onSubmitSignUp}>
|
||||
<div className="p-8">
|
||||
<div
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: 'auto 1fr' }}
|
||||
>
|
||||
<Field name="name" validation={{ required: true }} />
|
||||
<Field
|
||||
name="userName"
|
||||
validation={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9-_]+$/,
|
||||
message: 'Only alphanumeric and dash characters allowed',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
name="email"
|
||||
validation={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /[^@]+@[^.]+\..+/,
|
||||
message: 'please enter a valid email address',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex pt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkBox}
|
||||
onChange={() => setCheckBox(!checkBox)}
|
||||
/>{' '}
|
||||
<span className="pl-4 text-gray-500 text-sm max-w-sm">
|
||||
Stay up-to-date with CadHub's progress with the founder's (
|
||||
<OutBound className="underline" to="https://twitter.com/IrevDev">
|
||||
Kurt's
|
||||
</OutBound>
|
||||
) newsletter
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 block text-center pt-4">
|
||||
Use of CadHub requires you to abide by our{' '}
|
||||
<Link
|
||||
onClick={onClose}
|
||||
to={routes.codeOfConduct()}
|
||||
className="underline"
|
||||
>
|
||||
Code of Conduct
|
||||
</Link>
|
||||
, and agree with our{' '}
|
||||
<Link
|
||||
onClick={onClose}
|
||||
to={routes.privacyPolicy()}
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<HeroButton text="Sign Up" />
|
||||
</Form>
|
||||
)
|
||||
|
||||
export default LoginModal
|
||||
7
app/web/src/components/LoginModal/LoginModal.stories.js
Normal file
7
app/web/src/components/LoginModal/LoginModal.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import LoginModal from './LoginModal'
|
||||
|
||||
export const generated = () => {
|
||||
return <LoginModal open={true} />
|
||||
}
|
||||
|
||||
export default { title: 'Components/LoginModal' }
|
||||
11
app/web/src/components/LoginModal/LoginModal.test.js
Normal file
11
app/web/src/components/LoginModal/LoginModal.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import LoginModal from './LoginModal'
|
||||
|
||||
describe('LoginModal', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<LoginModal />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
48
app/web/src/components/NavPlusButton/NavPlusButton.tsx
Normal file
48
app/web/src/components/NavPlusButton/NavPlusButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
import { Popover } from '@headlessui/react'
|
||||
|
||||
const NavPlusButton: React.FC = () => {
|
||||
return (
|
||||
<Popover className="relative outline-none w-full h-full">
|
||||
<Popover.Button className="h-full w-full outline-none">
|
||||
<Svg name="plus" className="text-indigo-300" />
|
||||
</Popover.Button>
|
||||
|
||||
<Popover.Panel className="absolute z-10">
|
||||
<ul className="bg-gray-200 mt-4 rounded shadow-md overflow-hidden">
|
||||
{[
|
||||
{
|
||||
name: 'OpenSCAD',
|
||||
sub: 'beta',
|
||||
ideType: 'openScad',
|
||||
},
|
||||
{ name: 'CadQuery', sub: 'beta', ideType: 'cadQuery' },
|
||||
{
|
||||
name: 'CascadeStudio',
|
||||
sub: 'soon to be deprecated',
|
||||
},
|
||||
].map(({ name, sub, ideType }) => (
|
||||
<li
|
||||
key={name}
|
||||
className="px-4 py-2 hover:bg-gray-400 text-gray-800"
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
name === 'CascadeStudio'
|
||||
? routes.draftPart()
|
||||
: routes.devIde({ cadPackage: ideType })
|
||||
}
|
||||
>
|
||||
<div>{name}</div>
|
||||
<div className="text-xs text-gray-600 font-light">{sub}</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavPlusButton
|
||||
22
app/web/src/components/OutBound/OutBound.js
Normal file
22
app/web/src/components/OutBound/OutBound.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import ReactGA from 'react-ga'
|
||||
|
||||
const OutBound = ({ className, children, to }) => {
|
||||
return (
|
||||
<a
|
||||
className={className}
|
||||
target="_blank"
|
||||
href={to}
|
||||
onClick={() => {
|
||||
ReactGA.event({
|
||||
category: 'outbound',
|
||||
action: to,
|
||||
})
|
||||
return true
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default OutBound
|
||||
7
app/web/src/components/OutBound/OutBound.stories.js
Normal file
7
app/web/src/components/OutBound/OutBound.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import OutBound from './OutBound'
|
||||
|
||||
export const generated = () => {
|
||||
return <OutBound />
|
||||
}
|
||||
|
||||
export default { title: 'Components/OutBound' }
|
||||
11
app/web/src/components/OutBound/OutBound.test.js
Normal file
11
app/web/src/components/OutBound/OutBound.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import OutBound from './OutBound'
|
||||
|
||||
describe('OutBound', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<OutBound />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
204
app/web/src/components/PartCell/PartCell.js
Normal file
204
app/web/src/components/PartCell/PartCell.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
|
||||
import PartProfile from 'src/components/PartProfile'
|
||||
import { QUERY as PART_REACTION_QUERY } from 'src/components/PartReactionsCell/PartReactionsCell'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_PART_BY_USERNAME_TITLE(
|
||||
$userName: String!
|
||||
$partTitle: String
|
||||
$currentUserId: String
|
||||
) {
|
||||
userPart: userName(userName: $userName) {
|
||||
id
|
||||
name
|
||||
userName
|
||||
bio
|
||||
image
|
||||
Part(partTitle: $partTitle) {
|
||||
id
|
||||
title
|
||||
description
|
||||
code
|
||||
mainImage
|
||||
createdAt
|
||||
updatedAt
|
||||
userId
|
||||
Reaction {
|
||||
emote
|
||||
}
|
||||
userReactions: Reaction(userId: $currentUserId) {
|
||||
emote
|
||||
}
|
||||
Comment {
|
||||
id
|
||||
text
|
||||
user {
|
||||
userName
|
||||
image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_PART_MUTATION = gql`
|
||||
mutation UpdatePartMutation($id: String!, $input: UpdatePartInput!) {
|
||||
updatePart: updatePart(id: $id, input: $input) {
|
||||
id
|
||||
title
|
||||
description
|
||||
code
|
||||
mainImage
|
||||
userId
|
||||
Reaction {
|
||||
emote
|
||||
}
|
||||
user {
|
||||
id
|
||||
userName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const CREATE_PART_MUTATION = gql`
|
||||
mutation CreatePartMutation($input: CreatePartInput!) {
|
||||
createPart(input: $input) {
|
||||
id
|
||||
title
|
||||
user {
|
||||
id
|
||||
userName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const TOGGLE_REACTION_MUTATION = gql`
|
||||
mutation ToggleReactionMutation($input: TogglePartReactionInput!) {
|
||||
togglePartReaction(input: $input) {
|
||||
id
|
||||
emote
|
||||
}
|
||||
}
|
||||
`
|
||||
const CREATE_COMMENT_MUTATION = gql`
|
||||
mutation CreateCommentMutation($input: CreateCommentInput!) {
|
||||
createComment(input: $input) {
|
||||
id
|
||||
text
|
||||
}
|
||||
}
|
||||
`
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: String!) {
|
||||
deletePart(id: $id) {
|
||||
id
|
||||
title
|
||||
user {
|
||||
id
|
||||
userName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div className="h-screen">Loading...</div>
|
||||
|
||||
export const Empty = () => <div className="h-full">Empty</div>
|
||||
|
||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||
|
||||
export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
|
||||
const { currentUser } = useAuth()
|
||||
const { addMessage } = useFlash()
|
||||
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
|
||||
onCompleted: ({ updatePart }) => {
|
||||
navigate(
|
||||
routes.part({
|
||||
userName: updatePart.user.userName,
|
||||
partTitle: updatePart.title,
|
||||
})
|
||||
)
|
||||
addMessage('Part updated.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
const [createPart] = useMutation(CREATE_PART_MUTATION, {
|
||||
onCompleted: ({ createPart }) => {
|
||||
navigate(
|
||||
routes.part({
|
||||
userName: createPart?.user?.userName,
|
||||
partTitle: createPart?.title,
|
||||
})
|
||||
)
|
||||
addMessage('Part Created.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
const onSave = async (id, input) => {
|
||||
if (!id) {
|
||||
await createPart({ variables: { input } })
|
||||
} else {
|
||||
await updatePart({ variables: { id, input } })
|
||||
}
|
||||
refetch()
|
||||
}
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
onCompleted: ({ deletePart }) => {
|
||||
navigate(routes.home())
|
||||
addMessage('Part deleted.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
|
||||
const onDelete = () => {
|
||||
userPart?.Part?.id && deletePart({ variables: { id: userPart?.Part?.id } })
|
||||
}
|
||||
|
||||
const [toggleReaction] = useMutation(TOGGLE_REACTION_MUTATION, {
|
||||
onCompleted: () => refetch(),
|
||||
refetchQueries: [
|
||||
{
|
||||
query: PART_REACTION_QUERY,
|
||||
variables: { partId: userPart?.Part?.id },
|
||||
},
|
||||
],
|
||||
})
|
||||
const onReaction = (emote) =>
|
||||
toggleReaction({
|
||||
variables: {
|
||||
input: {
|
||||
emote,
|
||||
userId: currentUser.sub,
|
||||
partId: userPart?.Part?.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const [createComment] = useMutation(CREATE_COMMENT_MUTATION, {
|
||||
onCompleted: () => refetch(),
|
||||
})
|
||||
const onComment = (text) =>
|
||||
createComment({
|
||||
variables: {
|
||||
input: {
|
||||
text,
|
||||
userId: currentUser.sub,
|
||||
partId: userPart?.Part?.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PartProfile
|
||||
userPart={userPart}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEditable={isEditable}
|
||||
onReaction={onReaction}
|
||||
onComment={onComment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
6
app/web/src/components/PartCell/PartCell.mock.js
Normal file
6
app/web/src/components/PartCell/PartCell.mock.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
part: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
20
app/web/src/components/PartCell/PartCell.stories.js
Normal file
20
app/web/src/components/PartCell/PartCell.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './PartCell'
|
||||
import { standard } from './PartCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/PartCell' }
|
||||
26
app/web/src/components/PartCell/PartCell.test.js
Normal file
26
app/web/src/components/PartCell/PartCell.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartCell'
|
||||
import { standard } from './PartCell.mock'
|
||||
|
||||
describe('PartCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Empty renders successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success part={standard().part} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
115
app/web/src/components/PartForm/PartForm.js
Normal file
115
app/web/src/components/PartForm/PartForm.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Form,
|
||||
FormError,
|
||||
FieldError,
|
||||
Label,
|
||||
TextField,
|
||||
Submit,
|
||||
} from '@redwoodjs/forms'
|
||||
|
||||
const PartForm = (props) => {
|
||||
const onSubmit = (data) => {
|
||||
props.onSave(data, props?.part?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-form-wrapper">
|
||||
<Form onSubmit={onSubmit} error={props.error}>
|
||||
<FormError
|
||||
error={props.error}
|
||||
wrapperClassName="rw-form-error-wrapper"
|
||||
titleClassName="rw-form-error-title"
|
||||
listClassName="rw-form-error-list"
|
||||
/>
|
||||
|
||||
<Label
|
||||
name="title"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Title
|
||||
</Label>
|
||||
<TextField
|
||||
name="title"
|
||||
defaultValue={props.part?.title}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="title" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="description"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Description
|
||||
</Label>
|
||||
<TextField
|
||||
name="description"
|
||||
defaultValue={props.part?.description}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="description" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="code"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Code
|
||||
</Label>
|
||||
<TextField
|
||||
name="code"
|
||||
defaultValue={props.part?.code}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="code" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="mainImage"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Main image
|
||||
</Label>
|
||||
<TextField
|
||||
name="mainImage"
|
||||
defaultValue={props.part?.mainImage}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="mainImage" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="userId"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
User id
|
||||
</Label>
|
||||
<TextField
|
||||
name="userId"
|
||||
defaultValue={props.part?.userId}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="userId" className="rw-field-error" />
|
||||
|
||||
<div className="rw-button-group">
|
||||
<Submit disabled={props.loading} className="rw-button rw-button-blue">
|
||||
Save
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartForm
|
||||
291
app/web/src/components/PartProfile/PartProfile.js
Normal file
291
app/web/src/components/PartProfile/PartProfile.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import { Link, navigate, routes } from '@redwoodjs/router'
|
||||
import Editor from 'rich-markdown-editor'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
import ConfirmDialog from 'src/components/ConfirmDialog'
|
||||
import Breadcrumb from 'src/components/Breadcrumb'
|
||||
import EmojiReaction from 'src/components/EmojiReaction'
|
||||
import Button from 'src/components/Button'
|
||||
import PartReactionsCell from '../PartReactionsCell'
|
||||
import { countEmotes } from 'src/helpers/emote'
|
||||
import { getActiveClasses } from 'get-active-classes'
|
||||
|
||||
const PartProfile = ({
|
||||
userPart,
|
||||
isEditable,
|
||||
onSave,
|
||||
onDelete,
|
||||
onReaction,
|
||||
onComment,
|
||||
}) => {
|
||||
const [comment, setComment] = useState('')
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false)
|
||||
const [isReactionsModalOpen, setIsReactionsModalOpen] = useState(false)
|
||||
const [isInvalid, setIsInvalid] = useState(false)
|
||||
const { currentUser } = useAuth()
|
||||
const editorRef = useRef(null)
|
||||
const canEdit = currentUser?.sub === userPart.id
|
||||
const isImageEditable = !isEditable && canEdit // image is editable when not in profile edit mode in order to separate them as it's too hard too to upload an image to cloudinary temporarily until the use saves (and maybe have to clean up) for the time being
|
||||
const part = userPart?.Part
|
||||
const emotes = countEmotes(part?.Reaction)
|
||||
const userEmotes = part?.userReactions.map(({ emote }) => emote)
|
||||
useEffect(() => {
|
||||
isEditable &&
|
||||
!canEdit &&
|
||||
navigate(
|
||||
routes.part({ userName: userPart.userName, partTitle: part?.title })
|
||||
)
|
||||
}, [currentUser])
|
||||
const [input, setInput] = useState({
|
||||
title: part?.title,
|
||||
mainImage: part?.mainImage,
|
||||
description: part?.description,
|
||||
userId: userPart?.id,
|
||||
})
|
||||
const setProperty = (property, value) =>
|
||||
setInput({
|
||||
...input,
|
||||
[property]: value,
|
||||
})
|
||||
const onTitleChange = ({ target }) =>
|
||||
setProperty('title', target.value.replace(/([^a-zA-Z\d_:])/g, '-'))
|
||||
const onDescriptionChange = (description) =>
|
||||
setProperty('description', description())
|
||||
const onImageUpload = ({ cloudinaryPublicId }) => {
|
||||
onSave(part?.id, { ...input, mainImage: cloudinaryPublicId })
|
||||
}
|
||||
// setProperty('mainImage', cloudinaryPublicId)
|
||||
const onEditSaveClick = (hi) => {
|
||||
// do a thing
|
||||
if (isEditable) {
|
||||
if (!input.title) {
|
||||
setIsInvalid(true)
|
||||
return
|
||||
}
|
||||
setIsInvalid(false)
|
||||
onSave(part?.id, input)
|
||||
return
|
||||
}
|
||||
navigate(
|
||||
routes.editPart({ userName: userPart?.userName, partTitle: part?.title })
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="grid mt-20 gap-8"
|
||||
style={{ gridTemplateColumns: 'auto 12rem minmax(12rem, 42rem) auto' }}
|
||||
>
|
||||
{/* Side column */}
|
||||
<aside className="col-start-2 relative">
|
||||
<ImageUploader
|
||||
className="rounded-half rounded-br-lg shadow-md border-2 border-gray-200 border-solid"
|
||||
aspectRatio={1}
|
||||
imageUrl={userPart?.image}
|
||||
width={300}
|
||||
/>
|
||||
<h4 className="text-indigo-800 text-xl underline text-right py-4">
|
||||
<Link to={routes.user({ userName: userPart?.userName })}>
|
||||
{userPart?.name}
|
||||
</Link>
|
||||
</h4>
|
||||
<div className="h-px bg-indigo-200 mb-4" />
|
||||
<EmojiReaction
|
||||
emotes={emotes}
|
||||
userEmotes={userEmotes}
|
||||
onEmote={onReaction}
|
||||
onShowPartReactions={() => setIsReactionsModalOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
className="mt-6 ml-auto hover:shadow-lg bg-gradient-to-r from-transparent to-indigo-100"
|
||||
shouldAnimateHover
|
||||
iconName="chevron-down"
|
||||
onClick={() => {
|
||||
document.getElementById('comment-section').scrollIntoView()
|
||||
}}
|
||||
>
|
||||
{userPart?.Part?.Comment.length} Comments
|
||||
</Button>
|
||||
<Link
|
||||
to={routes.ide({
|
||||
userName: userPart?.userName,
|
||||
partTitle: part?.title,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-indigo-200 w-full justify-end"
|
||||
shouldAnimateHover
|
||||
iconName="terminal"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Open IDE
|
||||
</Button>
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-indigo-200 relative z-20 w-full justify-end"
|
||||
shouldAnimateHover
|
||||
iconName={isEditable ? 'save' : 'pencil'}
|
||||
onClick={onEditSaveClick}
|
||||
>
|
||||
{isEditable ? 'Save Details' : 'Edit Details'}
|
||||
</Button>
|
||||
{isEditable && (
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-indigo-200 relative z-20 w-full justify-end"
|
||||
shouldAnimateHover
|
||||
iconName="x"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
routes.part({
|
||||
userName: userPart.userName,
|
||||
partTitle: part?.title,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="mt-4 ml-auto shadow-md hover:shadow-lg bg-red-200 relative z-20 w-full justify-end"
|
||||
shouldAnimateHover
|
||||
iconName={'trash'}
|
||||
onClick={() => setIsConfirmDialogOpen(true)}
|
||||
type="danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* gray overlay */}
|
||||
{isEditable && (
|
||||
<div className="absolute inset-0 bg-gray-300 opacity-75 z-10 transform scale-x-110 -ml-1 -mt-2" />
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* main project center column */}
|
||||
<section className="col-start-3">
|
||||
<Breadcrumb
|
||||
className="inline"
|
||||
onPartTitleChange={isEditable && onTitleChange}
|
||||
userName={userPart?.userName}
|
||||
partTitle={input?.title}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
{!isEditable && part?.id && (
|
||||
<ImageUploader
|
||||
className="rounded-lg shadow-md border-2 border-gray-200 border-solid mt-8"
|
||||
onImageUpload={onImageUpload}
|
||||
aspectRatio={16 / 9}
|
||||
isEditable={isImageEditable}
|
||||
imageUrl={input?.mainImage}
|
||||
width={1010}
|
||||
/>
|
||||
)}
|
||||
<div className="text-gray-500 text-sm font-ropa-sans pt-8">
|
||||
{isEditable ? 'Markdown supported' : ''}
|
||||
</div>
|
||||
<div
|
||||
id="description-wrap"
|
||||
name="description"
|
||||
className="markdown-overrides rounded-lg shadow-md bg-white px-12 py-6 min-h-md"
|
||||
onClick={(e) =>
|
||||
e?.target?.id === 'description-wrap' &&
|
||||
editorRef?.current?.focusAtEnd()
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
defaultValue={part?.description || ''}
|
||||
readOnly={!isEditable}
|
||||
onChange={onDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* comments */}
|
||||
{!isEditable && (
|
||||
<>
|
||||
<div className="h-px bg-indigo-200 mt-8" />
|
||||
<h3
|
||||
className="text-indigo-800 text-lg font-roboto tracking-wider mb-4"
|
||||
id="comment-section"
|
||||
>
|
||||
Comments
|
||||
</h3>
|
||||
|
||||
<ul>
|
||||
{part?.Comment.map(({ text, user, id }) => (
|
||||
<li key={id} className="flex mb-6">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow flex-shrink-0">
|
||||
<ImageUploader
|
||||
className=""
|
||||
aspectRatio={1}
|
||||
imageUrl={user?.image}
|
||||
width={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 font-roboto">
|
||||
<div className="text-gray-800 font-bold text-lg mb-1">
|
||||
<Link to={routes.user({ userName: user?.userName })}>
|
||||
{user?.userName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-gray-700 p-3 rounded bg-gray-200 shadow">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{currentUser && (
|
||||
<>
|
||||
<div className="mt-12 ml-12">
|
||||
<textarea
|
||||
className="w-full h-32 rounded-lg shadow-inner outline-none resize-none p-3"
|
||||
placeholder="Leave a comment"
|
||||
value={comment}
|
||||
onChange={({ target }) => setComment(target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className={getActiveClasses(
|
||||
'ml-auto hover:shadow-lg bg-gray-300 mt-4 mb-20',
|
||||
{ 'bg-indigo-200': currentUser }
|
||||
)}
|
||||
shouldAnimateHover
|
||||
disabled={!currentUser}
|
||||
iconName={'save'}
|
||||
onClick={() => onComment(comment)}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDialogOpen}
|
||||
onClose={() => setIsConfirmDialogOpen(false)}
|
||||
onConfirm={onDelete}
|
||||
message="Are you sure you want to delete? This action cannot be undone."
|
||||
/>
|
||||
<Dialog
|
||||
open={isReactionsModalOpen}
|
||||
onClose={() => setIsReactionsModalOpen(false)}
|
||||
fullWidth={true}
|
||||
maxWidth={'sm'}
|
||||
>
|
||||
<PartReactionsCell partId={userPart?.Part?.id} />
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartProfile
|
||||
@@ -0,0 +1,7 @@
|
||||
import PartProfile from './PartProfile'
|
||||
|
||||
export const generated = () => {
|
||||
return <PartProfile />
|
||||
}
|
||||
|
||||
export default { title: 'Components/PartProfile' }
|
||||
11
app/web/src/components/PartProfile/PartProfile.test.js
Normal file
11
app/web/src/components/PartProfile/PartProfile.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import PartProfile from './PartProfile'
|
||||
|
||||
describe('PartProfile', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<PartProfile />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
75
app/web/src/components/PartReactions/PartReactions.js
Normal file
75
app/web/src/components/PartReactions/PartReactions.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import Tab from '@material-ui/core/Tab'
|
||||
import Tabs from '@material-ui/core/Tabs'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { countEmotes } from 'src/helpers/emote'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
|
||||
const PartReactions = ({ reactions }) => {
|
||||
const emotes = countEmotes(reactions)
|
||||
const [tab, setTab] = useState(0)
|
||||
const onTabChange = (_, newValue) => {
|
||||
setTab(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 p-4 min-h-md rounded-lg shadow-lg">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={onTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="off"
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
>
|
||||
<Tab label="All" style={{ minWidth: 100 }} />
|
||||
{emotes.map((emote, i) => (
|
||||
<Tab
|
||||
label={`${emote.emoji} ${emote.count}`}
|
||||
key={`${emote.emoji}-${i}}`}
|
||||
style={{ minWidth: 100 }}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<ul>
|
||||
{reactions
|
||||
.filter((reaction) =>
|
||||
tab === 0 ? true : reaction.emote === emotes[tab - 1].emoji
|
||||
)
|
||||
.map((reactionPart, i) => (
|
||||
<li
|
||||
className="flex flex-row justify-between p-3 items-center"
|
||||
key={`${reactionPart.emote}-${i}}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow flex-shrink-0">
|
||||
<ImageUploader
|
||||
className=""
|
||||
aspectRatio={1}
|
||||
imageUrl={reactionPart.user?.image}
|
||||
width={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 font-roboto">
|
||||
<div className="text-gray-800 font-normal text-md mb-1">
|
||||
<Link
|
||||
to={routes.user({
|
||||
userName: reactionPart.user?.userName,
|
||||
})}
|
||||
>
|
||||
{reactionPart.user?.userName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>{reactionPart.emote}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartReactions
|
||||
@@ -0,0 +1,30 @@
|
||||
import PartReactions from 'src/components/PartReactions'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PartReactionsQuery($partId: String!) {
|
||||
partReactionsByPartId(partId: $partId) {
|
||||
id
|
||||
emote
|
||||
user {
|
||||
id
|
||||
userName
|
||||
image
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="text-center py-8 font-roboto text-gray-700">
|
||||
No reactions to this part yet 😕
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||
|
||||
export const Success = ({ partReactionsByPartId }) => {
|
||||
return <PartReactions reactions={partReactionsByPartId} />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
partReactions: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
|
||||
import { standard } from './PartReactionsCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/PartReactionsCell' }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
|
||||
import { standard } from './PartReactionsCell.mock'
|
||||
|
||||
describe('PartReactionsCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Empty renders successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success partReactions={standard().partReactions} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
84
app/web/src/components/Parts/Parts.js
Normal file
84
app/web/src/components/Parts/Parts.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { countEmotes } from 'src/helpers/emote'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
|
||||
const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
|
||||
// temporary filtering parts that don't have images until some kind of search is added and there are more things on the website
|
||||
// it helps avoid the look of the website just being filled with dumby data.
|
||||
// related issue-104
|
||||
const filteredParts = useMemo(
|
||||
() =>
|
||||
(shouldFilterPartsWithoutImage
|
||||
? parts.filter(({ mainImage }) => mainImage)
|
||||
: [...parts]
|
||||
)
|
||||
// sort should probably be done on the service, but the filtering is temp too
|
||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)),
|
||||
[parts, shouldFilterPartsWithoutImage]
|
||||
)
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto mt-8">
|
||||
<ul
|
||||
className="grid gap-x-8 gap-y-12 items-center mx-4 relative"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
|
||||
>
|
||||
{filteredParts.map(({ title, mainImage, user, Reaction }) => (
|
||||
<li
|
||||
className="rounded-lg shadow-md hover:shadow-lg mx-px transform hover:-translate-y-px transition-all duration-150"
|
||||
key={`${user?.userName}--${title}`}
|
||||
>
|
||||
<Link
|
||||
to={routes.part({ userName: user?.userName, partTitle: title })}
|
||||
>
|
||||
<div className="flex items-center p-2 bg-gray-200 border-gray-300 rounded-t-lg border-t border-l border-r">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow">
|
||||
<ImageUploader
|
||||
className=""
|
||||
aspectRatio={1}
|
||||
imageUrl={user?.image}
|
||||
width={50}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-ropa-sans ml-3 text-lg text-indigo-900">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden relative rounded-b-lg">
|
||||
<ImageUploader
|
||||
className=""
|
||||
aspectRatio={1.4}
|
||||
imageUrl={mainImage}
|
||||
width={700}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(19.04deg, rgba(62, 44, 118, 0.46) 10.52%, rgba(60, 54, 107, 0) 40.02%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 -mb-4 mr-4 flex justify-end">
|
||||
{countEmotes(Reaction).map(({ emoji, count }) => (
|
||||
<div
|
||||
key={emoji}
|
||||
className="h-8 w-8 overflow-hidden ml-2 p-1 rounded-full bg-opacity-75 bg-gray-200 border border-gray-300 shadow-md flex items-center justify-between"
|
||||
>
|
||||
<div className="-ml-px text-sm w-1">{emoji}</div>
|
||||
<div className="text-sm pl-1 font-ropa-sans text-gray-800">
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartsList
|
||||
47
app/web/src/components/PartsCell/PartsCell.js
Normal file
47
app/web/src/components/PartsCell/PartsCell.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Parts from 'src/components/Parts'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS {
|
||||
parts {
|
||||
id
|
||||
title
|
||||
mainImage
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
image
|
||||
userName
|
||||
}
|
||||
Reaction {
|
||||
emote
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No parts yet. '}
|
||||
<Link to={routes.draftPart()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Success = ({
|
||||
parts,
|
||||
variables: { shouldFilterPartsWithoutImage },
|
||||
}) => {
|
||||
return (
|
||||
<Parts
|
||||
parts={parts}
|
||||
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
40
app/web/src/components/PartsOfUserCell/PartsOfUserCell.js
Normal file
40
app/web/src/components/PartsOfUserCell/PartsOfUserCell.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Parts from 'src/components/Parts'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS_OF_USER($userName: String!) {
|
||||
parts(userName: $userName) {
|
||||
id
|
||||
title
|
||||
mainImage
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
image
|
||||
userName
|
||||
}
|
||||
Reaction {
|
||||
emote
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return <div className="rw-text-center">No parts yet.</div>
|
||||
}
|
||||
|
||||
export const Success = ({
|
||||
parts,
|
||||
variables: { shouldFilterPartsWithoutImage },
|
||||
}) => {
|
||||
return (
|
||||
<Parts
|
||||
parts={parts}
|
||||
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
partsOfUser: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './PartsOfUserCell'
|
||||
import { standard } from './PartsOfUserCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/PartsOfUserCell' }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartsOfUserCell'
|
||||
import { standard } from './PartsOfUserCell.mock'
|
||||
|
||||
describe('PartsOfUserCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Empty renders successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success partsOfUser={standard().partsOfUser} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
33
app/web/src/components/ProfileTextInput/ProfileTextInput.js
Normal file
33
app/web/src/components/ProfileTextInput/ProfileTextInput.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import InputText from 'src/components/InputText'
|
||||
|
||||
const ProfileTextInput = ({ fields, isEditable, onChange = () => {} }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="grid items-center"
|
||||
style={{ gridTemplateColumns: 'auto 1fr' }}
|
||||
>
|
||||
{Object.entries(fields).map(([property, value]) => (
|
||||
<Fragment key={property}>
|
||||
<span className="capitalize text-gray-500 text-sm align-middle my-3">
|
||||
{property}:
|
||||
</span>
|
||||
|
||||
<InputText
|
||||
className="text-xl"
|
||||
value={value}
|
||||
onChange={({ target }) =>
|
||||
onChange({ ...fields, [property]: target.value })
|
||||
}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileTextInput
|
||||
@@ -0,0 +1,7 @@
|
||||
import ProfileTextInput from './ProfileTextInput'
|
||||
|
||||
export const generated = () => {
|
||||
return <ProfileTextInput />
|
||||
}
|
||||
|
||||
export default { title: 'Components/ProfileTextInput' }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import ProfileTextInput from './ProfileTextInput'
|
||||
|
||||
describe('ProfileTextInput', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ProfileTextInput />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
23
app/web/src/components/Seo/Seo.js
Normal file
23
app/web/src/components/Seo/Seo.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Helmet } from 'react-helmet'
|
||||
|
||||
const Seo = ({ title, description, lang }) => {
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
htmlAttributes={{
|
||||
lang,
|
||||
}}
|
||||
title={title}
|
||||
titleTemplate={`Cadhub - ${title}`}
|
||||
>
|
||||
<meta property="og:locale" content={lang} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta name="description" content={description} />
|
||||
<title>Cadhub - {title}</title>
|
||||
</Helmet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Seo
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
|
||||
import { QUERY } from 'src/components/SubjectAccessRequestsCell'
|
||||
|
||||
const DELETE_SUBJECT_ACCESS_REQUEST_MUTATION = gql`
|
||||
mutation DeleteSubjectAccessRequestMutation($id: String!) {
|
||||
deleteSubjectAccessRequest(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const jsonDisplay = (obj) => {
|
||||
return (
|
||||
<pre>
|
||||
<code>{JSON.stringify(obj, null, 2)}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const SubjectAccessRequest = ({ subjectAccessRequest }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [deleteSubjectAccessRequest] = useMutation(
|
||||
DELETE_SUBJECT_ACCESS_REQUEST_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
navigate(routes.subjectAccessRequests())
|
||||
addMessage('SubjectAccessRequest deleted.', {
|
||||
classes: 'rw-flash-success',
|
||||
})
|
||||
},
|
||||
// This refetches the query on the list page. Read more about other ways to
|
||||
// update the cache over here:
|
||||
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to delete subjectAccessRequest ' + id + '?'
|
||||
)
|
||||
) {
|
||||
deleteSubjectAccessRequest({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
SubjectAccessRequest {subjectAccessRequest.id} Detail
|
||||
</h2>
|
||||
</header>
|
||||
<table className="rw-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<td>{subjectAccessRequest.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Comment</th>
|
||||
<td>{subjectAccessRequest.comment}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Payload</th>
|
||||
<td>{subjectAccessRequest.payload}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User id</th>
|
||||
<td>{subjectAccessRequest.userId}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created at</th>
|
||||
<td>{timeTag(subjectAccessRequest.createdAt)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated at</th>
|
||||
<td>{timeTag(subjectAccessRequest.updatedAt)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="rw-button-group">
|
||||
<Link
|
||||
to={routes.editSubjectAccessRequest({ id: subjectAccessRequest.id })}
|
||||
className="rw-button rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<a
|
||||
href="#"
|
||||
className="rw-button rw-button-red"
|
||||
onClick={() => onDeleteClick(subjectAccessRequest.id)}
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectAccessRequest
|
||||
@@ -0,0 +1,22 @@
|
||||
import SubjectAccessRequest from 'src/components/SubjectAccessRequest'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_SUBJECT_ACCESS_REQUEST_BY_ID($id: String!) {
|
||||
subjectAccessRequest: subjectAccessRequest(id: $id) {
|
||||
id
|
||||
comment
|
||||
payload
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => <div>SubjectAccessRequest not found</div>
|
||||
|
||||
export const Success = ({ subjectAccessRequest }) => {
|
||||
return <SubjectAccessRequest subjectAccessRequest={subjectAccessRequest} />
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Form,
|
||||
FormError,
|
||||
FieldError,
|
||||
Label,
|
||||
TextField,
|
||||
Submit,
|
||||
} from '@redwoodjs/forms'
|
||||
|
||||
const SubjectAccessRequestForm = (props) => {
|
||||
const onSubmit = (data) => {
|
||||
props.onSave(data, props?.subjectAccessRequest?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-form-wrapper">
|
||||
<Form onSubmit={onSubmit} error={props.error}>
|
||||
<FormError
|
||||
error={props.error}
|
||||
wrapperClassName="rw-form-error-wrapper"
|
||||
titleClassName="rw-form-error-title"
|
||||
listClassName="rw-form-error-list"
|
||||
/>
|
||||
|
||||
<Label
|
||||
name="comment"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Comment
|
||||
</Label>
|
||||
<TextField
|
||||
name="comment"
|
||||
defaultValue={props.subjectAccessRequest?.comment}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="comment" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="payload"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Payload
|
||||
</Label>
|
||||
<TextField
|
||||
name="payload"
|
||||
defaultValue={props.subjectAccessRequest?.payload}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="payload" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="userId"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
User id
|
||||
</Label>
|
||||
<TextField
|
||||
name="userId"
|
||||
defaultValue={props.subjectAccessRequest?.userId}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
<FieldError name="userId" className="rw-field-error" />
|
||||
|
||||
<div className="rw-button-group">
|
||||
<Submit disabled={props.loading} className="rw-button rw-button-blue">
|
||||
Save
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectAccessRequestForm
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { QUERY } from 'src/components/SubjectAccessRequestsCell'
|
||||
|
||||
const DELETE_SUBJECT_ACCESS_REQUEST_MUTATION = gql`
|
||||
mutation DeleteSubjectAccessRequestMutation($id: String!) {
|
||||
deleteSubjectAccessRequest(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
|
||||
const truncate = (text) => {
|
||||
let output = text
|
||||
if (text && text.length > MAX_STRING_LENGTH) {
|
||||
output = output.substring(0, MAX_STRING_LENGTH) + '...'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const jsonTruncate = (obj) => {
|
||||
return truncate(JSON.stringify(obj, null, 2))
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const SubjectAccessRequestsList = ({ subjectAccessRequests }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [deleteSubjectAccessRequest] = useMutation(
|
||||
DELETE_SUBJECT_ACCESS_REQUEST_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
addMessage('SubjectAccessRequest deleted.', {
|
||||
classes: 'rw-flash-success',
|
||||
})
|
||||
},
|
||||
// This refetches the query on the list page. Read more about other ways to
|
||||
// update the cache over here:
|
||||
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to delete subjectAccessRequest ' + id + '?'
|
||||
)
|
||||
) {
|
||||
deleteSubjectAccessRequest({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment rw-table-wrapper-responsive">
|
||||
<table className="rw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Comment</th>
|
||||
<th>Payload</th>
|
||||
<th>User id</th>
|
||||
<th>Created at</th>
|
||||
<th>Updated at</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subjectAccessRequests.map((subjectAccessRequest) => (
|
||||
<tr key={subjectAccessRequest.id}>
|
||||
<td>{truncate(subjectAccessRequest.id)}</td>
|
||||
<td>{truncate(subjectAccessRequest.comment)}</td>
|
||||
<td>{truncate(subjectAccessRequest.payload)}</td>
|
||||
<td>{truncate(subjectAccessRequest.userId)}</td>
|
||||
<td>{timeTag(subjectAccessRequest.createdAt)}</td>
|
||||
<td>{timeTag(subjectAccessRequest.updatedAt)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<Link
|
||||
to={routes.subjectAccessRequest({
|
||||
id: subjectAccessRequest.id,
|
||||
})}
|
||||
title={
|
||||
'Show subjectAccessRequest ' +
|
||||
subjectAccessRequest.id +
|
||||
' detail'
|
||||
}
|
||||
className="rw-button rw-button-small"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editSubjectAccessRequest({
|
||||
id: subjectAccessRequest.id,
|
||||
})}
|
||||
title={
|
||||
'Edit subjectAccessRequest ' + subjectAccessRequest.id
|
||||
}
|
||||
className="rw-button rw-button-small rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<a
|
||||
href="#"
|
||||
title={
|
||||
'Delete subjectAccessRequest ' + subjectAccessRequest.id
|
||||
}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(subjectAccessRequest.id)}
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectAccessRequestsList
|
||||
@@ -0,0 +1,24 @@
|
||||
import SubjectAccessRequests from 'src/components/SubjectAccessRequests'
|
||||
|
||||
export const QUERY = gql`
|
||||
query SUBJECT_ACCESS_REQUESTS {
|
||||
subjectAccessRequests {
|
||||
id
|
||||
comment
|
||||
payload
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return <div className="rw-text-center">No subjectAccessRequests yet.</div>
|
||||
}
|
||||
|
||||
export const Success = ({ subjectAccessRequests }) => {
|
||||
return <SubjectAccessRequests subjectAccessRequests={subjectAccessRequests} />
|
||||
}
|
||||
391
app/web/src/components/Svg/Svg.js
Normal file
391
app/web/src/components/Svg/Svg.js
Normal file
File diff suppressed because one or more lines are too long
106
app/web/src/components/UserProfile/UserProfile.js
Normal file
106
app/web/src/components/UserProfile/UserProfile.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import Editor from 'rich-markdown-editor'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
import Button from 'src/components/Button'
|
||||
import ProfileTextInput from 'src/components/ProfileTextInput'
|
||||
import PartsOfUser from 'src/components/PartsOfUserCell'
|
||||
|
||||
const UserProfile = ({ user, isEditable, loading, onSave, error, parts }) => {
|
||||
const { currentUser } = useAuth()
|
||||
const canEdit = currentUser?.sub === user.id
|
||||
const isImageEditable = !isEditable && canEdit // image is editable when not in profile edit mode in order to separate them as it's too hard too to upload an image to cloudinary temporarily until the use saves (and maybe have to clean up) for the time being
|
||||
useEffect(() => {
|
||||
isEditable && !canEdit && navigate(routes.user({ userName: user.userName }))
|
||||
}, [currentUser])
|
||||
const [input, setInput] = useState({
|
||||
userName: user.userName,
|
||||
name: user.name,
|
||||
bio: user.bio,
|
||||
image: user.image,
|
||||
})
|
||||
const { userName, name } = input
|
||||
const editableTextFields = { userName, name }
|
||||
return (
|
||||
<>
|
||||
<section className="max-w-2xl mx-auto mt-20 ">
|
||||
<div className="flex">
|
||||
{!isEditable && (
|
||||
<div className="w-40 flex-shrink-0">
|
||||
<ImageUploader
|
||||
className="rounded-half rounded-br-lg shadow-md border-2 border-gray-200 border-solid"
|
||||
onImageUpload={({ cloudinaryPublicId: image }) => {
|
||||
onSave(user.userName, {
|
||||
...input,
|
||||
image,
|
||||
})
|
||||
}}
|
||||
aspectRatio={1}
|
||||
isEditable={isImageEditable}
|
||||
imageUrl={user.image}
|
||||
width={300}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-6 flex flex-col justify-between">
|
||||
<ProfileTextInput
|
||||
fields={editableTextFields}
|
||||
onChange={({ userName, name }) =>
|
||||
setInput({
|
||||
...input,
|
||||
name,
|
||||
userName: userName.replace(/([^a-zA-Z\d_:])/g, '-'),
|
||||
})
|
||||
}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
className="bg-indigo-200"
|
||||
iconName="plus"
|
||||
onClick={() => onSave(user.userName, input)}
|
||||
>
|
||||
Save Profile
|
||||
</Button> // TODO replace pencil with a save icon
|
||||
) : canEdit ? (
|
||||
<Button
|
||||
className="bg-indigo-200"
|
||||
iconName="pencil"
|
||||
onClick={() =>
|
||||
navigate(routes.editUser({ userName: user.userName }))
|
||||
}
|
||||
>
|
||||
Edit Profile
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-3xl text-gray-500 font-ropa-sans">Bio:</h3>
|
||||
<div
|
||||
name="description"
|
||||
className="markdown-overrides rounded-lg shadow-md bg-white p-12 my-6 min-h-md"
|
||||
>
|
||||
<Editor
|
||||
defaultValue={user.bio || ''}
|
||||
readOnly={!isEditable}
|
||||
onChange={(bioFn) =>
|
||||
setInput({
|
||||
...input,
|
||||
bio: bioFn(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-3xl text-gray-500 font-ropa-sans">Parts:</h3>
|
||||
<PartsOfUser userName={user?.userName} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
@@ -0,0 +1,7 @@
|
||||
import UserProfile from './UserProfile'
|
||||
|
||||
export const generated = () => {
|
||||
return <UserProfile />
|
||||
}
|
||||
|
||||
export default { title: 'Components/UserProfile' }
|
||||
11
app/web/src/components/UserProfile/UserProfile.test.js
Normal file
11
app/web/src/components/UserProfile/UserProfile.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import UserProfile from './UserProfile'
|
||||
|
||||
describe('UserProfile', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<UserProfile />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
97
app/web/src/components/Users/Users.js
Normal file
97
app/web/src/components/Users/Users.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMutation, useFlash } from '@redwoodjs/web'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
const DELETE_USER_MUTATION = gql`
|
||||
mutation DeleteUserMutation($id: String!) {
|
||||
deleteUser(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
|
||||
const truncate = (text) => {
|
||||
let output = text
|
||||
if (text && text.length > MAX_STRING_LENGTH) {
|
||||
output = output.substring(0, MAX_STRING_LENGTH) + '...'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const jsonTruncate = (obj) => {
|
||||
return truncate(JSON.stringify(obj, null, 2))
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const UsersList = ({ users }) => {
|
||||
const { addMessage } = useFlash()
|
||||
const [deleteUser] = useMutation(DELETE_USER_MUTATION, {
|
||||
onCompleted: () => {
|
||||
addMessage('User deleted.', { classes: 'rw-flash-success' })
|
||||
},
|
||||
})
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (confirm('Are you sure you want to delete user ' + id + '?')) {
|
||||
deleteUser({ variables: { id }, refetchQueries: ['USERS'] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment rw-table-wrapper-responsive">
|
||||
<table className="rw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>User name</th>
|
||||
<th>Email</th>
|
||||
<th>Created at</th>
|
||||
<th>Updated at</th>
|
||||
<th>Image</th>
|
||||
<th>Bio</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{truncate(user.id)}</td>
|
||||
<td>{truncate(user.userName)}</td>
|
||||
<td>{truncate(user.email)}</td>
|
||||
<td>{timeTag(user.createdAt)}</td>
|
||||
<td>{timeTag(user.updatedAt)}</td>
|
||||
<td>{truncate(user.image)}</td>
|
||||
<td>{truncate(user.bio)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<a
|
||||
href="#"
|
||||
title={'Delete user ' + user.id}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(user.id)}
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersList
|
||||
34
app/web/src/components/UsersCell/UsersCell.js
Normal file
34
app/web/src/components/UsersCell/UsersCell.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Users from 'src/components/Users'
|
||||
|
||||
export const QUERY = gql`
|
||||
query USERS {
|
||||
users {
|
||||
id
|
||||
userName
|
||||
email
|
||||
createdAt
|
||||
updatedAt
|
||||
image
|
||||
bio
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No users yet. '}
|
||||
<Link to={routes.newUser()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Success = ({ users }) => {
|
||||
return <Users users={users} />
|
||||
}
|
||||
Reference in New Issue
Block a user