massive refactor toDrop cascadeStudio and add CadQuery + OpenSCAD

resolves #400
This commit is contained in:
Kurt Hutten
2021-07-08 21:17:07 +10:00
parent 477a557eb8
commit 8e558d2342
158 changed files with 2335 additions and 2300 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "web/src/cascade"]
path = app/web/src/cascade
url = https://github.com/Irev-Dev/CascadeStudio.git

View File

@@ -1,6 +1,8 @@
{
"cSpell.words": [
"Hutten",
"cadquery",
"openscad",
"sendmail"
]
}

View File

@@ -3,6 +3,12 @@
# [C a d H u b](https://cadhub.xyz)
<!-- [![Netlify Status](https://api.netlify.com/api/v1/badges/77f37543-e54a-4723-8136-157c0221ec27/deploy-status)](https://app.netlify.com/sites/cadhubxyz/deploys) -->
Let's help Code-CAD reach its [full potential!](https://cadhub.xyz) We're making a ~~cad~~hub for the Code-CAD community, think of it as model-repository crossed with a live editor. We have integrations in progress for [OpenSCAD](https://cadhub.xyz/dev-ide/openscad) and [CadQuery](https://cadhub.xyz/dev-ide/cadquery) with [more coming soon](https://github.com/Irev-Dev/curated-code-cad).
If you want to be involved in anyway, checkout the [Road Map](https://github.com/Irev-Dev/cadhub/discussions/212) and get in touch via, [twitter](https://twitter.com/IrevDev), [discord](https://discord.gg/SD7zFRNjGH) or [discussions](https://github.com/Irev-Dev/cadhub/discussions).
<img src="https://raw.githubusercontent.com/Irev-Dev/repo-images/main/images/fullcadhubshot.jpg">
<img src="https://raw.githubusercontent.com/Irev-Dev/cadhub/main/docs/static/img/blog/curated-code-cad/CadHubSS.jpg">
## Getting your dev environment setup
@@ -10,9 +16,9 @@
Because we're integrating cascadeStudio, this is done some what crudely for the time being, so you'll need to clone the repo with submodules.
```terminal
git clone --recurse-submodules -j8 git@github.com:Irev-Dev/cadhub.git
git clone git@github.com:Irev-Dev/cadhub.git
# or
git clone --recurse-submodules -j8 https://github.com/Irev-Dev/cadhub.git
git clone https://github.com/Irev-Dev/cadhub.git
```
Install dependencies

View File

@@ -1 +1 @@
/web/src/cascade/*

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "RW_DataMigration" (
"version" TEXT NOT NULL,
"name" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL,
"finishedAt" TIMESTAMP(3) NOT NULL,
PRIMARY KEY ("version")
);

View File

@@ -0,0 +1,31 @@
/*
Warnings:
- You are about to drop the `Comment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Part` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `PartReaction` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_partId_fkey";
-- DropForeignKey
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_userId_fkey";
-- DropForeignKey
ALTER TABLE "Part" DROP CONSTRAINT "Part_userId_fkey";
-- DropForeignKey
ALTER TABLE "PartReaction" DROP CONSTRAINT "PartReaction_partId_fkey";
-- DropForeignKey
ALTER TABLE "PartReaction" DROP CONSTRAINT "PartReaction_userId_fkey";
-- DropTable
DROP TABLE "Comment";
-- DropTable
DROP TABLE "Part";
-- DropTable
DROP TABLE "PartReaction";

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"title" VARCHAR(25) NOT NULL,
"description" TEXT,
"code" TEXT,
"mainImage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectReaction" (
"id" TEXT NOT NULL,
"emote" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"text" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Project.title_userId_unique" ON "Project"("title", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectReaction.emote_userId_projectId_unique" ON "ProjectReaction"("emote", "userId", "projectId");
-- AddForeignKey
ALTER TABLE "Project" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectReaction" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectReaction" ADD FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "CadPackage" AS ENUM ('openscad', 'cadquery');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "cadPackage" "CadPackage" NOT NULL DEFAULT E'openscad';

View File

@@ -1,2 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -14,8 +14,7 @@ generator client {
// ADMIN
// }
// enum PartType {
// CASCADESTUDIO
// enum ProjectType {
// JSCAD
// }
@@ -33,15 +32,20 @@ model User {
image String? // url maybe id or file storage service? cloudinary?
bio String? //mark down
Part Part[]
Reaction PartReaction[]
Project Project[]
Reaction ProjectReaction[]
Comment Comment[]
SubjectAccessRequest SubjectAccessRequest[]
}
model Part {
enum CadPackage {
openscad
cadquery
}
model Project {
id String @id @default(uuid())
title String
title String @db.VarChar(25)
description String? // markdown string
code String?
mainImage String? // link to cloudinary
@@ -50,23 +54,24 @@ model Part {
user User @relation(fields: [userId], references: [id])
userId String
deleted Boolean @default(false)
cadPackage CadPackage @default(openscad)
Comment Comment[]
Reaction PartReaction[]
Reaction ProjectReaction[]
@@unique([title, userId])
}
model PartReaction {
model ProjectReaction {
id String @id @default(uuid())
emote String // an emoji
user User @relation(fields: [userId], references: [id])
userId String
part Part @relation(fields: [partId], references: [id])
partId String
project Project @relation(fields: [projectId], references: [id])
projectId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([emote, userId, partId])
@@unique([emote, userId, projectId])
}
model Comment {
@@ -74,8 +79,8 @@ model Comment {
text String // the comment, should I allow mark down?
user User @relation(fields: [userId], references: [id])
userId String
part Part @relation(fields: [partId], references: [id])
partId String
project Project @relation(fields: [projectId], references: [id])
projectId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -91,3 +96,10 @@ model SubjectAccessRequest {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RW_DataMigration {
version String @id
name String
startedAt DateTime
finishedAt DateTime
}

View File

@@ -50,9 +50,9 @@ async function main() {
})
}
const parts = [
const projects = [
{
title: 'demo-part1',
title: 'demo-project1',
description: '# can be markdown',
mainImage: 'CadHub/kjdlgjnu0xmwksia7xox',
user: {
@@ -62,7 +62,7 @@ async function main() {
},
},
{
title: 'demo-part2',
title: 'demo-project2',
description: '## [hey](www.google.com)',
user: {
connect: {
@@ -72,39 +72,43 @@ async function main() {
},
]
existing = await db.part.findMany({where: { title: parts[0].title}})
existing = await db.project.findMany({where: { title: projects[0].title}})
if(!existing.length) {
await db.part.create({
data: parts[0],
await db.project.create({
data: projects[0],
})
}
existing = await db.part.findMany({where: { title: parts[1].title}})
existing = await db.project.findMany({where: { title: projects[1].title}})
if(!existing.length) {
await db.part.create({
data: parts[1],
await db.project.create({
data: projects[1],
})
}
const aPart = await db.part.findUnique({where: {
const aProject = await db.project.findUnique({where: {
title_userId: {
title: parts[0].title,
title: projects[0].title,
userId: users[0].id,
}
}})
await db.comment.create({
data: {
text: "nice part, I like it",
user: {connect: { id: users[0].id}},
part: {connect: { id: aPart.id}},
text: "nice project, I like it",
userId: users[0].id,
projectId: aProject.id,
// user: {connect: { id: users[0].id}},
// project: {connect: { id: aProject.id}},
}
})
await db.partReaction.create({
await db.projectReaction.create({
data: {
emote: "❤️",
user: {connect: { id: users[0].id}},
part: {connect: { id: aPart.id}},
userId: users[0].id,
projectId: aProject.id,
// user: {connect: { id: users[0].id}},
// project: {connect: { id: aProject.id}},
}
})

View File

@@ -6,6 +6,7 @@
"@redwoodjs/api": "^0.34.1",
"@sentry/node": "^6.5.1",
"cloudinary": "^1.23.0",
"human-id": "^2.0.1",
"nodemailer": "^6.6.2"
},
"devDependencies": {

View File

@@ -0,0 +1,39 @@
export const schema = gql`
type ProjectReaction {
id: String!
emote: String!
user: User!
userId: String!
project: Project!
projectId: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
projectReactions: [ProjectReaction!]!
projectReaction(id: String!): ProjectReaction
projectReactionsByProjectId(projectId: String!): [ProjectReaction!]!
}
input ToggleProjectReactionInput {
emote: String!
userId: String!
projectId: String!
}
input UpdateProjectReactionInput {
emote: String
userId: String
projectId: String
}
type Mutation {
toggleProjectReaction(input: ToggleProjectReactionInput!): ProjectReaction!
updateProjectReaction(
id: String!
input: UpdateProjectReactionInput!
): ProjectReaction!
deleteProjectReaction(id: String!): ProjectReaction!
}
`

View File

@@ -4,8 +4,8 @@ export const schema = gql`
text: String!
user: User!
userId: String!
part: Part!
partId: String!
project: Project!
projectId: String!
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -18,13 +18,13 @@ export const schema = gql`
input CreateCommentInput {
text: String!
userId: String!
partId: String!
projectId: String!
}
input UpdateCommentInput {
text: String
userId: String
partId: String
projectId: String
}
type Mutation {

View File

@@ -1,39 +0,0 @@
export const schema = gql`
type PartReaction {
id: String!
emote: String!
user: User!
userId: String!
part: Part!
partId: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
partReactions: [PartReaction!]!
partReaction(id: String!): PartReaction
partReactionsByPartId(partId: String!): [PartReaction!]!
}
input TogglePartReactionInput {
emote: String!
userId: String!
partId: String!
}
input UpdatePartReactionInput {
emote: String
userId: String
partId: String
}
type Mutation {
togglePartReaction(input: TogglePartReactionInput!): PartReaction!
updatePartReaction(
id: String!
input: UpdatePartReactionInput!
): PartReaction!
deletePartReaction(id: String!): PartReaction!
}
`

View File

@@ -1,45 +0,0 @@
export const schema = gql`
type Part {
id: String!
title: String!
description: String
code: String
mainImage: String
createdAt: DateTime!
updatedAt: DateTime!
deleted: Boolean!
user: User!
userId: String!
Comment: [Comment]!
Reaction(userId: String): [PartReaction]!
}
type Query {
parts(userName: String): [Part!]!
part(id: String!): Part
partByUserAndTitle(userName: String!, partTitle: String!): Part
}
input CreatePartInput {
title: String!
description: String
code: String
mainImage: String
userId: String!
}
input UpdatePartInput {
title: String
description: String
code: String
mainImage: String
userId: String
}
type Mutation {
createPart(input: CreatePartInput!): Part!
forkPart(input: CreatePartInput!): Part!
updatePart(id: String!, input: UpdatePartInput!): Part!
deletePart(id: String!): Part!
}
`

View File

@@ -0,0 +1,52 @@
export const schema = gql`
type Project {
id: String!
title: String!
description: String
code: String
mainImage: String
createdAt: DateTime!
updatedAt: DateTime!
user: User!
userId: String!
deleted: Boolean!
cadPackage: CadPackage!
Comment: [Comment]!
Reaction(userId: String): [ProjectReaction]!
}
enum CadPackage {
openscad
cadquery
}
type Query {
projects(userName: String): [Project!]!
project(id: String!): Project
projectByUserAndTitle(userName: String!, projectTitle: String!): Project
}
input CreateProjectInput {
title: String
description: String
code: String
mainImage: String
userId: String!
cadPackage: CadPackage!
}
input UpdateProjectInput {
title: String
description: String
code: String
mainImage: String
userId: String
}
type Mutation {
createProject(input: CreateProjectInput!): Project!
forkProject(input: CreateProjectInput!): Project!
updateProject(id: String!, input: UpdateProjectInput!): Project!
deleteProject(id: String!): Project!
}
`

View File

@@ -8,9 +8,9 @@ export const schema = gql`
updatedAt: DateTime!
image: String
bio: String
Parts: [Part]!
Part(partTitle: String): Part
Reaction: [PartReaction]!
Projects: [Project]!
Project(projectTitle: String): Project
Reaction: [ProjectReaction]!
Comment: [Comment]!
SubjectAccessRequest: [SubjectAccessRequest]!
}

View File

@@ -1,13 +1,17 @@
import { AuthenticationError, ForbiddenError } from '@redwoodjs/api'
import { db } from 'src/lib/db'
export const requireOwnership = async ({ userId, userName, partId } = {}) => {
export const requireOwnership = async ({
userId,
userName,
projectId,
}: { userId?: string; userName?: string; projectId?: string } = {}) => {
// IMPORTANT, don't forget to await this function, as it will only block
// unwanted db actions if it has time to look up resources in the db.
if (!context.currentUser) {
throw new AuthenticationError("You don't have permission to do that.")
}
if (!userId && !userName && !partId) {
if (!userId && !userName && !projectId) {
throw new ForbiddenError("You don't have access to do that.")
}
@@ -33,10 +37,10 @@ export const requireOwnership = async ({ userId, userName, partId } = {}) => {
}
}
if (partId) {
const user = await db.part
if (projectId) {
const user = await db.project
.findUnique({
where: { id: partId },
where: { id: projectId },
})
.user()

View File

@@ -1,9 +0,0 @@
/*
import { comments } from './comments'
*/
describe('comments', () => {
it('returns true', () => {
expect(true).toBe(true)
})
})

View File

@@ -33,6 +33,6 @@ export const deleteComment = ({ id }) => {
export const Comment = {
user: (_obj, { root }) =>
db.comment.findUnique({ where: { id: root.id } }).user(),
part: (_obj, { root }) =>
db.comment.findUnique({ where: { id: root.id } }).part(),
project: (_obj, { root }) =>
db.comment.findUnique({ where: { id: root.id } }).project(),
}

View File

@@ -1,4 +1,6 @@
import { v2 as cloudinary } from 'cloudinary'
import humanId from 'human-id'
cloudinary.config({
cloud_name: 'irevdev',
api_key: process.env.CLOUDINARY_API_KEY,
@@ -36,6 +38,26 @@ export const generateUniqueString = async (
return generateUniqueString(newSeed, isUniqueCallback, count)
}
export const generateUniqueStringWithoutSeed = async (
isUniqueCallback: (seed: string) => Promise<any>,
count = 0
) => {
const seed = humanId({
separator: '-',
capitalize: false,
})
const isUnique = !(await isUniqueCallback(seed))
if (isUnique) {
return seed
}
count += 1
if (count > 100) {
console.log('trouble finding unique')
return `very-unique-${seed}`.slice(0, 10)
}
return generateUniqueStringWithoutSeed(isUniqueCallback, count)
}
export const destroyImage = ({ publicId }) =>
new Promise((resolve, reject) => {
cloudinary.uploader.destroy(publicId, (error, result) => {

View File

@@ -1,9 +0,0 @@
/*
import { partReactions } from './partReactions'
*/
describe('partReactions', () => {
it('returns true', () => {
expect(true).toBe(true)
})
})

View File

@@ -1,117 +0,0 @@
import { db } from 'src/lib/db'
import {
foreignKeyReplacement,
enforceAlphaNumeric,
generateUniqueString,
destroyImage,
} from 'src/services/helpers'
import { requireAuth } from 'src/lib/auth'
import { requireOwnership } from 'src/lib/owner'
export const parts = ({ userName }) => {
if (!userName) {
return db.part.findMany({ where: { deleted: false } })
}
return db.part.findMany({
where: {
deleted: false,
user: {
userName,
},
},
})
}
export const part = ({ id }) => {
return db.part.findUnique({
where: { id },
})
}
export const partByUserAndTitle = async ({ userName, partTitle }) => {
const user = await db.user.findUnique({
where: {
userName,
},
})
return db.part.findUnique({
where: {
title_userId: {
title: partTitle,
userId: user.id,
},
},
})
}
export const createPart = async ({ input }) => {
requireAuth()
return db.part.create({
data: foreignKeyReplacement(input),
})
}
export const forkPart = async ({ input }) => {
// Only difference between create and fork part is that fork part will generate a unique title
// (for the user) if there is a conflict
const isUniqueCallback = async (seed) =>
db.part.findUnique({
where: {
title_userId: {
title: seed,
userId: input.userId,
},
},
})
const title = await generateUniqueString(input.title, isUniqueCallback)
// TODO change the description to `forked from userName/partName ${rest of description}`
return db.part.create({
data: foreignKeyReplacement({ ...input, title }),
})
}
export const updatePart = async ({ id, input }) => {
requireAuth()
await requireOwnership({ partId: id })
if (input.title) {
input.title = enforceAlphaNumeric(input.title)
}
const originalPart = await db.part.findUnique({ where: { id } })
const imageToDestroy =
originalPart.mainImage !== input.mainImage &&
input.mainImage &&
originalPart.mainImage
const update = await db.part.update({
data: foreignKeyReplacement(input),
where: { id },
})
if (imageToDestroy) {
console.log(
`image destroyed, publicId: ${imageToDestroy}, partId: ${id}, replacing image is ${input.mainImage}`
)
// destroy after the db has been updated
destroyImage({ publicId: imageToDestroy })
}
return update
}
export const deletePart = async ({ id }) => {
requireAuth()
await requireOwnership({ partId: id })
return db.part.update({
data: {
deleted: true,
},
where: { id },
})
}
export const Part = {
user: (_obj, { root }) =>
db.part.findUnique({ where: { id: root.id } }).user(),
Comment: (_obj, { root }) =>
db.part.findUnique({ where: { id: root.id } }).Comment(),
Reaction: (_obj, { root }) =>
db.part
.findUnique({ where: { id: root.id } })
.Reaction({ where: { userId: _obj.userId } }),
}

View File

@@ -1,9 +0,0 @@
/*
import { parts } from './parts'
*/
describe('parts', () => {
it('returns true', () => {
expect(true).toBe(true)
})
})

View File

@@ -5,24 +5,24 @@ import { requireOwnership } from 'src/lib/owner'
import { db } from 'src/lib/db'
import { foreignKeyReplacement } from 'src/services/helpers'
export const partReactions = () => {
return db.partReaction.findMany()
export const projectReactions = () => {
return db.projectReaction.findMany()
}
export const partReaction = ({ id }) => {
return db.partReaction.findUnique({
export const projectReaction = ({ id }) => {
return db.projectReaction.findUnique({
where: { id },
})
}
export const partReactionsByPartId = ({ partId }) => {
return db.partReaction.findMany({
where: { partId: partId },
export const projectReactionsByProjectId = ({ projectId }) => {
return db.projectReaction.findMany({
where: { projectId },
})
}
export const togglePartReaction = async ({ input }) => {
// if write fails emote_userId_partId @@unique constraint, then delete it instead
export const toggleProjectReaction = async ({ input }) => {
// if write fails emote_userId_projectId @@unique constraint, then delete it instead
requireAuth()
await requireOwnership({ userId: input?.userId })
const legalReactions = ['❤️', '👍', '😄', '🙌'] // TODO figure out a way of sharing code between FE and BE, so this is consistent with web/src/components/EmojiReaction/EmojiReaction.js
@@ -36,33 +36,33 @@ export const togglePartReaction = async ({ input }) => {
let dbPromise
const inputClone = { ...input } // TODO foreignKeyReplacement mutates input, which I should fix but am lazy right now
try {
dbPromise = await db.partReaction.create({
dbPromise = await db.projectReaction.create({
data: foreignKeyReplacement(input),
})
} catch (e) {
dbPromise = db.partReaction.delete({
where: { emote_userId_partId: inputClone },
dbPromise = db.projectReaction.delete({
where: { emote_userId_projectId: inputClone },
})
}
return dbPromise
}
export const updatePartReaction = ({ id, input }) => {
return db.partReaction.update({
export const updateProjectReaction = ({ id, input }) => {
return db.projectReaction.update({
data: foreignKeyReplacement(input),
where: { id },
})
}
export const deletePartReaction = ({ id }) => {
return db.partReaction.delete({
export const deleteProjectReaction = ({ id }) => {
return db.projectReaction.delete({
where: { id },
})
}
export const PartReaction = {
export const ProjectReaction = {
user: (_obj, { root }) =>
db.partReaction.findUnique({ where: { id: root.id } }).user(),
part: (_obj, { root }) =>
db.partReaction.findUnique({ where: { id: root.id } }).part(),
db.projectReaction.findUnique({ where: { id: root.id } }).user(),
project: (_obj, { root }) =>
db.projectReaction.findUnique({ where: { id: root.id } }).project(),
}

View File

@@ -0,0 +1,141 @@
import type { Prisma } from '@prisma/client'
import type { ResolverArgs } from '@redwoodjs/api'
import { db } from 'src/lib/db'
import {
foreignKeyReplacement,
enforceAlphaNumeric,
generateUniqueString,
generateUniqueStringWithoutSeed,
destroyImage,
} from 'src/services/helpers'
import { requireAuth } from 'src/lib/auth'
import { requireOwnership } from 'src/lib/owner'
export const projects = ({ userName }) => {
if (!userName) {
return db.project.findMany({ where: { deleted: false } })
}
return db.project.findMany({
where: {
deleted: false,
user: {
userName,
},
},
})
}
export const project = ({ id }: Prisma.ProjectWhereUniqueInput) => {
return db.project.findUnique({
where: { id },
})
}
export const projectByUserAndTitle = async ({ userName, projectTitle }) => {
const user = await db.user.findUnique({
where: {
userName,
},
})
return db.project.findUnique({
where: {
title_userId: {
title: projectTitle,
userId: user.id,
},
},
})
}
const isUniqueProjectTitle = (userId: string) => async (seed: string) =>
db.project.findUnique({
where: {
title_userId: {
title: seed,
userId,
},
},
})
interface CreateProjectArgs {
input: Prisma.ProjectCreateArgs['data']
}
export const createProject = async ({ input }: CreateProjectArgs) => {
requireAuth()
console.log(input.userId)
const isUniqueCallback = isUniqueProjectTitle(input.userId)
let title = input.title
if (!title) {
title = await generateUniqueStringWithoutSeed(isUniqueCallback)
}
return db.project.create({
data: foreignKeyReplacement({
...input,
title,
}),
})
}
export const forkProject = async ({ input }) => {
// Only difference between create and fork project is that fork project will generate a unique title
// (for the user) if there is a conflict
const isUniqueCallback = isUniqueProjectTitle(input.userId)
const title = await generateUniqueString(input.title, isUniqueCallback)
// TODO change the description to `forked from userName/projectName ${rest of description}`
return db.project.create({
data: foreignKeyReplacement({ ...input, title }),
})
}
interface UpdateProjectArgs extends Prisma.ProjectWhereUniqueInput {
input: Prisma.ProjectUpdateInput
}
export const updateProject = async ({ id, input }: UpdateProjectArgs) => {
requireAuth()
await requireOwnership({ projectId: id })
if (input.title) {
input.title = enforceAlphaNumeric(input.title)
}
const originalProject = await db.project.findUnique({ where: { id } })
const imageToDestroy =
originalProject.mainImage !== input.mainImage &&
input.mainImage &&
originalProject.mainImage
const update = await db.project.update({
data: foreignKeyReplacement(input),
where: { id },
})
if (imageToDestroy) {
console.log(
`image destroyed, publicId: ${imageToDestroy}, projectId: ${id}, replacing image is ${input.mainImage}`
)
// destroy after the db has been updated
destroyImage({ publicId: imageToDestroy })
}
return update
}
export const deleteProject = async ({ id }: Prisma.ProjectWhereUniqueInput) => {
requireAuth()
await requireOwnership({ projectId: id })
return db.project.update({
data: {
deleted: true,
},
where: { id },
})
}
export const Project = {
user: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project.findUnique({ where: { id: root.id } }).user(),
Comment: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project
.findUnique({ where: { id: root.id } })
.Comment({ orderBy: { createdAt: 'desc' } }),
Reaction: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project
.findUnique({ where: { id: root.id } })
.Reaction({ where: { userId: _obj.userId } }),
}

View File

@@ -1,9 +0,0 @@
/*
import { subjectAccessRequests } from './subjectAccessRequests'
*/
describe('subjectAccessRequests', () => {
it('returns true', () => {
expect(true).toBe(true)
})
})

View File

@@ -1,9 +0,0 @@
/*
import { users } from './users'
*/
describe('users', () => {
it('returns true', () => {
expect(true).toBe(true)
})
})

View File

@@ -51,9 +51,9 @@ export const updateUserByUserName = async ({ userName, input }) => {
`You've tried to used a protected word as you userName, try something other than `
)
}
const originalPart = await db.user.findUnique({ where: { userName } })
const originalProject = await db.user.findUnique({ where: { userName } })
const imageToDestroy =
originalPart.image !== input.image && originalPart.image
originalProject.image !== input.image && originalProject.image
const update = await db.user.update({
data: input,
where: { userName },
@@ -73,14 +73,14 @@ export const deleteUser = ({ id }) => {
}
export const User = {
Parts: (_obj, { root }) =>
db.user.findUnique({ where: { id: root.id } }).Part(),
Part: (_obj, { root }) =>
_obj.partTitle &&
db.part.findUnique({
Projects: (_obj, { root }) =>
db.user.findUnique({ where: { id: root.id } }).Project(),
Project: (_obj, { root }) =>
_obj.projectTitle &&
db.project.findUnique({
where: {
title_userId: {
title: _obj.partTitle,
title: _obj.projectTitle,
userId: root.id,
},
},

View File

@@ -27,5 +27,5 @@
open = true
[experimental]
esbuild = false
esbuild = true

View File

@@ -1,92 +1,9 @@
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = (config, { env }) => {
config.plugins.forEach((plugin) => {
if (plugin.constructor.name === 'HtmlWebpackPlugin') {
plugin.options.favicon = './src/favicon.svg'
} else if (plugin.constructor.name === 'CopyPlugin') {
plugin.patterns.push({
from: './src/cascade/js/StandardLibraryIntellisense.ts',
to: 'js/StandardLibraryIntellisense.ts',
})
plugin.patterns.push({
from: './src/cascade/static_node_modules/opencascade.js/dist/oc.d.ts',
to: 'opencascade.d.ts',
})
plugin.patterns.push({
from: '../node_modules/three/src/Three.d.ts',
to: 'Three.d.ts',
})
plugin.patterns.push({
from: './src/cascade/fonts',
to: 'fonts',
})
plugin.patterns.push({
from: './src/cascade/textures',
to: 'textures',
})
}
})
config.plugins.push(
new MonacoWebpackPlugin({
languages: ['typescript'],
features: [
'accessibilityHelp',
'anchorSelect',
'bracketMatching',
'caretOperations',
'clipboard',
'codeAction',
'codelens',
'comment',
'contextmenu',
'coreCommands',
'cursorUndo',
'documentSymbols',
'find',
'folding',
'fontZoom',
'format',
'gotoError',
'gotoLine',
'gotoSymbol',
'hover',
'inPlaceReplace',
'indentation',
'inlineHints',
'inspectTokens',
'linesOperations',
'linkedEditing',
'links',
'multicursor',
'parameterHints',
'quickCommand',
'quickHelp',
'quickOutline',
'referenceSearch',
'rename',
'smartSelect',
'snippets',
'suggest',
'toggleHighContrast',
'toggleTabFocusMode',
'transpose',
'unusualLineTerminators',
'viewportSemanticTokens',
'wordHighlighter',
'wordOperations',
'wordPartOperations',
],
})
)
config.module.rules[0].oneOf.push({
test: /opencascade\.wasm\.wasm$/,
type: 'javascript/auto',
loader: 'file-loader',
})
config.node = {
fs: 'empty',
}
return config
}

View File

@@ -16,23 +16,19 @@
"@headlessui/react": "^1.0.0",
"@material-ui/core": "^4.11.0",
"@monaco-editor/react": "^4.0.11",
"@react-three/fiber": "^7.0.5",
"@redwoodjs/auth": "^0.34.1",
"@redwoodjs/forms": "^0.34.1",
"@redwoodjs/router": "^0.34.1",
"@redwoodjs/web": "^0.34.1",
"@sentry/browser": "^6.5.1",
"@tailwindcss/aspect-ratio": "^0.2.1",
"browser-fs-access": "^0.17.2",
"cloudinary-react": "^1.6.7",
"controlkit": "^0.1.9",
"get-active-classes": "^0.0.11",
"golden-layout": "^1.5.9",
"gotrue-js": "^0.9.27",
"jquery": "^3.5.1",
"lodash": "^4.17.21",
"monaco-editor": "^0.20.0",
"monaco-editor-webpack-plugin": "^1.9.1",
"netlify-identity-widget": "^1.9.1",
"opencascade.js": "^0.1.15",
"pako": "^2.0.3",
"prop-types": "^15.7.2",
"react": "^17.0.2",
@@ -43,19 +39,16 @@
"react-image-crop": "^8.6.6",
"react-mosaic-component": "^4.1.1",
"react-tabs": "^3.2.2",
"react-three-fiber": "^5.3.19",
"rich-markdown-editor": "^11.0.2",
"styled-components": "^5.2.0",
"three": "^0.118.3"
"three": "^0.130.1"
},
"devDependencies": {
"@types/lodash": "^4.14.170",
"autoprefixer": "^10.2.5",
"html-webpack-plugin": "^4.5.0",
"opentype.js": "^1.3.3",
"postcss": "^8.2.13",
"postcss-loader": "4.0.2",
"tailwindcss": "^2.1.2",
"worker-loader": "^3.0.7"
"tailwindcss": "^2.1.2"
}
}

View File

@@ -44,26 +44,26 @@ const Routes = () => {
<Route notfound page={NotFoundPage} />
{/* Ownership enforced routes */}
<Route path="/u/{userName}/new" page={NewPartPage} name="newPart" />
<Route path="/u/{userName}/new" page={NewProjectPage} name="newProject" />
<Private unauthenticated="home" role="user">
<Route path="/u/{userName}/edit" page={EditUserPage} name="editUser" />
<Route path="/u/{userName}/{partTitle}/edit" page={EditPartPage} name="editPart" />
<Route path="/u/{userName}/{projectTitle}/edit" page={EditProjectPage} name="editProject" />
</Private>
{/* End ownership enforced routes */}
<Route path="/draft" page={DraftPartPage} name="draftPart" />
<Route path="/draft/{cadPackage}" page={DraftProjectPage} name="draftProject" />
<Route path="/u/{userName}" page={UserPage} name="user" />
<Route path="/u/{userName}/{partTitle}" page={PartPage} name="part" />
<Route path="/u/{userName}/{partTitle}/ide" page={IdePartPage} name="ide" />
<Route path="/u/{userName}/{projectTitle}" page={ProjectPage} name="project" />
<Route path="/u/{userName}/{projectTitle}/ide" page={IdeProjectPage} name="ide" />
<Private unauthenticated="home" role="admin">
<Route path="/admin/users" page={UsersPage} name="users" />
<Route path="/admin/parts" page={AdminPartsPage} name="parts" />
<Route path="/admin/projects" page={AdminProjectsPage} name="projects" />
<Route path="/admin/subject-access-requests/{id}/edit" page={EditSubjectAccessRequestPage} name="editSubjectAccessRequest" />
<Route path="/admin/subject-access-requests/{id}" page={SubjectAccessRequestPage} name="subjectAccessRequest" />
<Route path="/admin/subject-access-requests" page={SubjectAccessRequestsPage} name="subjectAccessRequests" />
{/* Retired for now but might want to bring it back, delete if older that I danno late 2021 */}
{/* Retired for now but might want to bring it back, delete if older that I dunno late 2021 */}
{/* <Route path="/admin/email" page={AdminEmailPage} name="adminEmail" /> */}
</Private>
</Router>

View File

@@ -2,11 +2,11 @@ import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { Link, routes } from '@redwoodjs/router'
import { QUERY } from 'src/components/AdminPartsCell'
import { QUERY } from 'src/components/AdminProjectsCell/AdminProjectsCell'
const DELETE_PART_MUTATION = gql`
mutation DeletePartMutationAdmin($id: String!) {
deletePart(id: $id) {
const DELETE_PROJECT_MUTATION_ADMIN = gql`
mutation DeleteProjectMutationAdmin($id: String!) {
deleteProject(id: $id) {
id
}
}
@@ -34,10 +34,10 @@ const checkboxInputTag = (checked) => {
return <input type="checkbox" checked={checked} disabled />
}
const AdminParts = ({ parts }) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
const AdminProjects = ({ projects }) => {
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION_ADMIN, {
onCompleted: () => {
toast.success('Part deleted.')
toast.success('Project deleted.')
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
@@ -47,8 +47,8 @@ const AdminParts = ({ parts }) => {
})
const onDeleteClick = (id) => {
if (confirm('Are you sure you want to delete part ' + id + '?')) {
deletePart({ variables: { id } })
if (confirm('Are you sure you want to delete project ' + id + '?')) {
deleteProject({ variables: { id } })
}
}
@@ -70,44 +70,44 @@ const AdminParts = ({ parts }) => {
</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>
{projects.map((project) => (
<tr key={project.id}>
<td>{truncate(project.id)}</td>
<td>{truncate(project.title)}</td>
<td>{truncate(project.description)}</td>
<td>{truncate(project.code)}</td>
<td>{truncate(project.mainImage)}</td>
<td>{timeTag(project.createdAt)}</td>
<td>{timeTag(project.updatedAt)}</td>
<td>{truncate(project.userId)}</td>
<td>{checkboxInputTag(project.deleted)}</td>
<td>
<nav className="rw-table-actions">
<Link
to={routes.part({
userName: part?.user?.userName,
partTitle: part?.title,
to={routes.project({
userName: project?.user?.userName,
projectTitle: project?.title,
})}
title={'Show part ' + part.id + ' detail'}
title={'Show project ' + project.id + ' detail'}
className="rw-button rw-button-small"
>
Show
</Link>
<Link
to={routes.editPart({
userName: part?.user?.userName,
partTitle: part?.title,
to={routes.editProject({
userName: project?.user?.userName,
projectTitle: project?.title,
})}
title={'Edit part ' + part.id}
title={'Edit project ' + project.id}
className="rw-button rw-button-small rw-button-blue"
>
Edit
</Link>
<a
href="#"
title={'Delete part ' + part.id}
title={'Delete project ' + project.id}
className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(part.id)}
onClick={() => onDeleteClick(project.id)}
>
Delete
</a>
@@ -121,4 +121,4 @@ const AdminParts = ({ parts }) => {
)
}
export default AdminParts
export default AdminProjects

View File

@@ -1,10 +1,10 @@
import { Link, routes } from '@redwoodjs/router'
import AdminParts from 'src/components/AdminParts'
import AdminProjects from 'src/components/AdminProjects/AdminProjects'
export const QUERY = gql`
query PARTS_ADMIN {
parts {
query PROJECTS_ADMIN {
projects {
id
title
description
@@ -26,14 +26,14 @@ 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">
{'No projects yet. '}
<Link to={routes.newProject()} className="rw-link">
{'Create one?'}
</Link>
</div>
)
}
export const Success = ({ parts }) => {
return <AdminParts parts={parts} />
export const Success = ({ projects }) => {
return <AdminProjects projects={projects} />
}

View File

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

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

View File

@@ -19,7 +19,7 @@ const Button = ({
'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',
'flex items-center bg-opacity-50 rounded p-2 px-6',
{
'mx-px transform hover:-translate-y-px transition-all duration-150':
shouldAnimateHover && !disabled,
@@ -29,7 +29,7 @@ const Button = ({
onClick={onClick}
>
{children}
<Svg className="w-6 ml-4" name={iconName} />
{iconName && <Svg className="w-6 ml-4" name={iconName} />}
</button>
)
}

View File

@@ -0,0 +1,30 @@
import { ideTypeNameMap } from 'src/helpers/hooks/useIdeContext'
interface CadPackageProps {
cadPackage: string
className?: string
}
const CadPackage = ({ cadPackage, className = '' }: CadPackageProps) => {
const cadName = ideTypeNameMap[cadPackage] || ''
const isOpenScad = cadPackage === 'openscad'
const isCadQuery = cadPackage === 'cadquery'
return (
<div
className={
`flex items-center gap-2 cursor-default text-gray-100 ${
isOpenScad && 'bg-yellow-800'
} ${isCadQuery && 'bg-ch-blue-300'} bg-opacity-30 ` + className
}
>
<div
className={`${isOpenScad && 'bg-yellow-200'} ${
isCadQuery && 'bg-blue-800'
} w-5 h-5 rounded-full`}
/>
<div>{cadName}</div>
</div>
)
}
export default CadPackage

View File

@@ -0,0 +1,199 @@
import { useState } from 'react'
import { toast } from '@redwoodjs/web/toast'
import Popover from '@material-ui/core/Popover'
import Svg from 'src/components/Svg/Svg'
import Button from 'src/components/Button/Button'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { useUpdateProject } from 'src/helpers/hooks/useUpdateProject'
import { uploadToCloudinary } from 'src/helpers/cloudinary'
const anchorOrigin = {
vertical: 'bottom',
horizontal: 'center',
}
const transformOrigin = {
vertical: 'top',
horizontal: 'center',
}
const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
const [captureState, setCaptureState] = useState<any>({})
const [anchorEl, setAnchorEl] = useState(null)
const [whichPopup, setWhichPopup] = useState(null)
const { state, project } = useIdeContext()
const { updateProject } = useUpdateProject({
onCompleted: () => toast.success('Image updated'),
})
const onCapture = async () => {
const threeInstance = state.threeInstance
const isOpenScadImage = state?.objectData?.type === 'png'
let imgBlob
if (!isOpenScadImage) {
const updateCanvasSize = ({
width,
height,
}: {
width: number
height: number
}) => {
threeInstance.camera.aspect = width / height
threeInstance.camera.updateProjectionMatrix()
threeInstance.gl.setSize(width, height)
threeInstance.gl.render(
threeInstance.scene,
threeInstance.camera,
null,
false
)
}
const oldSize = threeInstance.size
updateCanvasSize({ width: 400, height: 300 })
imgBlob = new Promise((resolve, reject) => {
threeInstance.gl.domElement.toBlob(
(blob) => {
resolve(blob)
},
'image/jpeg',
1
)
})
updateCanvasSize(oldSize)
} else {
console.log(project?.title)
imgBlob = state.objectData.data
}
const config = {
image: await imgBlob,
currImage: project?.mainImage,
imageObjectURL: window.URL.createObjectURL(await imgBlob),
callback: uploadAndUpdateImage,
cloudinaryImgURL: '',
updated: false,
}
async function uploadAndUpdateImage() {
// Upload the image to Cloudinary
const cloudinaryImgURL = await uploadToCloudinary(config.image)
// Save the screenshot as the mainImage
updateProject({
variables: {
id: project?.id,
input: {
mainImage: cloudinaryImgURL.public_id,
},
},
})
return cloudinaryImgURL
}
// if there isn't a screenshot saved yet, just go ahead and save right away
if (shouldUpdateImage) {
config.cloudinaryImgURL = (await uploadAndUpdateImage()).public_id
config.updated = true
}
return config
}
const handleDownload = (url) => {
const aTag = document.createElement('a')
document.body.appendChild(aTag)
aTag.href = url
aTag.style.display = 'none'
aTag.download = `${project?.title}-${new Date().toISOString()}.jpg`
aTag.click()
document.body.removeChild(aTag)
}
const handleClick = ({ event, whichPopup }) => {
setAnchorEl(event.currentTarget)
setWhichPopup(whichPopup)
}
const handleClose = () => {
setAnchorEl(null)
setWhichPopup(null)
}
return (
<div>
{canEdit && (
<div>
<TheButton
onClick={async (event) => {
handleClick({ event, whichPopup: 'capture' })
setCaptureState(await onCapture())
}}
/>
<Popover
id={'capture-popover'}
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-200 text-indigo-100 text-opacity-100 bg-opacity-80"
shouldAnimateHover
onClick={() =>
handleDownload(captureState.imageObjectURL)
}
>
Download
</Button>
</div>
</div>
)}
</div>
</Popover>
</div>
)}
</div>
)
}
export default CaptureButton

View File

@@ -0,0 +1,105 @@
import { useState } from 'react'
import { toast } from '@redwoodjs/web/toast'
import { Link, navigate, routes } from '@redwoodjs/router'
import { useUpdateProject } from 'src/helpers/hooks/useUpdateProject'
import Svg from 'src/components/Svg/Svg'
interface EditableProjectTitleProps {
id: string
userName: string
projectTitle: string
canEdit: boolean
shouldRouteToIde: boolean
}
const EditableProjectTitle = ({
id,
userName,
projectTitle,
canEdit,
shouldRouteToIde,
}: EditableProjectTitleProps) => {
const [inEditMode, setInEditMode] = useState(false)
const [newTitle, setNewTitle] = useState(projectTitle)
const inputRef = React.useRef(null)
const { updateProject, loading, error } = useUpdateProject({
onCompleted: ({ updateProject }) => {
const routeVars = {
userName: updateProject.user.userName,
projectTitle: updateProject.title,
}
navigate(
shouldRouteToIde ? routes.ide(routeVars) : routes.project(routeVars)
)
toast.success('Project updated.')
},
})
const onTitleChange = ({ target }) =>
setNewTitle(target.value.replace(/([^a-zA-Z\d_:])/g, '-'))
return (
<>
{!inEditMode && (
<>
<Link
to={routes.project({
userName,
projectTitle,
})}
className="pl-4"
>
/{projectTitle}
</Link>
{canEdit && (
<button
onClick={() => {
setInEditMode(true)
setTimeout(() => inputRef?.current?.focus())
}}
>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
)}
</>
)}
{inEditMode && (
<>
<span className="flex items-center ml-4 border border-ch-gray-300 rounded-sm">
<span className="ml-1">/</span>
<input
className="pl-1 bg-ch-gray-900"
value={newTitle}
onChange={onTitleChange}
ref={inputRef}
onBlur={({ relatedTarget }) => {
if (relatedTarget?.id !== 'rename-button') {
setInEditMode(false)
setNewTitle(projectTitle)
}
}}
/>
</span>
<div className="flex items-center h-full">
<button
className="ml-4 flex p-px px-2 gap-2 bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 rounded-sm border border-ch-purple-400"
id="rename-button"
onClick={() =>
updateProject({ variables: { id, input: { title: newTitle } } })
}
>
<Svg
name="check"
className="w-6 h-6 text-ch-purple-500"
strokeWidth={3}
/>
<span>Rename</span>
</button>
</div>
</>
)}
</>
)
}
export default EditableProjectTitle

View File

@@ -1,13 +1,20 @@
import { Menu } from '@headlessui/react'
import { useIdeContext, ideTypeNameMap } from 'src/helpers/hooks/useIdeContext'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import Svg from 'src/components/Svg/Svg'
import { useRender } from 'src/components/IdeWrapper/useRender'
import { makeStlDownloadHandler, PullTitleFromFirstLine } from './helpers'
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
import CadPackage from 'src/components/CadPackage/CadPackage'
const EditorMenu = () => {
const handleRender = useRender()
const saveCode = useSaveCode()
const { state, thunkDispatch } = useIdeContext()
const onRender = () => {
handleRender()
saveCode({ code: state.code })
}
const handleStlDownload = makeStlDownloadHandler({
type: state.objectData?.type,
ideType: state.ideType,
@@ -16,9 +23,6 @@ const EditorMenu = () => {
fileName: PullTitleFromFirstLine(state.code || ''),
thunkDispatch,
})
const cadName = ideTypeNameMap[state.ideType] || ''
const isOpenScad = state.ideType === 'openScad'
const isCadQuery = state.ideType === 'cadQuery'
return (
<div className="flex justify-between bg-ch-gray-760 text-gray-100">
<div className="flex items-center h-9 w-full cursor-grab">
@@ -27,7 +31,7 @@ const EditorMenu = () => {
</div>
<div className="flex gap-6 px-5">
<FileDropdown
handleRender={handleRender}
handleRender={onRender}
handleStlDownload={handleStlDownload}
/>
<button className="cursor-not-allowed" disabled>
@@ -45,14 +49,7 @@ const EditorMenu = () => {
<Svg name="gear" className="w-6 p-px" />
</button>
</div>
<div className="flex items-center cursor-default">
<div
className={`${isOpenScad && 'bg-yellow-200'} ${
isCadQuery && 'bg-blue-800'
} w-5 h-5 rounded-full`}
/>
<div className="px-2">{cadName}</div>
</div>
<CadPackage cadPackage={state.ideType} className="px-3" />
</div>
)
}

View File

@@ -54,13 +54,13 @@ export const makeStlDownloadHandler =
if (geometry) {
if (
type === 'geometry' &&
(quality === 'high' || ideType === 'openScad')
(quality === 'high' || ideType === 'openscad')
) {
saveFile(geometry)
} else {
thunkDispatch((dispatch, getState) => {
const state = getState()
const specialCadProcess = ideType === 'openScad' && 'stl'
const specialCadProcess = ideType === 'openscad' && 'stl'
dispatch({ type: 'setLoading' })
requestRender({
state,

View File

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

View File

@@ -3,7 +3,7 @@ import { getActiveClasses } from 'get-active-classes'
import Popover from '@material-ui/core/Popover'
import { useAuth } from '@redwoodjs/auth'
import Svg from 'src/components/Svg'
import Svg from 'src/components/Svg/Svg'
const emojiMenu = ['❤️', '👍', '😄', '🙌']
// const emojiMenu = ['🏆', '❤️', '👍', '😊', '😄', '🚀', '👏', '🙌']
@@ -20,7 +20,7 @@ const EmojiReaction = ({
emotes,
userEmotes,
onEmote = () => {},
onShowPartReactions,
onShowProjectReactions,
className,
}) => {
const { currentUser } = useAuth()
@@ -57,42 +57,41 @@ const EmojiReaction = ({
return (
<>
<div
className={getActiveClasses(
'h-10 relative overflow-hidden py-4',
className
)}
className={getActiveClasses('relative overflow-hidden pt-1', className)}
>
<div className="absolute left-0 w-8 inset-y-0 z-10 flex items-center bg-gray-100">
<div className="z-10 flex items-center gap-4 h-10">
<div
className="h-8 w-8 relative"
className="h-full w-10"
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 className="bg-ch-gray-600 w-full h-full flex justify-center items-center shadow-md hover:shadow-lg transform hover:-translate-y-px transition-all duration-150 rounded">
<Svg className="w-8 text-ch-gray-300" name="dots-vertical" />
</button>
</div>
</div>
<div className="whitespace-nowrap 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) }
'tracking-wide border border-transparent hover:border-ch-gray-300 h-full p-1 px-4 transform hover:-translate-y-px transition-all duration-150 flex items-center rounded',
{
'bg-ch-gray-500 text-ch-gray-900':
currentUser && userEmotes?.includes(emote.emoji),
'bg-ch-gray-600': !(
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 className="text-lg pr-2">{emote.emoji}</span>
<span className="text-sm font-fira-code">{emote.count}</span>
</span>
))}
</div>
<div className="whitespace-nowrap flex items-center flex-row-reverse"></div>
</div>
<Popover
id={popoverId}
@@ -121,7 +120,7 @@ const EmojiReaction = ({
</button>
))}
</div>
<button className="text-gray-700" onClick={onShowPartReactions}>
<button className="text-gray-700" onClick={onShowProjectReactions}>
View Reactions
</button>
</div>

View File

@@ -32,14 +32,17 @@ export function makeExternalUrl(resourceUrl: string): string {
}#${fetchText}=${prepareDecodedUrl(resourceUrl)}`
}
export function useIdeInit(cadPackage: string) {
export function useIdeInit(cadPackage: string, code = '') {
const { thunkDispatch } = useIdeContext()
const handleRender = useRender()
useEffect(() => {
thunkDispatch({
type: 'initIde',
payload: { cadPackage },
payload: { cadPackage, code },
})
if (code) {
return
}
// load code from hash if it's there
const triggerRender = () =>
setTimeout(() => {

View File

@@ -0,0 +1,27 @@
import { useState } from 'react'
import { Image as CloudinaryImage } from 'cloudinary-react'
interface Props {
image: string
className?: string
}
const Gravatar = ({ image, className = '' }: Props) => {
return (
<div
className={
'aspect-h-1 rounded-full overflow-hidden border-2 border-gray-200 ' +
className
}
>
<CloudinaryImage
cloudName="irevdev"
publicId={image || 'CadHub/eia1kwru54g2kf02s2xx'}
width={40}
crop="scale"
/>
</div>
)
}
export default Gravatar

View File

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

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

View File

@@ -1,11 +1,11 @@
import { useRef, useEffect, Suspense, lazy } from 'react'
import { Suspense, lazy } from 'react'
import { Mosaic, MosaicWindow } from 'react-mosaic-component'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { requestRender } from 'src/helpers/hooks/useIdeState'
import IdeConsole from 'src/components/IdeConsole'
import 'react-mosaic-component/react-mosaic-component.css'
import EditorMenu from 'src/components/EditorMenu/EditorMenu'
import PanelToolbar from 'src/components/PanelToolbar'
import PanelToolbar from 'src/components/PanelToolbar/PanelToolbar'
import { use3dViewerResize } from 'src/helpers/hooks/use3dViewerResize'
const IdeEditor = lazy(() => import('src/components/IdeEditor/IdeEditor'))
const IdeViewer = lazy(() => import('src/components/IdeViewer/IdeViewer'))
@@ -54,54 +54,15 @@ const TOOLBAR_MAP = {
),
Console: (
<div>
<PanelToolbar panelName="Console" />
<PanelToolbar panelName="Console" showTopGradient />
</div>
),
}
const IdeContainer = () => {
const { viewerDomRef, handleViewerSizeUpdate } = use3dViewerResize()
const { state, thunkDispatch } = useIdeContext()
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 (['png', 'INIT'].includes(state.objectData?.type)) {
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
@@ -117,7 +78,7 @@ const IdeContainer = () => {
className={`${id.toLowerCase()} ${id.toLowerCase()}-tile`}
>
{id === 'Viewer' ? (
<div id="view-wrapper" className="h-full" ref={viewerDOM}>
<div id="view-wrapper" className="h-full" ref={viewerDomRef}>
{ELEMENT_MAP[id]}
</div>
) : (

View File

@@ -3,15 +3,18 @@ import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { makeCodeStoreKey, requestRender } from 'src/helpers/hooks/useIdeState'
import Editor, { useMonaco } from '@monaco-editor/react'
import { theme } from 'src/../tailwind.config'
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
const colors = theme.extend.colors
const IdeEditor = ({ Loading }) => {
const { state, thunkDispatch } = useIdeContext()
const [theme, setTheme] = useState('vs-dark')
const saveCode = useSaveCode()
const ideTypeToLanguageMap = {
cadQuery: 'python',
openScad: 'cpp',
cadquery: 'python',
openscad: 'cpp',
}
const monaco = useMonaco()
useEffect(() => {
@@ -49,6 +52,7 @@ const IdeEditor = ({ Loading }) => {
thunkDispatch((dispatch, getState) => {
const state = getState()
dispatch({ type: 'setLoading' })
saveCode({ code: state.code })
requestRender({
state,
dispatch,

View File

@@ -1,41 +1,135 @@
import { useAuth } from '@redwoodjs/auth'
import { Popover } from '@headlessui/react'
import { Link, navigate, routes } from '@redwoodjs/router'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding'
import ExternalScript from 'src/components/EncodedUrl/ExternalScript'
import Svg from 'src/components/Svg/Svg'
import NavPlusButton from 'src/components/NavPlusButton'
import ProfileSlashLogin from 'src/components/ProfileSlashLogin'
import Gravatar from 'src/components/Gravatar/Gravatar'
import EditableProjectTitle from 'src/components/EditableProjecTitle/EditableProjecTitle'
import CaptureButton from 'src/components/CaptureButton/CaptureButton'
const TopButton = ({
onClick,
children,
className,
name,
Tag = 'button',
}: {
onClick?: () => void
children: React.ReactNode
className?: string
name: string
}) => (
<button
Tag?: string
}) => {
const FinalTag = Tag as unknown as keyof JSX.IntrinsicElements
return (
<FinalTag
onClick={onClick}
className={`flex bg-gray-200 h-10 justify-center items-center px-4 rounded ${className}`}
className={`flex bg-gray-200 h-10 flex-shrink-0 justify-center items-center px-4 rounded ${className} whitespace-nowrap`}
>
{children}
<span className="hidden md:block ml-2">{name}</span>
</button>
)
</FinalTag>
)
}
interface IdeHeaderProps {
handleRender: () => void
projectTitle?: string
projectOwner?: string
projectOwnerId?: string
projectOwnerImage?: string
projectId?: string
}
const IdeHeader = ({
handleRender,
projectOwner,
projectTitle,
projectOwnerImage,
projectId,
projectOwnerId,
}: IdeHeaderProps) => {
const { currentUser } = useAuth()
const { project } = useIdeContext()
const canEdit =
(currentUser &&
currentUser?.sub === (project?.user?.id || projectOwnerId)) ||
currentUser?.roles.includes('admin')
const _projectId = projectId || project?.id
const _projectOwner = project?.user?.userName || projectOwner
const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
return (
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center">
<div className="bg-ch-gray-700 md:pr-48 h-full"></div>
<div className="text-gray-200 flex gap-4 mr-4">
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center text-lg">
{_projectId ? (
<div className="h-full text-gray-300 flex items-center">
<span className="bg-ch-gray-700 h-full flex items-center gap-2 px-4">
<Gravatar
image={project?.user?.image || projectOwnerImage}
className="w-10"
/>
<Link
to={routes.user({
userName: _projectOwner,
})}
>
{_projectOwner}
</Link>
</span>
<EditableProjectTitle
id={_projectId}
userName={_projectOwner}
projectTitle={project?.title || projectTitle}
canEdit={canEdit}
shouldRouteToIde={!projectTitle}
/>
</div>
) : (
<div />
)}
<div className="text-gray-200 flex gap-4 mr-4 items-center">
{canEdit && !projectTitle && (
<CaptureButton
canEdit={canEdit}
shouldUpdateImage={!project?.mainImage}
TheButton={({ onClick }) => (
<TopButton
onClick={onClick}
name="Save Project Image"
className=" bg-ch-gray-300 bg-opacity-70 hover:bg-opacity-90 text-ch-gray-900"
>
<Svg name="camera" className="w-6 h-6" />
</TopButton>
)}
/>
)}
{!projectTitle && (
<TopButton
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
onClick={handleRender}
name="Preview"
name={canEdit ? 'Save' : 'Preview'}
>
<Svg name="photograph" className="w-6 h-6 text-ch-pink-500" />
<Svg
name={canEdit ? 'floppy-disk' : 'photograph'}
className="w-6 h-6 text-ch-pink-500"
/>
</TopButton>
)}
{projectTitle && (
<TopButton
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
onClick={() =>
navigate(routes.ide({ userName: _projectOwner, projectTitle }))
}
name="Editor"
>
<Svg name="terminal" className="w-6 h-6 text-ch-pink-500" />
</TopButton>
)}
<Popover className="relative outline-none w-full h-full">
{({ open }) => {
@@ -43,6 +137,7 @@ const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
<>
<Popover.Button className="h-full w-full outline-none">
<TopButton
Tag="div"
name="Share"
className=" bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
>
@@ -77,6 +172,10 @@ const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
}}
</Popover>
{/* <TopButton>Fork</TopButton> */}
<div className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 flex items-center justify-center">
<NavPlusButton />
</div>
<ProfileSlashLogin />
</div>
</div>
)

View File

@@ -1,90 +0,0 @@
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
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 UpdatePartMutationIde($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 { user } = useUser()
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
onCompleted: () => {
toast.success('Part updated.')
},
})
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,
})
)
toast.success('Part Forked.')
},
})
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

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

View File

@@ -0,0 +1,16 @@
import { Loading, Empty, Success } from './IdeProjectCell'
import { standard } from './IdeProjectCell.mock'
export const loading = () => {
return Loading ? <Loading /> : null
}
export const empty = () => {
return Empty ? <Empty /> : null
}
export const success = () => {
return Success ? <Success {...standard()} /> : null
}
export default { title: 'Cells/IdeProjectCell' }

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartsOfUserCell'
import { standard } from './PartsOfUserCell.mock'
import { Loading, Empty, Success } from './IdeProjectCell'
import { standard } from './IdeProjectCell.mock'
describe('PartsOfUserCell', () => {
describe('IdeProjectCell', () => {
test('Loading renders successfully', () => {
render(<Loading />)
// Use screen.debug() to see output
@@ -14,13 +14,8 @@ describe('PartsOfUserCell', () => {
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} />)
render(<Success ideProject={standard().ideProject} />)
expect(screen.getByText(/42/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,120 @@
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { navigate, routes } from '@redwoodjs/router'
import { QUERY as UsersProjectsQuery } from 'src/components/ProjectsOfUserCell'
import useUser from 'src/helpers/hooks/useUser'
import DevIdePage from 'src/pages/DevIdePage/DevIdePage'
export const QUERY = gql`
query FIND_PROJECT_BY_USENAME_TITLE(
$projectTitle: String!
$userName: String!
) {
project: projectByUserAndTitle(
projectTitle: $projectTitle
userName: $userName
) {
id
title
description
code
mainImage
createdAt
cadPackage
user {
id
userName
image
}
}
}
`
export interface Project {
id: string
title: string
description: string
code: string
mainImage: string
createdAt: string
cadPackage: 'openscad' | 'cadquery'
user: {
id: string
userName: string
image: string
}
}
export const UPDATE_PROJECT_MUTATION_IDE = gql`
mutation UpdateProjectMutationIde($id: String!, $input: UpdateProjectInput!) {
updateProject(id: $id, input: $input) {
id
}
}
`
export const FORK_PROJECT_MUTATION = gql`
mutation ForkProjectMutation($input: CreateProjectInput!) {
forkProject(input: $input) {
id
title
user {
userName
}
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Project not found</div>
interface SaveCodeArgs {
input: any
id: string
isFork: boolean
}
export const Success = ({
project,
refetch,
}: {
project: Project
refetch: any
}) => {
const { user } = useUser()
const [updateProject, { loading, error }] = useMutation(
UPDATE_PROJECT_MUTATION_IDE,
{
onCompleted: () => {
toast.success('Project updated.')
},
}
)
const [forkProject] = useMutation(FORK_PROJECT_MUTATION, {
refetchQueries: [
{
query: UsersProjectsQuery,
variables: { userName: user?.userName },
},
],
onCompleted: ({ forkProject }) => {
navigate(
routes.ide({
userName: forkProject?.user?.userName,
projectTitle: forkProject?.title,
})
)
toast.success('Project Forked.')
},
})
const saveCode = async ({ input, id, isFork }: SaveCodeArgs) => {
if (!isFork) {
await updateProject({ variables: { id, input } })
refetch()
return
}
forkProject({ variables: { input } })
}
return <DevIdePage cadPackage={project?.cadPackage} project={project} />
}

View File

@@ -1,422 +0,0 @@
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 } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
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 [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,
})
)
toast.success(`Part created with title: ${data?.forkPart?.title}.`)
} 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

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

@@ -1,12 +1,6 @@
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { useRef, useState, useEffect } from 'react'
import {
Canvas,
extend,
useFrame,
useThree,
useUpdate,
} from 'react-three-fiber'
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
import { Canvas, extend, useFrame, useThree } from '@react-three/fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { Vector3 } from 'three'
import { requestRender } from 'src/helpers/hooks/useIdeState'
@@ -20,9 +14,12 @@ extend({ OrbitControls })
function Asset({ geometry: incomingGeo }) {
const mesh = useRef()
const ref = useUpdate((geometry) => {
geometry.attributes = incomingGeo.attributes
})
const ref = useRef<any>({})
useLayoutEffect(() => {
if (incomingGeo?.attributes) {
ref.current.attributes = incomingGeo.attributes
}
}, [incomingGeo])
if (!incomingGeo) return null
return (
<mesh ref={mesh} scale={[1, 1, 1]}>
@@ -33,9 +30,13 @@ function Asset({ geometry: incomingGeo }) {
}
let debounceTimeoutId
function Controls({ onCameraChange, onDragStart }) {
const controls = useRef()
const { camera, gl } = useThree()
function Controls({ onCameraChange, onDragStart, onInit }) {
const controls = useRef<any>()
const threeInstance = useThree()
const { camera, gl } = threeInstance
useEffect(() => {
onInit(threeInstance)
}, [])
useEffect(() => {
// init camera position
camera.position.x = 200
@@ -49,7 +50,7 @@ function Controls({ onCameraChange, onDragStart }) {
// in Three.js Y is the vertical axis (Z for openscad)
camera.rotation._order = 'YXZ'
const getRotations = () => {
const { x, y, z } = camera.rotation
const { x, y, z } = camera?.rotation || {}
const rad2Deg = 180 / Math.PI
const scadX = (x + Math.PI / 2) * rad2Deg
const scadZ = y * rad2Deg
@@ -100,8 +101,8 @@ function Controls({ onCameraChange, onDragStart }) {
onDragStart()
clearTimeout(debounceTimeoutId)
}
controls.current.addEventListener('end', dragCallback)
controls.current.addEventListener('start', dragStart)
controls?.current?.addEventListener('end', dragCallback)
controls?.current?.addEventListener('start', dragStart)
const oldCurrent = controls.current
dragCallback()
return () => {
@@ -141,11 +142,16 @@ function Sphere(props) {
</mesh>
)
}
const IdeViewer = ({ Loading }) => {
const { state, thunkDispatch } = useIdeContext()
const [isDragging, setIsDragging] = useState(false)
const [image, setImage] = useState()
const onInit = (threeInstance) => {
thunkDispatch({ type: 'setThreeInstance', payload: threeInstance })
}
useEffect(() => {
setImage(state.objectData?.type === 'png' && state.objectData?.data)
setIsDragging(false)
@@ -164,7 +170,12 @@ const IdeViewer = ({ Loading }) => {
isDragging ? 'opacity-25' : 'opacity-100'
}`}
>
<img alt="code-cad preview" src={image} className="h-full w-full" />
<img
alt="code-cad preview"
id="special"
src={URL.createObjectURL(image)}
className="h-full w-full"
/>
</div>
)}
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
@@ -178,6 +189,7 @@ const IdeViewer = ({ Loading }) => {
<Canvas>
<Controls
onDragStart={() => setIsDragging(true)}
onInit={onInit}
onCameraChange={(camera) => {
thunkDispatch({
type: 'updateCamera',

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import IdeContainer from 'src/components/IdeContainer/IdeContainer'
import { useRender } from './useRender'
import OutBound from 'src/components/OutBound/OutBound'
@@ -6,12 +6,25 @@ import IdeSideBar from 'src/components/IdeSideBar/IdeSideBar'
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
import Svg from 'src/components/Svg/Svg'
import { useIdeInit } from 'src/components/EncodedUrl/helpers'
import type { Project } from 'src/components/IdeProjectCell/IdeProjectCell'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
const IdeToolbarNew = ({ cadPackage }) => {
interface Props {
cadPackage: string
}
const IdeWrapper = ({ cadPackage }: Props) => {
const [shouldShowConstructionMessage, setShouldShowConstructionMessage] =
useState(true)
const { state, project } = useIdeContext()
const handleRender = useRender()
useIdeInit(cadPackage)
const saveCode = useSaveCode()
const onRender = () => {
handleRender()
saveCode({ code: state.code })
}
useIdeInit(cadPackage, project?.code || state?.code)
return (
<div className="h-full flex">
@@ -20,7 +33,7 @@ const IdeToolbarNew = ({ cadPackage }) => {
</div>
<div className="h-full flex flex-grow flex-col">
<nav className="flex">
<IdeHeader handleRender={handleRender} />
<IdeHeader handleRender={onRender} />
</nav>
{shouldShowConstructionMessage && (
<div className="py-1 md:py-2 bg-pink-200 flex">
@@ -48,4 +61,4 @@ const IdeToolbarNew = ({ cadPackage }) => {
)
}
export default IdeToolbarNew
export default IdeWrapper

View File

@@ -0,0 +1,27 @@
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { useState } from 'react'
import type { Prisma } from '@prisma/client'
import { useAuth } from '@redwoodjs/auth'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { UPDATE_PROJECT_MUTATION_IDE } from 'src/components/IdeProjectCell/IdeProjectCell'
export const useSaveCode = () => {
const { currentUser } = useAuth()
const { project } = useIdeContext()
const [updateProject, { error }] = useMutation(UPDATE_PROJECT_MUTATION_IDE)
const [nowError, setNowError] = useState(false)
if (error && !nowError) {
toast.success('problem updating updating project')
}
if (!!error !== nowError) {
setNowError(!!error)
}
if (project?.user?.id !== currentUser?.sub) {
return () => console.log('not your project')
}
return (input: Prisma.ProjectUpdateInput) => {
updateProject({ variables: { id: project.id, input } })
}
}

View File

@@ -56,7 +56,7 @@ const LandingSection = () => {
</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">
<h2 className="text-indigo-700 text-5xl font-roboto my-16 tracking-widest font-light">
What's the potential of Code-CAD?
</h2>
<MarketingPoint
@@ -140,9 +140,9 @@ const LandingSection = () => {
over the next 10 years. As coders proliferate, so will the number of
areas in which they operate, including CAD.
</p>
</MarketingPoint> */}
</MarketingPoint>
</div>
{/* <div className="w-3/4 mx-auto h-px bg-pink-400 mt-32" /> */}
<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
@@ -164,13 +164,13 @@ const LandingSection = () => {
>
CadQuery
</OutBound>{' '}
{/* with more{' '}
with more{' '}
<OutBound
className="text-gray-600 underline"
to="https://github.com/Irev-Dev/cadhub/discussions/212"
>
features planned
</OutBound> */}
</OutBound>
.
</p>
<p className="text-2xl font-medium text-gray-600 px-8 pb-8">
@@ -183,7 +183,7 @@ const LandingSection = () => {
</OutBound>{' '}
or
</p>
<Link to={routes.devIde({ cadPackage: 'openScad' })}>
<Link to={routes.devIde({ cadPackage: 'openscad' })}>
<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
@@ -201,18 +201,6 @@ const LandingSection = () => {
/>
</div>
</div>
<div className="text-center mb-32 max-w-xl mx-auto text-gray-500 pr-6">
caveat; the following projects are based on a project we're depricating
support for, it's a{' '}
<OutBound
className="text-gray-600 underline"
to="https://github.com/Irev-Dev/cadhub/discussions/261"
>
long story
</OutBound>
, though rest-assured saving projects with OpenSCAD and CadQuery will be
available soon
</div>
<LoginModal
open={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}

View File

@@ -6,30 +6,24 @@ 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" />
<Svg name="plus" className="text-gray-200" />
</Popover.Button>
<Popover.Panel className="absolute z-10">
<Popover.Panel className="absolute z-10 right-0">
<ul className="bg-gray-200 mt-4 rounded shadow-md overflow-hidden">
{[
{
name: 'OpenSCAD',
sub: 'beta',
ideType: 'openScad',
ideType: 'openscad',
},
{ name: 'CadQuery', sub: 'beta', ideType: 'cadQuery' },
{ name: 'CadQuery', sub: 'beta', ideType: 'cadquery' },
].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 })
}
>
<Link to={routes.draftProject({ cadPackage: ideType })}>
<div>{name}</div>
<div className="text-xs text-gray-600 font-light">{sub}</div>
</Link>

View File

@@ -2,9 +2,19 @@ import { useContext } from 'react'
import { MosaicWindowContext } from 'react-mosaic-component'
import Svg from 'src/components/Svg/Svg'
const PanelToolbar = ({ panelName }: { panelName: string }) => {
const PanelToolbar = ({
panelName,
showTopGradient,
}: {
panelName: string
showTopGradient?: boolean
}) => {
const { mosaicWindowActions } = useContext(MosaicWindowContext)
return (
<>
{showTopGradient && (
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-ch-gray-800 to-transparent" />
)}
<div className="absolute top-0 right-0 flex items-center h-9">
<button
className="bg-ch-gray-760 text-ch-gray-300 px-3 rounded-bl-lg h-full cursor-not-allowed"
@@ -19,6 +29,7 @@ const PanelToolbar = ({ panelName }: { panelName: string }) => {
</div>
)}
</div>
</>
)
}

View File

@@ -1,306 +0,0 @@
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'
import OutBound from 'src/components/OutBound/OutBound'
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 || currentUser?.roles.includes('admin')
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}
/>
<div>
<h3 className="text-center p-2 bg-pink-200 rounded-md mt-4 shadow-md">
Warning, this part was made with CascadeStudio which is being
deprecated on CadHub.{' '}
<OutBound
className="text-gray-600 underline"
to="https://github.com/Irev-Dev/cadhub/discussions/261"
>
Click here
</OutBound>{' '}
for more information
</h3>
</div>
{!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

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

View File

@@ -1,20 +0,0 @@
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,7 @@
import ProfileSlashLogin from './ProfileSlashLogin'
export const generated = () => {
return <ProfileSlashLogin />
}
export default { title: 'Components/ProfileSlashLogin' }

View File

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

View File

@@ -0,0 +1,114 @@
import { useState } from 'react'
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'
import ReactGA from 'react-ga'
import Popover from '@material-ui/core/Popover'
import useUser from 'src/helpers/hooks/useUser'
import ImageUploader from 'src/components/ImageUploader'
import LoginModal from 'src/components/LoginModal'
const ProfileSlashLogin = () => {
const { logOut, isAuthenticated, currentUser, client } = useAuth()
const { user, loading } = useUser()
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false)
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 recordedLogin = () => {
ReactGA.event({
category: 'login',
action: 'navbar login',
})
setIsLoginModalOpen(true)
}
return (
<div className="flex-shrink-0">
{isAuthenticated ? (
<div
className="h-8 w-8 border-2 rounded-full border-gray-200 relative text-indigo-200"
aria-describedby={popoverId}
>
<button
className="absolute inset-0 w-full h-full"
onClick={togglePopover}
>
{!loading && (
<ImageUploader
className="rounded-full object-cover"
aspectRatio={1}
imageUrl={user?.image}
width={80}
/>
)}
</button>
</div>
) : (
<div>
<a
href="#"
className="text-indigo-200 font-semibold underline mr-2"
onClick={recordedLogin}
>
Sign in/up
</a>
</div>
)}
{isAuthenticated && currentUser && (
<Popover
id={popoverId}
open={isOpen}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<div className="p-4 w-48">
<Link to={routes.user({ userName: user?.userName })}>
<h3 className="text-indigo-800" style={{ fontWeight: '500' }}>
Hello {user?.name}
</h3>
</Link>
<hr />
<br />
<Link to={routes.user({ userName: user?.userName })}>
<div className="text-indigo-800">Your Profile</div>
</Link>
<a href="#" className="text-indigo-800" onClick={logOut}>
Logout
</a>
</div>
</Popover>
)}
<LoginModal
open={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
/>
</div>
)
}
export default ProfileSlashLogin

View File

@@ -0,0 +1,22 @@
import { lazy, Suspense } from 'react'
const IdeViewer = lazy(() => import('src/components/IdeViewer/IdeViewer'))
import { use3dViewerResize } from 'src/helpers/hooks/use3dViewerResize'
const BigLoadingPing = () => (
<div className="inset-0 absolute flex items-center justify-center bg-ch-gray-800">
<div className="h-16 w-16 bg-pink-600 rounded-full animate-ping"></div>
</div>
)
const ProfileViewer = () => {
const { viewerDomRef } = use3dViewerResize()
return (
<div className="h-full" ref={viewerDomRef}>
<Suspense fallback={BigLoadingPing}>
<IdeViewer Loading={BigLoadingPing} />
</Suspense>
</div>
)
}
export default ProfileViewer

View File

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

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './IdePartCell'
import { standard } from './IdePartCell.mock'
import { Loading, Empty, Failure, Success } from './ProjectCell'
import { standard } from './ProjectCell.mock'
export const loading = () => {
return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null
}
export default { title: 'Cells/IdePartCell' }
export default { title: 'Cells/ProjectCell' }

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './IdePartCell'
import { standard } from './IdePartCell.mock'
import { Loading, Empty, Failure, Success } from './ProjectCell'
import { standard } from './ProjectCell.mock'
describe('IdePartCell', () => {
describe('ProjectCell', () => {
test('Loading renders successfully', () => {
render(<Loading />)
// Use screen.debug() to see output
@@ -20,7 +20,7 @@ describe('IdePartCell', () => {
})
test('Success renders successfully', async () => {
render(<Success idePart={standard().idePart} />)
render(<Success project={standard().project} />)
expect(screen.getByText(/42/i)).toBeInTheDocument()
})
})

View File

@@ -3,22 +3,22 @@ import { toast } from '@redwoodjs/web/toast'
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'
import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile'
import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell'
export const QUERY = gql`
query FIND_PART_BY_USERNAME_TITLE(
query FIND_PROJECT_BY_USERNAME_TITLE(
$userName: String!
$partTitle: String
$projectTitle: String
$currentUserId: String
) {
userPart: userName(userName: $userName) {
userProject: userName(userName: $userName) {
id
name
userName
bio
image
Part(partTitle: $partTitle) {
Project(projectTitle: $projectTitle) {
id
title
description
@@ -27,6 +27,7 @@ export const QUERY = gql`
createdAt
updatedAt
userId
cadPackage
Reaction {
emote
}
@@ -36,6 +37,7 @@ export const QUERY = gql`
Comment {
id
text
createdAt
user {
userName
image
@@ -46,9 +48,9 @@ export const QUERY = gql`
}
`
const UPDATE_PART_MUTATION = gql`
mutation UpdatePartMutation($id: String!, $input: UpdatePartInput!) {
updatePart: updatePart(id: $id, input: $input) {
const UPDATE_PROJECT_MUTATION = gql`
mutation UpdateProjectMutation($id: String!, $input: UpdateProjectInput!) {
updateProject: updateProject(id: $id, input: $input) {
id
title
description
@@ -65,9 +67,9 @@ const UPDATE_PART_MUTATION = gql`
}
}
`
const CREATE_PART_MUTATION = gql`
mutation CreatePartMutation($input: CreatePartInput!) {
createPart(input: $input) {
export const CREATE_PROJECT_MUTATION = gql`
mutation CreateProjectMutation($input: CreateProjectInput!) {
createProject(input: $input) {
id
title
user {
@@ -78,8 +80,8 @@ const CREATE_PART_MUTATION = gql`
}
`
const TOGGLE_REACTION_MUTATION = gql`
mutation ToggleReactionMutation($input: TogglePartReactionInput!) {
togglePartReaction(input: $input) {
mutation ToggleReactionMutation($input: ToggleProjectReactionInput!) {
toggleProjectReaction(input: $input) {
id
emote
}
@@ -93,9 +95,9 @@ const CREATE_COMMENT_MUTATION = gql`
}
}
`
const DELETE_PART_MUTATION = gql`
mutation DeletePartMutation($id: String!) {
deletePart(id: $id) {
const DELETE_PROJECT_MUTATION = gql`
mutation DeleteProjectMutation($id: String!) {
deleteProject(id: $id) {
id
title
user {
@@ -112,55 +114,63 @@ 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 }) => {
export const Success = ({
userProject,
variables: { isEditable },
refetch,
}) => {
const { currentUser } = useAuth()
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
onCompleted: ({ updatePart }) => {
const [updateProject, { loading, error }] = useMutation(
UPDATE_PROJECT_MUTATION,
{
onCompleted: ({ updateProject }) => {
navigate(
routes.part({
userName: updatePart.user.userName,
partTitle: updatePart.title,
routes.project({
userName: updateProject.user.userName,
projectTitle: updateProject.title,
})
)
toast.success('Part updated.')
toast.success('Project updated.')
},
})
const [createPart] = useMutation(CREATE_PART_MUTATION, {
onCompleted: ({ createPart }) => {
}
)
const [createProject] = useMutation(CREATE_PROJECT_MUTATION, {
onCompleted: ({ createProject }) => {
navigate(
routes.part({
userName: createPart?.user?.userName,
partTitle: createPart?.title,
routes.project({
userName: createProject?.user?.userName,
projectTitle: createProject?.title,
})
)
toast.success('Part Created.')
toast.success('Project Created.')
},
})
const onSave = async (id, input) => {
if (!id) {
await createPart({ variables: { input } })
await createProject({ variables: { input } })
} else {
await updatePart({ variables: { id, input } })
await updateProject({ variables: { id, input } })
}
refetch()
}
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
onCompleted: ({ deletePart }) => {
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
onCompleted: ({ deleteProject }) => {
navigate(routes.home())
toast.success('Part deleted.')
toast.success('Project deleted.')
},
})
const onDelete = () => {
userPart?.Part?.id && deletePart({ variables: { id: userPart?.Part?.id } })
userProject?.Project?.id &&
deleteProject({ variables: { id: userProject?.Project?.id } })
}
const [toggleReaction] = useMutation(TOGGLE_REACTION_MUTATION, {
onCompleted: () => refetch(),
refetchQueries: [
{
query: PART_REACTION_QUERY,
variables: { partId: userPart?.Part?.id },
query: PROJECT_REACTION_QUERY,
variables: { projectId: userProject?.Project?.id },
},
],
})
@@ -170,7 +180,7 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
input: {
emote,
userId: currentUser.sub,
partId: userPart?.Part?.id,
projectId: userProject?.Project?.id,
},
},
})
@@ -184,14 +194,14 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
input: {
text,
userId: currentUser.sub,
partId: userPart?.Part?.id,
projectId: userProject?.Project?.id,
},
},
})
return (
<PartProfile
userPart={userPart}
<ProjectProfile
userProject={userProject}
onSave={onSave}
onDelete={onDelete}
loading={loading}

View File

@@ -7,9 +7,9 @@ import {
Submit,
} from '@redwoodjs/forms'
const PartForm = (props) => {
const ProjectForm = (props) => {
const onSubmit = (data) => {
props.onSave(data, props?.part?.id)
props.onSave(data, props?.project?.id)
}
return (
@@ -31,7 +31,7 @@ const PartForm = (props) => {
</Label>
<TextField
name="title"
defaultValue={props.part?.title}
defaultValue={props.project?.title}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
@@ -47,7 +47,7 @@ const PartForm = (props) => {
</Label>
<TextField
name="description"
defaultValue={props.part?.description}
defaultValue={props.project?.description}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
@@ -63,7 +63,7 @@ const PartForm = (props) => {
</Label>
<TextField
name="code"
defaultValue={props.part?.code}
defaultValue={props.project?.code}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
@@ -79,7 +79,7 @@ const PartForm = (props) => {
</Label>
<TextField
name="mainImage"
defaultValue={props.part?.mainImage}
defaultValue={props.project?.mainImage}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
@@ -95,7 +95,7 @@ const PartForm = (props) => {
</Label>
<TextField
name="userId"
defaultValue={props.part?.userId}
defaultValue={props.project?.userId}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
@@ -112,4 +112,4 @@ const PartForm = (props) => {
)
}
export default PartForm
export default ProjectForm

View File

@@ -0,0 +1,293 @@
import { useState, useEffect, useRef, lazy, Suspense } 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 ConfirmDialog from 'src/components/ConfirmDialog/ConfirmDialog'
import EmojiReaction from 'src/components/EmojiReaction/EmojiReaction'
import Button from 'src/components/Button/Button'
import ProjectReactionsCell from '../ProjectReactionsCell'
import { countEmotes } from 'src/helpers/emote'
import { getActiveClasses } from 'get-active-classes'
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
import CadPackage from 'src/components/CadPackage/CadPackage'
import Gravatar from 'src/components/Gravatar/Gravatar'
import { useIdeInit } from 'src/components/EncodedUrl/helpers'
import ProfileViewer from '../ProfileViewer/ProfileViewer'
import Svg from 'src/components/Svg/Svg'
const KeyValue = ({
keyName,
children,
hide = false,
canEdit = false,
onEdit,
isEditable = false,
}: {
keyName: string
children: React.ReactNode
hide?: boolean
canEdit?: boolean
onEdit?: () => void
isEditable?: boolean
}) => {
if (!children || hide) return null
return (
<div>
<div className="text-ch-blue-600 font-fira-code flex text-sm">
{keyName}
{canEdit &&
(isEditable ? (
<button
className="ml-4 flex p-px px-2 gap-2 bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 rounded-sm border border-ch-purple-400"
id="rename-button"
onClick={onEdit}
>
<Svg
name="check"
className="w-6 h-6 text-ch-purple-500"
strokeWidth={3}
/>
<span>Update</span>
</button>
) : (
<button onClick={onEdit}>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
))}
</div>
<div className="text-ch-gray-300">{children}</div>
</div>
)
}
const ProjectProfile = ({
userProject,
onSave,
onDelete,
onReaction,
onComment,
}) => {
const [comment, setComment] = useState('')
const [isEditable, setIsEditable] = useState(false)
const onCommentClear = () => {
onComment(comment)
setComment('')
}
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false)
const [isReactionsModalOpen, setIsReactionsModalOpen] = useState(false)
const { currentUser } = useAuth()
const editorRef = useRef(null)
const canEdit =
currentUser?.sub === userProject.id || currentUser?.roles.includes('admin')
const project = userProject?.Project
const emotes = countEmotes(project?.Reaction)
const userEmotes = project?.userReactions.map(({ emote }) => emote)
useEffect(() => {
isEditable &&
!canEdit &&
navigate(
routes.project({
userName: userProject.userName,
projectTitle: project?.title,
})
)
}, [currentUser])
useIdeInit(project?.cadPackage, project?.code)
const [newDescription, setNewDescription] = useState(project?.description)
const onDescriptionChange = (description) => setNewDescription(description())
const onEditSaveClick = () => {
if (isEditable) {
onSave(project?.id, { description: newDescription })
return
}
navigate(
routes.editProject({
userName: userProject?.userName,
projectTitle: project?.title,
})
)
}
return (
<>
<div className="h-screen flex flex-col text-lg font-fira-sans">
<div className="flex">
<Link
to={routes.home()}
className="w-16 h-16 flex items-center justify-center bg-ch-gray-900"
>
<Svg className="w-12" name="favicon" />
</Link>
<IdeHeader
handleRender={() => {}}
projectTitle={project?.title}
projectOwner={userProject?.userName}
projectOwnerImage={userProject?.image}
projectOwnerId={userProject?.id}
projectId={project?.id}
/>
</div>
<div className="relative flex-grow">
<div className="grid grid-cols-1 md:auto-cols-preview-layout grid-flow-row-dense absolute inset-0">
{/* Viewer */}
<div className="md:col-start-2 w-full min-h-md">
<ProfileViewer />
</div>
{/* Side panel */}
<div className="bg-ch-gray-760 font-fira-sans px-20 pt-12 flex flex-col gap-6 overflow-y-auto">
<h3 className="text-5xl capitalize text-ch-gray-300">
{project?.title.replace(/-/g, ' ')}
</h3>
<div className="flex items-center text-gray-100">
<span className="pr-4">Built with</span>
<CadPackage
cadPackage={project?.cadPackage}
className="px-3 py-2 rounded"
/>
</div>
<KeyValue
keyName="Description"
hide={!project?.description && !canEdit}
canEdit={canEdit}
onEdit={() => {
if (!isEditable) {
setIsEditable(true)
} else {
onEditSaveClick()
setIsEditable(false)
}
}}
isEditable={isEditable}
>
<div
id="description-wrap"
name="description"
className={
'markdown-overrides rounded shadow-md bg-white pl-6 pb-2 mt-2' +
(isEditable ? ' min-h-md' : '')
}
onClick={(e) =>
e?.target?.id === 'description-wrap' &&
editorRef?.current?.focusAtEnd()
}
>
<Editor
ref={editorRef}
defaultValue={project?.description || ''}
readOnly={!isEditable}
onChange={onDescriptionChange}
/>
</div>
</KeyValue>
<div className="flex gap-6">
<KeyValue keyName="Created on">
{new Date(project?.createdAt).toDateString()}
</KeyValue>
<KeyValue keyName="Updated on">
{new Date(project?.updatedAt).toDateString()}
</KeyValue>
</div>
<KeyValue keyName="Reactions">
<EmojiReaction
emotes={emotes}
userEmotes={userEmotes}
onEmote={onReaction}
onShowProjectReactions={() => setIsReactionsModalOpen(true)}
/>
</KeyValue>
<KeyValue keyName="Comments" hide={!currentUser}>
{!isEditable && (
<>
{currentUser && (
<>
<div className="pt-1">
<textarea
className="w-full h-32 rounded shadow-inner outline-none resize-none p-3 bg-ch-gray-600 placeholder-ch-gray-500 font-fira-sans"
placeholder="Have a question about this model, or a helpful tip about how to improve it? Remember, be nice!"
value={comment}
onChange={({ target }) => setComment(target.value)}
/>
</div>
<Button
className={getActiveClasses(
'ml-auto hover:bg-opacity-100 bg-ch-pink-800 bg-opacity-30 mt-4 mb-6 text-ch-gray-300',
{ 'bg-indigo-200': currentUser }
)}
shouldAnimateHover
disabled={!currentUser}
iconName={''}
onClick={onCommentClear}
>
Comment
</Button>
</>
)}
<ul>
{project?.Comment.map(({ text, user, id, createdAt }) => (
<li key={id} className="mb-5">
<div className="flex justify-between">
<Link
className="flex items-center"
to={routes.user({ userName: user?.userName })}
>
<Gravatar
image={user?.image}
className="w-10 h-10 mr-4"
/>
{user?.userName}
</Link>
<div className="font-fira-code text-ch-blue-600 flex items-center">
{new Date(createdAt).toDateString()}
</div>
</div>
<div className="ml-5 border-l-2 pl-5 my-3 border-ch-gray-300 text-ch-gray-300">
{text}
</div>
</li>
))}
</ul>
</>
)}
</KeyValue>
{canEdit && (
<>
<h4 className="mt-10 text-red-600">Danger Zone</h4>
<Button
className={getActiveClasses(
'mr-auto bg-red-500 mb-6 text-ch-gray-300',
{ 'bg-indigo-200': currentUser }
)}
shouldAnimateHover
disabled={!currentUser}
iconName={'trash'}
onClick={() => setIsConfirmDialogOpen(true)}
>
Delete Project
</Button>
</>
)}
</div>
</div>
</div>
</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'}
>
<ProjectReactionsCell projectId={userProject?.Project?.id} />
</Dialog>
</>
)
}
export default ProjectProfile

View File

@@ -3,9 +3,9 @@ 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'
import ImageUploader from 'src/components/ImageUploader/ImageUploader'
const PartReactions = ({ reactions }) => {
const ProjectReactions = ({ reactions }) => {
const emotes = countEmotes(reactions)
const [tab, setTab] = useState(0)
const onTabChange = (_, newValue) => {
@@ -36,17 +36,17 @@ const PartReactions = ({ reactions }) => {
.filter((reaction) =>
tab === 0 ? true : reaction.emote === emotes[tab - 1].emoji
)
.map((reactionPart, i) => (
.map((reactionProject, i) => (
<li
className="flex flex-row justify-between p-3 items-center"
key={`${reactionPart.emote}-${i}}`}
key={`${reactionProject.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}
imageUrl={reactionProject.user?.image}
width={50}
/>
</div>
@@ -54,16 +54,16 @@ const PartReactions = ({ reactions }) => {
<div className="text-gray-800 font-normal text-md mb-1">
<Link
to={routes.user({
userName: reactionPart.user?.userName,
userName: reactionProject.user?.userName,
})}
>
{reactionPart.user?.userName}
{reactionProject.user?.userName}
</Link>
</div>
</div>
</div>
<div>
<span>{reactionPart.emote}</span>
<span>{reactionProject.emote}</span>
</div>
</li>
))}
@@ -72,4 +72,4 @@ const PartReactions = ({ reactions }) => {
)
}
export default PartReactions
export default ProjectReactions

View File

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

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
import { standard } from './PartReactionsCell.mock'
import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
import { standard } from './ProjectReactionsCell.mock'
export const loading = () => {
return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null
}
export default { title: 'Cells/PartReactionsCell' }
export default { title: 'Cells/ProjectReactionsCell' }

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartCell'
import { standard } from './PartCell.mock'
import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
import { standard } from './ProjectReactionsCell.mock'
describe('PartCell', () => {
describe('ProjectReactionsCell', () => {
test('Loading renders successfully', () => {
render(<Loading />)
// Use screen.debug() to see output
@@ -20,7 +20,7 @@ describe('PartCell', () => {
})
test('Success renders successfully', async () => {
render(<Success part={standard().part} />)
render(<Success projectReactions={standard().projectReactions} />)
expect(screen.getByText(/42/i)).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,8 @@
import PartReactions from 'src/components/PartReactions'
import ProjectReactions from 'src/components/ProjectReactions/ProjectReactions'
export const QUERY = gql`
query PartReactionsQuery($partId: String!) {
partReactionsByPartId(partId: $partId) {
query ProjectReactionsQuery($projectId: String!) {
projectReactionsByProjectId(projectId: $projectId) {
id
emote
user {
@@ -19,12 +19,12 @@ 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 😕
No reactions to this project yet 😕
</div>
)
export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ partReactionsByPartId }) => {
return <PartReactions reactions={partReactionsByPartId} />
export const Success = ({ projectReactionsByProjectId }) => {
return <ProjectReactions reactions={projectReactionsByProjectId} />
}

View File

@@ -4,19 +4,25 @@ 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
const ProjectsList = ({
projects,
shouldFilterProjectsWithoutImage = false,
}) => {
// temporary filtering projects 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(
const filteredProjects = useMemo(
() =>
(shouldFilterPartsWithoutImage
? parts.filter(({ mainImage }) => mainImage)
: [...parts]
(shouldFilterProjectsWithoutImage
? projects.filter(({ mainImage }) => mainImage)
: [...projects]
)
// 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]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
),
[projects, shouldFilterProjectsWithoutImage]
)
return (
<section className="max-w-6xl mx-auto mt-8">
@@ -24,13 +30,16 @@ const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
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 }) => (
{filteredProjects.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 })}
to={routes.project({
userName: user?.userName,
projectTitle: 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">
@@ -81,4 +90,4 @@ const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
)
}
export default PartsList
export default ProjectsList

View File

@@ -1,10 +1,10 @@
import { Link, routes } from '@redwoodjs/router'
import Parts from 'src/components/Parts'
import Projects from 'src/components/Projects/Projects'
export const QUERY = gql`
query PARTS {
parts {
query PROJECTS {
projects {
id
title
mainImage
@@ -26,8 +26,8 @@ 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">
{'No projects yet. '}
<Link to={routes.draftProject()} className="rw-link">
{'Create one?'}
</Link>
</div>
@@ -35,13 +35,13 @@ export const Empty = () => {
}
export const Success = ({
parts,
variables: { shouldFilterPartsWithoutImage },
projects,
variables: { shouldFilterProjectsWithoutImage },
}) => {
return (
<Parts
parts={parts}
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
<Projects
projects={projects}
shouldFilterProjectsWithoutImage={shouldFilterProjectsWithoutImage}
/>
)
}

View File

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

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './PartCell'
import { standard } from './PartCell.mock'
import { Loading, Empty, Failure, Success } from './ProjectsOfUserCell'
import { standard } from './ProjectsOfUserCell.mock'
export const loading = () => {
return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null
}
export default { title: 'Cells/PartCell' }
export default { title: 'Cells/ProjectsOfUserCell' }

View File

@@ -1,8 +1,8 @@
import { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
import { standard } from './PartReactionsCell.mock'
import { Loading, Empty, Success } from './ProjectsOfUserCell'
import { standard } from './ProjectsOfUserCell.mock'
describe('PartReactionsCell', () => {
describe('ProjectsOfUserCell', () => {
test('Loading renders successfully', () => {
render(<Loading />)
// Use screen.debug() to see output
@@ -14,13 +14,8 @@ describe('PartReactionsCell', () => {
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} />)
render(<Success projectsOfUser={standard().projectsOfUser} />)
expect(screen.getByText(/42/i)).toBeInTheDocument()
})
})

View File

@@ -1,10 +1,10 @@
import { Link, routes } from '@redwoodjs/router'
import Parts from 'src/components/Parts'
import Projects from 'src/components/Projects/Projects'
export const QUERY = gql`
query PARTS_OF_USER($userName: String!) {
parts(userName: $userName) {
query PROJECTS_OF_USER($userName: String!) {
projects(userName: $userName) {
id
title
mainImage
@@ -24,17 +24,17 @@ export const QUERY = gql`
export const Loading = () => <div>Loading...</div>
export const Empty = () => {
return <div className="rw-text-center">No parts yet.</div>
return <div className="rw-text-center">No projects yet.</div>
}
export const Success = ({
parts,
variables: { shouldFilterPartsWithoutImage },
projects,
variables: { shouldFilterProjectsWithoutImage },
}) => {
return (
<Parts
parts={parts}
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
<Projects
projects={projects}
shouldFilterProjectsWithoutImage={shouldFilterProjectsWithoutImage}
/>
)
}

View File

@@ -1,22 +1,24 @@
type SvgNames =
| 'arrow-down'
| 'arrow'
// | 'arrow'
| 'arrow-left'
| 'big-gear'
| 'camera'
| 'checkmark'
| 'check'
| 'chevron-down'
| 'dots-vertical'
| 'drag-grid'
| 'exclamation-circle'
| 'favicon'
| 'flag'
| 'floppy-disk'
| 'fork'
| 'gear'
| 'lightbulb'
| 'logout'
| 'mac-cmd-key'
| 'pencil'
| 'pencil-solid'
| 'photograph'
| 'plus'
| 'plus-circle'
@@ -84,13 +86,19 @@ const Svg = ({
/>
</svg>
),
checkmark: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
check: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.3438 19.6875C15.7803 19.6875 20.1875 15.2803 20.1875 9.84375C20.1875 4.4072 15.7803 0 10.3438 0C4.9072 0 0.5 4.4072 0.5 9.84375C0.5 15.2803 4.9072 19.6875 10.3438 19.6875ZM15.3321 6.5547C15.6384 6.09517 15.5142 5.4743 15.0547 5.16795C14.5952 4.8616 13.9743 4.98577 13.6679 5.4453L9.34457 11.9304L7.20711 9.79289C6.81658 9.40237 6.18342 9.40237 5.79289 9.79289C5.40237 10.1834 5.40237 10.8166 5.79289 11.2071L8.79289 14.2071C9.00474 14.419 9.3004 14.5247 9.59854 14.4951C9.89667 14.4656 10.1659 14.304 10.3321 14.0547L15.3321 6.5547Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
d="M5 13l4 4L19 7"
/>
</svg>
),
@@ -263,6 +271,16 @@ const Svg = ({
/>
</svg>
),
'floppy-disk': (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M2.0293 19.0684H16.0293C17.1339 19.0684 18.0293 18.173 18.0293 17.0684V5.88782L13.4281 0.910156H12.0956V6.52001H2.88691V0.910156H2.0293C0.924727 0.910156 0.0292969 1.80559 0.0292969 2.91016V17.0684C0.0292969 18.1729 0.924727 19.0684 2.0293 19.0684ZM2.88691 10.8961H14.9981V17.249H2.88691V10.8961ZM8.02924 2.88855C8.02924 2.32878 8.48302 1.875 9.04278 1.875C9.60255 1.875 10.0563 2.32878 10.0563 2.88854V4.53734C10.0563 5.0971 9.60255 5.55088 9.04278 5.55088C8.48302 5.55088 8.02924 5.0971 8.02924 4.53734V2.88855Z"
/>
</svg>
),
fork: (
<svg
viewBox="-3 -3 32 32" // TODO size this properly, or get a better icon
@@ -347,6 +365,16 @@ const Svg = ({
/>
</svg>
),
'pencil-solid': (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
),
photograph: (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -5,9 +5,16 @@ 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'
import ProjectsOfUser from 'src/components/ProjectsOfUserCell'
const UserProfile = ({ user, isEditable, loading, onSave, error, parts }) => {
const UserProfile = ({
user,
isEditable,
loading,
onSave,
error,
projects,
}) => {
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
@@ -95,8 +102,8 @@ const UserProfile = ({ user, isEditable, loading, onSave, error, parts }) => {
</div>
</div>
<div className="mt-10">
<h3 className="text-3xl text-gray-500 font-ropa-sans">Parts:</h3>
<PartsOfUser userName={user?.userName} />
<h3 className="text-3xl text-gray-500 font-ropa-sans">Projects:</h3>
<ProjectsOfUser userName={user?.userName} />
</div>
</section>
</>

View File

@@ -52,9 +52,9 @@ export const render = async ({
}
}
const openScad = {
const openscad = {
render,
// more functions to come
}
export default openScad
export default openscad

View File

@@ -1,7 +1,7 @@
import openScad from './openScadController'
import cadQuery from './cadQueryController'
import openscad from './openScadController'
import cadquery from './cadQueryController'
export const cadPackages = {
openScad,
cadQuery,
openscad,
cadquery,
}

View File

@@ -56,7 +56,10 @@ export const render = async ({ code, settings }: RenderArgs) => {
}
const data = await response.json()
const type = data.type !== 'stl' ? 'png' : 'geometry'
const newData = data.type !== 'stl' ? data.url : stlToGeometry(data.url)
const newData =
data.type !== 'stl'
? fetch(data.url).then((a) => a.blob())
: stlToGeometry(data.url)
return createHealthyResponse({
type,
data: await newData,
@@ -102,12 +105,12 @@ export const stl = async ({ code, settings }: RenderArgs) => {
}
}
const openScad = {
const openscad = {
render,
stl,
}
export default openScad
export default openscad
function cleanError(error) {
return error.replace(/["|']\/tmp\/.+\/main.scad["|']/g, "'main.scad'")

View File

@@ -1,55 +0,0 @@
import { initialize } from 'src/cascade/js/MainPage/CascadeMain'
import { monacoEditor } from 'src/cascade/js/MainPage/CascadeState'
class CascadeController {
_hasInitialised = false
incomingOnCodeChang = () => {}
controllerOnCodeChange = (code) => {
this.incomingOnCodeChang(code)
}
initialise(onCodeChange, code) {
const onInit = () => {
const editor = monacoEditor
editor.setValue(code)
editor.evaluateCode(false)
}
// only inits on first call, after that it just updates the editor and revaluates code, maybe should rename?
this.incomingOnCodeChang = onCodeChange
if (!this._hasInitialised) {
initialize(this.controllerOnCodeChange, code, onInit)
this._hasInitialised = true
return
}
onInit()
}
capture(environment, width = 512, height = 384) {
environment.camera.aspect = width / height
environment.camera.updateProjectionMatrix()
environment.renderer.setSize(width, height)
environment.renderer.render(
environment.scene,
environment.camera,
null,
false
)
let imgBlob = new Promise((resolve, reject) => {
environment.renderer.domElement.toBlob(
(blob) => {
blob.name = `part_capture-${Date.now()}`
resolve(blob)
},
'image/jpeg',
1
)
})
// Return to original dimensions
environment.onWindowResize()
return imgBlob
}
}
export default new CascadeController()

View File

@@ -1,7 +1,5 @@
// TODO: create a tidy util for uploading to Cloudinary and returning the public ID
import axios from 'axios'
import { threejsViewport } from 'src/cascade/js/MainPage/CascadeState'
import CascadeController from 'src/helpers/cascadeController'
const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images'
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload'
@@ -21,12 +19,3 @@ export async function uploadToCloudinary(imgBlob) {
console.error('ERROR', e)
}
}
export const captureAndSaveViewport = async () => {
// Get the canvas image as a Data URL
const imgBlob = await CascadeController.capture(threejsViewport.environment)
// Upload the image to Cloudinary
const { public_id: publicId } = await uploadToCloudinary(imgBlob)
return { publicId, imgBlob }
}

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