diff --git a/.gitignore b/.gitignore index 1110f69..c89327a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.history .DS_Store .env .netlify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd4f9ff..9edf3a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,9 @@ Install dependencies yarn install ``` -Setting up the db, you'll need to have a postgres installed locally, you can [follow this guide](https://redwoodjs.com/docs/local-postgres-setup). +Setting up the db, you'll need to have a postgres installed locally, you can [follow this guide](https://redwoodjs.com/docs/local-postgres-setup). -Run the following +Run the following (Note: these commands require the `DATABASE_URL` env variable to be set. if you see no result when you run `echo $DATABASE_URL`, you can set it with a command like `export DATABASE_URL=postgres://postgres:somepassword@localhost`) ``` terminal yarn rw prisma migrate dev yarn rw prisma db seed @@ -59,6 +59,27 @@ localUser2@kurthutten.com: `abc123` localAdmin@kurthutten.com: `abc123` +### Discord bot setup + +To set up the discord bot to notify when users publish new content (see also [the discord JS tutorial](https://discordjs.guide/preparations/setting-up-a-bot-application.html)): + +1. If you're setting up the bot in a dev environment, create a new discord server (the "plus" button on the left when logged into the Discord webpage). Make note of the name of the project. +2. With [developer mode turned on](https://www.howtogeek.com/714348/how-to-enable-or-disable-developer-mode-on-discord/), right click the channel you wish the bot to announce on and select "Copy ID". Add this to `.env.defaults` as `DISCORD_CHANNEL_ID`. +3. [create a new application](https://discord.com/developers/applications), or navigate to an existing one. +4. Create a bot within that application. Copy the bot token and add it to `.env.defaults` as `DISCORD_TOKEN`. +5. Go to the "URL Generator" under "OAuth2" and create a URL with scope "bot" and text permission "Send Messages". +6. Copy the generated URL and open it in a new tab. Follow the instructions on the page to add the bot to your discord server. + +When you next start CADHub, you should see in the logs `Discord: logged in as ` and you should see a startup message from the bot in the channel. + +To send messages as the bot when things happen in the service, use the `sendChat` helper function: + +```typescript +import { sendChat } from 'src/lib/discord' + +sendChat("hello world!") +``` + ## Designs In progress, though can be [seen on Figma](https://www.figma.com/file/VUh53RdncjZ7NuFYj0RGB9/CadHub?node-id=0%3A1) diff --git a/app/.env.defaults b/app/.env.defaults index f698efd..c6799db 100644 --- a/app/.env.defaults +++ b/app/.env.defaults @@ -18,9 +18,9 @@ CLOUDINARY_API_KEY=476712943135152 # trace | info | debug | warn | error | silent # LOG_LEVEL=debug - # EMAIL_PASSWORD=abc123 - +# DISCORD_TOKEN=abc123 +# DISCORD_CHANNEL_ID=12345 # CAD_LAMBDA_BASE_URL="http://localhost:8080" diff --git a/app/api/db/schema.prisma b/app/api/db/schema.prisma index e62813d..9b02179 100644 --- a/app/api/db/schema.prisma +++ b/app/api/db/schema.prisma @@ -5,7 +5,7 @@ datasource db { generator client { provider = "prisma-client-js" - binaryTargets = "native" + binaryTargets = ["native", "darwin-arm64", "darwin"] } // sqlLight does not suport enums so we can't use enums until we set up postgresql in dev mode diff --git a/app/api/package.json b/app/api/package.json index 55d32ad..2013209 100644 --- a/app/api/package.json +++ b/app/api/package.json @@ -9,6 +9,7 @@ "axios": "^0.21.1", "cloudinary": "^1.23.0", "cors": "^2.8.5", + "discord.js": "^13.6.0", "express": "^4.17.1", "human-id": "^2.0.1", "middy": "^0.36.0", @@ -21,4 +22,4 @@ "concurrently": "^6.0.0", "nodemon": "^2.0.7" } -} \ No newline at end of file +} diff --git a/app/api/src/functions/graphql.ts b/app/api/src/functions/graphql.ts index 5441483..667252f 100644 --- a/app/api/src/functions/graphql.ts +++ b/app/api/src/functions/graphql.ts @@ -1,6 +1,7 @@ import { createGraphQLHandler } from '@redwoodjs/graphql-server' import { createSentryApolloPlugin } from 'src/lib/sentry' import { logger } from 'src/lib/logger' +import 'discord.js' import directives from 'src/directives/**/*.{js,ts}' import sdls from 'src/graphql/**/*.sdl.{js,ts}' diff --git a/app/api/src/lib/discord.ts b/app/api/src/lib/discord.ts new file mode 100644 index 0000000..127a03b --- /dev/null +++ b/app/api/src/lib/discord.ts @@ -0,0 +1,40 @@ +import { Client, Intents, MessageAttachment } from 'discord.js' + +const client = new Client({ + intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES], +}) + +export async function sendDiscordMessage(text: string, url?: string) { + if (!client.isReady()) { + console.error(`Discord: client is not ready to send message ("${text}")`) + } else { + const channel = await client.channels.fetch(process.env.DISCORD_CHANNEL_ID) + if (url) { + channel.send({ + embeds: [ + { + title: text, + image: { + url: url, + }, + }, + ], + }) + } else { + channel.send(text) + } + } +} + +client.on('ready', async () => { + console.log(`Discord: logged in as ${client.user.tag}`) +}) + +if (!process.env.DISCORD_TOKEN || !process.env.DISCORD_CHANNEL_ID) { + console.warn( + 'Discord bot not configured - please set process.env.DISCORD_TOKEN and process.env.DISCORD_CHANNEL_ID to send discord chats' + ) +} else { + console.log(`Discord: logging in (token ${process.env.DISCORD_TOKEN})`) + client.login(process.env.DISCORD_TOKEN) +} diff --git a/app/api/src/services/projects/projects.ts b/app/api/src/services/projects/projects.ts index 200e40b..9fbba54 100644 --- a/app/api/src/services/projects/projects.ts +++ b/app/api/src/services/projects/projects.ts @@ -12,6 +12,7 @@ import { } from 'src/services/helpers' import { requireAuth } from 'src/lib/auth' import { requireOwnership, requireProjectOwnership } from 'src/lib/owner' +import { sendDiscordMessage } from 'src/lib/discord' export const projects = ({ userName }) => { if (!userName) { @@ -243,7 +244,22 @@ export const updateProjectImages = async ({ const [updatedProject] = await Promise.all([ projectPromise, imageDestroyPromise, - ]) + ]).then(async (result) => { + const { userName } = await db.user.findUnique({ + where: { id: project.userId }, + }) + sendDiscordMessage( + [ + `${userName} just added an image to their ${project.cadPackage} project:`, + ` => ${project.title}`, + ``, + `Check it out, leave a comment, make them feel welcome!`, + `https://cadhub.xyz/u/${userName}/${project.title}`, + ].join('\n'), + `https://res.cloudinary.com/irevdev/image/upload/c_scale,w_700/v1/${mainImage}` + ) + return result + }) return updatedProject } diff --git a/app/netlify.toml b/app/netlify.toml index 658ce19..b4af42d 100644 --- a/app/netlify.toml +++ b/app/netlify.toml @@ -2,6 +2,7 @@ command = "yarn rw deploy netlify" publish = "web/dist" functions = "api/dist/functions" +ignore = "false" [dev] # To use [Netlify Dev](https://www.netlify.com/products/dev/), diff --git a/app/redwood.toml b/app/redwood.toml index ba5c9a4..4177995 100644 --- a/app/redwood.toml +++ b/app/redwood.toml @@ -17,7 +17,7 @@ 'SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT', - 'EMAIL_PASSWORD' + 'EMAIL_PASSWORD', ] # experimentalFastRefresh = true # this seems to break cascadeStudio [api] diff --git a/app/web/src/Routes.js b/app/web/src/Routes.js index a2f053b..58578bc 100644 --- a/app/web/src/Routes.js +++ b/app/web/src/Routes.js @@ -56,6 +56,7 @@ const Routes = () => { + diff --git a/app/web/src/components/EditorMenu/menuConfig.tsx b/app/web/src/components/EditorMenu/menuConfig.tsx index 4408ad9..32e8669 100644 --- a/app/web/src/components/EditorMenu/menuConfig.tsx +++ b/app/web/src/components/EditorMenu/menuConfig.tsx @@ -1,6 +1,9 @@ import React from 'react' import { useRender } from 'src/components/IdeWrapper/useRender' -import { makeStlDownloadHandler, PullTitleFromFirstLine } from './helpers' +import { + makeStlDownloadHandler, + PullTitleFromFirstLine, +} from 'src/helpers/download_stl' import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode' import { DropdownItem } from './Dropdowns' import { useShortcutsModalContext } from './AllShortcutsModal' diff --git a/app/web/src/components/EmbedProject/EmbedProject.tsx b/app/web/src/components/EmbedProject/EmbedProject.tsx new file mode 100644 index 0000000..fd7e092 --- /dev/null +++ b/app/web/src/components/EmbedProject/EmbedProject.tsx @@ -0,0 +1,30 @@ +import Seo from 'src/components/Seo/Seo' +import IdeViewer from 'src/components/IdeViewer/IdeViewer' +import { useIdeState } from 'src/helpers/hooks/useIdeState' +import type { Project } from 'src/components/EmbedProjectCell/EmbedProjectCell' +import { IdeContext } from 'src/helpers/hooks/useIdeContext' +import { use3dViewerResize } from 'src/helpers/hooks/use3dViewerResize' +import { useEffect } from 'react' + +interface Props { + project?: Project +} + +const EmbedProject = ({ project }: Props) => { + const [state, thunkDispatch] = useIdeState() + const { viewerDomRef, handleViewerSizeUpdate } = use3dViewerResize() + + useEffect(() => { + handleViewerSizeUpdate() + }, []) + + return ( +
+ + + +
+ ) +} + +export default EmbedProject diff --git a/app/web/src/components/EmbedProjectCell/EmbedProjectCell.mock.ts b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.mock.ts new file mode 100644 index 0000000..2e297e0 --- /dev/null +++ b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.mock.ts @@ -0,0 +1,6 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + ideProject: { + id: 42, + }, +}) diff --git a/app/web/src/components/EmbedProjectCell/EmbedProjectCell.stories.tsx b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.stories.tsx new file mode 100644 index 0000000..77b02cf --- /dev/null +++ b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.stories.tsx @@ -0,0 +1,16 @@ +import { Loading, Empty, Success } from './EmbedProjectCell' +import { standard } from './EmbedProjectCell.mock' + +export const loading = () => { + return Loading ? : null +} + +export const empty = () => { + return Empty ? : null +} + +export const success = () => { + return Success ? : null +} + +export default { title: 'Cells/IdeProjectCell' } diff --git a/app/web/src/components/EmbedProjectCell/EmbedProjectCell.test.tsx b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.test.tsx new file mode 100644 index 0000000..9e770d4 --- /dev/null +++ b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@redwoodjs/testing' +import { Loading, Empty, Success } from './EmbedProjectCell' +import { standard } from './EmbedProjectCell.mock' + +describe('IdeProjectCell', () => { + test('Loading renders successfully', () => { + render() + // Use screen.debug() to see output + expect(screen.getByText('Loading...')).toBeInTheDocument() + }) + + test('Empty renders successfully', async () => { + render() + expect(screen.getByText('Empty')).toBeInTheDocument() + }) + + test('Success renders successfully', async () => { + render() + expect(screen.getByText(/42/i)).toBeInTheDocument() + }) +}) diff --git a/app/web/src/components/EmbedProjectCell/EmbedProjectCell.tsx b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.tsx new file mode 100644 index 0000000..3cb2e6c --- /dev/null +++ b/app/web/src/components/EmbedProjectCell/EmbedProjectCell.tsx @@ -0,0 +1,46 @@ +import { useIdeState } from 'src/helpers/hooks/useIdeState' +import { IdeContext } from 'src/helpers/hooks/useIdeContext' +import EmbedViewer from '../EmbedViewer/EmbedViewer' +import { QUERY as IdeQuery } from 'src/components/IdeProjectCell' + +export const QUERY = IdeQuery +export interface Project { + id: string + title: string + code: string + description: string + mainImage: string + createdAt: string + cadPackage: 'openscad' | 'cadquery' + user: { + id: string + userName: string + image: string + } +} + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Project not found
+ +interface SaveCodeArgs { + input: any + id: string + isFork: boolean +} + +export const Success = ({ + project, + refetch, +}: { + project: Project + refetch: any +}) => { + const [state, thunkDispatch] = useIdeState() + + return ( + + + + ) +} diff --git a/app/web/src/components/EmbedViewer/EmbedViewer.tsx b/app/web/src/components/EmbedViewer/EmbedViewer.tsx new file mode 100644 index 0000000..3ecee29 --- /dev/null +++ b/app/web/src/components/EmbedViewer/EmbedViewer.tsx @@ -0,0 +1,50 @@ +import { useIdeInit } from 'src/components/EncodedUrl/helpers' +import { useIdeContext } from 'src/helpers/hooks/useIdeContext' +import IdeViewer from 'src/components/IdeViewer/IdeViewer' +import { use3dViewerResize } from 'src/helpers/hooks/use3dViewerResize' +import CadPackage from '../CadPackage/CadPackage' +import LogoType from '../LogoType/LogoType' +import { Link, routes } from '@redwoodjs/router' + +function EmbedViewer() { + const { state, project } = useIdeContext() + useIdeInit(project?.cadPackage, project?.code || state?.code, 'viewer') + const { viewerDomRef } = use3dViewerResize() + + return ( +
+ +
+

+ {project?.title.replace(/-/g, ' ')} +

+

+ by @{project?.user?.userName} +

+

+ built with{' '} +
+ +
+

+
+
+ View on{' '} + + + +
+
+ ) +} + +export default EmbedViewer diff --git a/app/web/src/components/IdeViewer/IdeViewer.tsx b/app/web/src/components/IdeViewer/IdeViewer.tsx index d1045c3..1f30310 100644 --- a/app/web/src/components/IdeViewer/IdeViewer.tsx +++ b/app/web/src/components/IdeViewer/IdeViewer.tsx @@ -4,8 +4,10 @@ import { PureIdeViewer } from './PureIdeViewer' const IdeViewer = ({ handleOwnCamera = false, + isMinimal = false, }: { handleOwnCamera?: boolean + isMinimal?: boolean }) => { const { state, thunkDispatch } = useIdeContext() const dataType = state.objectData?.type @@ -51,6 +53,7 @@ const IdeViewer = ({ isLoading={state.isLoading} camera={state?.camera} ideType={ideType} + isMinimal={isMinimal} /> ) } diff --git a/app/web/src/components/IdeViewer/PureIdeViewer.tsx b/app/web/src/components/IdeViewer/PureIdeViewer.tsx index 33f5e46..8b0f56f 100644 --- a/app/web/src/components/IdeViewer/PureIdeViewer.tsx +++ b/app/web/src/components/IdeViewer/PureIdeViewer.tsx @@ -212,7 +212,7 @@ export function PureIdeViewer({ alt="code-cad preview" id="special" src={URL.createObjectURL(image)} - className="h-full w-full" + className="w-full h-full" /> )} diff --git a/app/web/src/components/LogoType/LogoType.js b/app/web/src/components/LogoType/LogoType.js new file mode 100644 index 0000000..320289d --- /dev/null +++ b/app/web/src/components/LogoType/LogoType.js @@ -0,0 +1,44 @@ +import Tooltip from '@material-ui/core/Tooltip' +import { Link, routes } from '@redwoodjs/router' +import Svg from 'src/components/Svg' + +export default function LogoType({ className = '', wrappedInLink = false }) { + return ( +
    +
  • + {wrappedInLink ? ( + +
    + +
    + + ) : ( +
    +
    + +
    +
    + )} +
  • +
  • + +
    + {/* Because of how specific these styles are to this heading/logo and it doesn't need to be replicated else where as well as it's very precise with the placement of "pre-alpha" I think it's appropriate. */} +

    + CadHub +

    +
    + pre-alpha +
    +
    +
    +
  • +
+ ) +} diff --git a/app/web/src/components/ProjectCell/ProjectCell.tsx b/app/web/src/components/ProjectCell/ProjectCell.tsx index 8c41c20..428fa24 100644 --- a/app/web/src/components/ProjectCell/ProjectCell.tsx +++ b/app/web/src/components/ProjectCell/ProjectCell.tsx @@ -2,6 +2,7 @@ import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { navigate, routes } from '@redwoodjs/router' import { useAuth } from '@redwoodjs/auth' +import { makeStlDownloadHandler } from 'src/helpers/download_stl' import { useIdeState } from 'src/helpers/hooks/useIdeState' import { IdeContext } from 'src/helpers/hooks/useIdeContext' import { CREATE_PROJECT_MUTATION } from 'src/components/NavPlusButton/NavPlusButton' @@ -192,6 +193,15 @@ export const Success = ({ userProject, refetch }) => { }, }) + const onStlDownload = makeStlDownloadHandler({ + type: state.objectData?.type, + ideType: state.ideType, + geometry: state.objectData?.data, + quality: state.objectData?.quality, + fileName: `${userProject.Project.title}.stl`, + thunkDispatch, + }) + return ( { onDelete={onDelete} onReaction={onReaction} onComment={onComment} + onStlDownload={onStlDownload} /> ) diff --git a/app/web/src/components/ProjectProfile/ProjectProfile.tsx b/app/web/src/components/ProjectProfile/ProjectProfile.tsx index bb8cd1b..2b856c3 100644 --- a/app/web/src/components/ProjectProfile/ProjectProfile.tsx +++ b/app/web/src/components/ProjectProfile/ProjectProfile.tsx @@ -25,6 +25,7 @@ const ProjectProfile = ({ onDelete, onReaction, onComment, + onStlDownload, }) => { const [comment, setComment] = useState('') const [isEditing, setIsEditing] = useState(false) @@ -91,12 +92,25 @@ const ProjectProfile = ({

{project?.title.replace(/-/g, ' ')}

-
- Built with - +
+
+ Built with + +
+
{(project?.description || hasPermissionToEdit) && ( { const { state } = useIdeContext() - if ((state.ideType !== 'openscad' && state.ideType !== 'curv') || state.objectData?.type !== 'png') { + if ( + (state.ideType !== 'openscad' && state.ideType !== 'curv') || + state.objectData?.type !== 'png' + ) { return null } - return state.ideType === 'openscad' ? + return state.ideType === 'openscad' ? ( Why reload each camera move? - :
- Alpha Curv integration, no camera support currently. -
+ ) : ( +
+ Alpha Curv integration, no camera support currently. +
+ ) } export default StaticImageMessage diff --git a/app/web/src/components/Svg/Svg.tsx b/app/web/src/components/Svg/Svg.tsx index 7e47e7c..bd08f29 100644 --- a/app/web/src/components/Svg/Svg.tsx +++ b/app/web/src/components/Svg/Svg.tsx @@ -139,6 +139,21 @@ const Svg = ({ /> ), + 'document-download': ( + + + + ), 'dots-vertical': ( new MeshPhongMaterial(params), }, line: { diff --git a/app/web/src/helpers/cadPackages/jsCad/jscadParams.ts b/app/web/src/helpers/cadPackages/jsCad/jscadParams.ts index 855579a..33f0fc3 100644 --- a/app/web/src/helpers/cadPackages/jsCad/jscadParams.ts +++ b/app/web/src/helpers/cadPackages/jsCad/jscadParams.ts @@ -8,6 +8,7 @@ type JscadTypeNames = | 'group' | 'text' | 'int' + | 'float' | 'number' | 'slider' | 'email' @@ -37,7 +38,7 @@ interface JscadTextParam extends JscadParamBase { maxLength: number } interface JscadIntNumberSliderParam extends JscadParamBase { - type: 'int' | 'number' | 'slider' + type: 'int' | 'number' | 'float' | 'slider' initial: number min?: number max?: number @@ -93,6 +94,7 @@ export function jsCadToCadhubParams(input: JsCadParams[]): CadhubParams[] { switch (param.type) { case 'slider': case 'number': + case 'float': case 'int': return { type: 'number', diff --git a/app/web/src/helpers/cadPackages/jsCad/jscadWorker.ts b/app/web/src/helpers/cadPackages/jsCad/jscadWorker.ts index 50f2ad6..f4eb77e 100644 --- a/app/web/src/helpers/cadPackages/jsCad/jscadWorker.ts +++ b/app/web/src/helpers/cadPackages/jsCad/jscadWorker.ts @@ -315,9 +315,14 @@ function parseDef(code, line) { } const makeScriptWorker = ({ callback, convertToSolids }) => { - let onInit, main, scriptStats, entities + let onInit, main, scriptStats, entities, lastParamsDef function runMain(params = {}) { + if (lastParamsDef) + lastParamsDef.forEach((def) => { + if (!(def.name in params) && 'initial' in def) + params[def.name] = def.initial + }) let time = Date.now() let solids const transfer = [] @@ -397,10 +402,11 @@ const makeScriptWorker = ({ callback, convertToSolids }) => { if (idx === -1) { paramsDef.push(p) } else { - paramsDef.splice(idx, 1, p) + paramsDef[idx] = p } }) } + lastParamsDef = paramsDef callback({ action: 'parameterDefinitions', worker: 'main', diff --git a/app/web/src/components/EditorMenu/helpers.ts b/app/web/src/helpers/download_stl.ts similarity index 100% rename from app/web/src/components/EditorMenu/helpers.ts rename to app/web/src/helpers/download_stl.ts diff --git a/app/web/src/layouts/MainLayout/MainLayout.js b/app/web/src/layouts/MainLayout/MainLayout.js index fd17a0f..7061590 100644 --- a/app/web/src/layouts/MainLayout/MainLayout.js +++ b/app/web/src/layouts/MainLayout/MainLayout.js @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react' import { Link, routes, navigate } from '@redwoodjs/router' import { useAuth } from '@redwoodjs/auth' import { Toaster, toast } from '@redwoodjs/web/toast' -import Tooltip from '@material-ui/core/Tooltip' import { Popover } from '@headlessui/react' import { getActiveClasses } from 'get-active-classes' import Footer from 'src/components/Footer' @@ -12,11 +11,11 @@ import NavPlusButton from 'src/components/NavPlusButton' import ReactGA from 'react-ga' import { isBrowser } from '@redwoodjs/prerender/browserUtils' -import Svg from 'src/components/Svg' import { ImageFallback } from 'src/components/ImageUploader' import useUser from 'src/helpers/hooks/useUser' import './MainLayout.css' import RecentProjectsCell from 'src/components/RecentProjectsCell' +import LogoType from 'src/components/LogoType' let previousSubmission = '' @@ -72,39 +71,12 @@ const MainLayout = ({ children, shouldRemoveFooterInIde }) => { }, [hash, client]) return (