Attempt to at move app into app sub dir

This commit is contained in:
Kurt Hutten
2021-05-01 07:32:21 +10:00
parent 9db76458d1
commit 78677a99f8
220 changed files with 1 additions and 1 deletions

View File

View 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>&nbsp;</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

View 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} />
}

View 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

View File

@@ -0,0 +1,7 @@
import Breadcrumb from './Breadcrumb'
export const generated = () => {
return <Breadcrumb />
}
export default { title: 'Components/Breadcrumb' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import Breadcrumb from './Breadcrumb'
describe('Breadcrumb', () => {
it('renders successfully', () => {
expect(() => {
render(<Breadcrumb />)
}).not.toThrow()
})
})

View 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

View 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' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import Button from './Button'
describe('Button', () => {
it('renders successfully', () => {
expect(() => {
render(<Button />)
}).not.toThrow()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import ConfirmDialog from './ConfirmDialog'
export const generated = () => {
return <ConfirmDialog />
}
export default { title: 'Components/ConfirmDialog' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import ConfirmDialog from './ConfirmDialog'
describe('ConfirmDialog', () => {
it('renders successfully', () => {
expect(() => {
render(<ConfirmDialog />)
}).not.toThrow()
})
})

View File

@@ -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>
)
}

View 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}
/>
)
}

View File

@@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
editUser: {
id: 42,
},
})

View 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' }

View 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()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import EmojiReaction from './EmojiReaction'
export const generated = () => {
return <EmojiReaction />
}
export default { title: 'Components/EmojiReaction' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import EmojiReaction from './EmojiReaction'
describe('EmojiReaction', () => {
it('renders successfully', () => {
expect(() => {
render(<EmojiReaction />)
}).not.toThrow()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import Footer from './Footer'
export const generated = () => {
return <Footer />
}
export default { title: 'Components/Footer' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import Footer from './Footer'
describe('Footer', () => {
it('renders successfully', () => {
expect(() => {
render(<Footer />)
}).not.toThrow()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import IdeCascadeStudio from './IdeCascadeStudio'
export const generated = () => {
return <IdeCascadeStudio />
}
export default { title: 'Components/IdeCascadeStudio' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import IdeCascadeStudio from './IdeCascadeStudio'
describe('IdeCascadeStudio', () => {
it('renders successfully', () => {
expect(() => {
render(<IdeCascadeStudio />)
}).not.toThrow()
})
})

View 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

View 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

View 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

View 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}
/>
)
}

View File

@@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
idePart: {
id: 42,
},
})

View 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' }

View 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()
})
})

View 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

View 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' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import IdeToolbar from './IdeToolbar'
describe('IdeToolbar', () => {
it('renders successfully', () => {
expect(() => {
render(<IdeToolbar />)
}).not.toThrow()
})
})

View 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

View 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

View 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
)
})
}

View File

@@ -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' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import ImageUploader from './ImageUploader'
describe('ImageUploader', () => {
it('renders successfully', () => {
expect(() => {
render(<ImageUploader />)
}).not.toThrow()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import InputText from './InputText'
export const generated = () => {
return <InputText />
}
export default { title: 'Components/InputText' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import InputText from './InputText'
describe('InputText', () => {
it('renders successfully', () => {
expect(() => {
render(<InputText />)
}).not.toThrow()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import InputTextForm from './InputTextForm'
export const generated = () => {
return <InputTextForm />
}
export default { title: 'Components/InputTextForm' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import InputTextForm from './InputTextForm'
describe('InputTextForm', () => {
it('renders successfully', () => {
expect(() => {
render(<InputTextForm />)
}).not.toThrow()
})
})

View 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 its our gift to the
code-cad community. Lets 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>
)
}

View File

@@ -0,0 +1,7 @@
import LandingSection from './LandingSection'
export const generated = () => {
return <LandingSection />
}
export default { title: 'Components/LandingSection' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import LandingSection from './LandingSection'
describe('LandingSection', () => {
it('renders successfully', () => {
expect(() => {
render(<LandingSection />)
}).not.toThrow()
})
})

File diff suppressed because one or more lines are too long

View 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

View File

@@ -0,0 +1,7 @@
import LoginModal from './LoginModal'
export const generated = () => {
return <LoginModal open={true} />
}
export default { title: 'Components/LoginModal' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import LoginModal from './LoginModal'
describe('LoginModal', () => {
it('renders successfully', () => {
expect(() => {
render(<LoginModal />)
}).not.toThrow()
})
})

View 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

View 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

View File

@@ -0,0 +1,7 @@
import OutBound from './OutBound'
export const generated = () => {
return <OutBound />
}
export default { title: 'Components/OutBound' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import OutBound from './OutBound'
describe('OutBound', () => {
it('renders successfully', () => {
expect(() => {
render(<OutBound />)
}).not.toThrow()
})
})

View 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}
/>
)
}

View File

@@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
part: {
id: 42,
},
})

View 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' }

View 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()
})
})

View 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

View 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

View File

@@ -0,0 +1,7 @@
import PartProfile from './PartProfile'
export const generated = () => {
return <PartProfile />
}
export default { title: 'Components/PartProfile' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import PartProfile from './PartProfile'
describe('PartProfile', () => {
it('renders successfully', () => {
expect(() => {
render(<PartProfile />)
}).not.toThrow()
})
})

View 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

View File

@@ -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} />
}

View File

@@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
partReactions: {
id: 42,
},
})

View File

@@ -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' }

View File

@@ -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()
})
})

View 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

View 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}
/>
)
}

View 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}
/>
)
}

View File

@@ -0,0 +1,6 @@
// Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({
partsOfUser: {
id: 42,
},
})

View File

@@ -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' }

View File

@@ -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()
})
})

View 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

View File

@@ -0,0 +1,7 @@
import ProfileTextInput from './ProfileTextInput'
export const generated = () => {
return <ProfileTextInput />
}
export default { title: 'Components/ProfileTextInput' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import ProfileTextInput from './ProfileTextInput'
describe('ProfileTextInput', () => {
it('renders successfully', () => {
expect(() => {
render(<ProfileTextInput />)
}).not.toThrow()
})
})

View 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

View File

@@ -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

View File

@@ -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} />
}

View File

@@ -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

View File

@@ -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>&nbsp;</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

View File

@@ -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} />
}

File diff suppressed because one or more lines are too long

View 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

View File

@@ -0,0 +1,7 @@
import UserProfile from './UserProfile'
export const generated = () => {
return <UserProfile />
}
export default { title: 'Components/UserProfile' }

View File

@@ -0,0 +1,11 @@
import { render } from '@redwoodjs/testing'
import UserProfile from './UserProfile'
describe('UserProfile', () => {
it('renders successfully', () => {
expect(() => {
render(<UserProfile />)
}).not.toThrow()
})
})

View 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>&nbsp;</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

View 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} />
}