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": [ "cSpell.words": [
"Hutten", "Hutten",
"cadquery",
"openscad",
"sendmail" "sendmail"
] ]
} }

View File

@@ -3,6 +3,12 @@
# [C a d H u b](https://cadhub.xyz) # [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) --> <!-- [![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"> <img src="https://raw.githubusercontent.com/Irev-Dev/cadhub/main/docs/static/img/blog/curated-code-cad/CadHubSS.jpg">
## Getting your dev environment setup ## 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. 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 ```terminal
git clone --recurse-submodules -j8 git@github.com:Irev-Dev/cadhub.git git clone git@github.com:Irev-Dev/cadhub.git
# or # or
git clone --recurse-submodules -j8 https://github.com/Irev-Dev/cadhub.git git clone https://github.com/Irev-Dev/cadhub.git
``` ```
Install dependencies 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 # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -14,8 +14,7 @@ generator client {
// ADMIN // ADMIN
// } // }
// enum PartType { // enum ProjectType {
// CASCADESTUDIO
// JSCAD // JSCAD
// } // }
@@ -33,15 +32,20 @@ model User {
image String? // url maybe id or file storage service? cloudinary? image String? // url maybe id or file storage service? cloudinary?
bio String? //mark down bio String? //mark down
Part Part[] Project Project[]
Reaction PartReaction[] Reaction ProjectReaction[]
Comment Comment[] Comment Comment[]
SubjectAccessRequest SubjectAccessRequest[] SubjectAccessRequest SubjectAccessRequest[]
} }
model Part { enum CadPackage {
openscad
cadquery
}
model Project {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String @db.VarChar(25)
description String? // markdown string description String? // markdown string
code String? code String?
mainImage String? // link to cloudinary mainImage String? // link to cloudinary
@@ -50,23 +54,24 @@ model Part {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
deleted Boolean @default(false) deleted Boolean @default(false)
cadPackage CadPackage @default(openscad)
Comment Comment[] Comment Comment[]
Reaction PartReaction[] Reaction ProjectReaction[]
@@unique([title, userId]) @@unique([title, userId])
} }
model PartReaction { model ProjectReaction {
id String @id @default(uuid()) id String @id @default(uuid())
emote String // an emoji emote String // an emoji
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
part Part @relation(fields: [partId], references: [id]) project Project @relation(fields: [projectId], references: [id])
partId String projectId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([emote, userId, partId]) @@unique([emote, userId, projectId])
} }
model Comment { model Comment {
@@ -74,8 +79,8 @@ model Comment {
text String // the comment, should I allow mark down? text String // the comment, should I allow mark down?
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
part Part @relation(fields: [partId], references: [id]) project Project @relation(fields: [projectId], references: [id])
partId String projectId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -91,3 +96,10 @@ model SubjectAccessRequest {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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', description: '# can be markdown',
mainImage: 'CadHub/kjdlgjnu0xmwksia7xox', mainImage: 'CadHub/kjdlgjnu0xmwksia7xox',
user: { user: {
@@ -62,7 +62,7 @@ async function main() {
}, },
}, },
{ {
title: 'demo-part2', title: 'demo-project2',
description: '## [hey](www.google.com)', description: '## [hey](www.google.com)',
user: { user: {
connect: { 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) { if(!existing.length) {
await db.part.create({ await db.project.create({
data: parts[0], 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) { if(!existing.length) {
await db.part.create({ await db.project.create({
data: parts[1], data: projects[1],
}) })
} }
const aPart = await db.part.findUnique({where: { const aProject = await db.project.findUnique({where: {
title_userId: { title_userId: {
title: parts[0].title, title: projects[0].title,
userId: users[0].id, userId: users[0].id,
} }
}}) }})
await db.comment.create({ await db.comment.create({
data: { data: {
text: "nice part, I like it", text: "nice project, I like it",
user: {connect: { id: users[0].id}}, userId: users[0].id,
part: {connect: { id: aPart.id}}, projectId: aProject.id,
// user: {connect: { id: users[0].id}},
// project: {connect: { id: aProject.id}},
} }
}) })
await db.partReaction.create({ await db.projectReaction.create({
data: { data: {
emote: "❤️", emote: "❤️",
user: {connect: { id: users[0].id}}, userId: users[0].id,
part: {connect: { id: aPart.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", "@redwoodjs/api": "^0.34.1",
"@sentry/node": "^6.5.1", "@sentry/node": "^6.5.1",
"cloudinary": "^1.23.0", "cloudinary": "^1.23.0",
"human-id": "^2.0.1",
"nodemailer": "^6.6.2" "nodemailer": "^6.6.2"
}, },
"devDependencies": { "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! text: String!
user: User! user: User!
userId: String! userId: String!
part: Part! project: Project!
partId: String! projectId: String!
createdAt: DateTime! createdAt: DateTime!
updatedAt: DateTime! updatedAt: DateTime!
} }
@@ -18,13 +18,13 @@ export const schema = gql`
input CreateCommentInput { input CreateCommentInput {
text: String! text: String!
userId: String! userId: String!
partId: String! projectId: String!
} }
input UpdateCommentInput { input UpdateCommentInput {
text: String text: String
userId: String userId: String
partId: String projectId: String
} }
type Mutation { 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! updatedAt: DateTime!
image: String image: String
bio: String bio: String
Parts: [Part]! Projects: [Project]!
Part(partTitle: String): Part Project(projectTitle: String): Project
Reaction: [PartReaction]! Reaction: [ProjectReaction]!
Comment: [Comment]! Comment: [Comment]!
SubjectAccessRequest: [SubjectAccessRequest]! SubjectAccessRequest: [SubjectAccessRequest]!
} }

View File

@@ -1,13 +1,17 @@
import { AuthenticationError, ForbiddenError } from '@redwoodjs/api' import { AuthenticationError, ForbiddenError } from '@redwoodjs/api'
import { db } from 'src/lib/db' 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 // 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. // unwanted db actions if it has time to look up resources in the db.
if (!context.currentUser) { if (!context.currentUser) {
throw new AuthenticationError("You don't have permission to do that.") 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.") throw new ForbiddenError("You don't have access to do that.")
} }
@@ -33,10 +37,10 @@ export const requireOwnership = async ({ userId, userName, partId } = {}) => {
} }
} }
if (partId) { if (projectId) {
const user = await db.part const user = await db.project
.findUnique({ .findUnique({
where: { id: partId }, where: { id: projectId },
}) })
.user() .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 = { export const Comment = {
user: (_obj, { root }) => user: (_obj, { root }) =>
db.comment.findUnique({ where: { id: root.id } }).user(), db.comment.findUnique({ where: { id: root.id } }).user(),
part: (_obj, { root }) => project: (_obj, { root }) =>
db.comment.findUnique({ where: { id: root.id } }).part(), db.comment.findUnique({ where: { id: root.id } }).project(),
} }

View File

@@ -1,4 +1,6 @@
import { v2 as cloudinary } from 'cloudinary' import { v2 as cloudinary } from 'cloudinary'
import humanId from 'human-id'
cloudinary.config({ cloudinary.config({
cloud_name: 'irevdev', cloud_name: 'irevdev',
api_key: process.env.CLOUDINARY_API_KEY, api_key: process.env.CLOUDINARY_API_KEY,
@@ -36,6 +38,26 @@ export const generateUniqueString = async (
return generateUniqueString(newSeed, isUniqueCallback, count) 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 }) => export const destroyImage = ({ publicId }) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
cloudinary.uploader.destroy(publicId, (error, result) => { 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 { db } from 'src/lib/db'
import { foreignKeyReplacement } from 'src/services/helpers' import { foreignKeyReplacement } from 'src/services/helpers'
export const partReactions = () => { export const projectReactions = () => {
return db.partReaction.findMany() return db.projectReaction.findMany()
} }
export const partReaction = ({ id }) => { export const projectReaction = ({ id }) => {
return db.partReaction.findUnique({ return db.projectReaction.findUnique({
where: { id }, where: { id },
}) })
} }
export const partReactionsByPartId = ({ partId }) => { export const projectReactionsByProjectId = ({ projectId }) => {
return db.partReaction.findMany({ return db.projectReaction.findMany({
where: { partId: partId }, where: { projectId },
}) })
} }
export const togglePartReaction = async ({ input }) => { export const toggleProjectReaction = async ({ input }) => {
// if write fails emote_userId_partId @@unique constraint, then delete it instead // if write fails emote_userId_projectId @@unique constraint, then delete it instead
requireAuth() requireAuth()
await requireOwnership({ userId: input?.userId }) 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 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 let dbPromise
const inputClone = { ...input } // TODO foreignKeyReplacement mutates input, which I should fix but am lazy right now const inputClone = { ...input } // TODO foreignKeyReplacement mutates input, which I should fix but am lazy right now
try { try {
dbPromise = await db.partReaction.create({ dbPromise = await db.projectReaction.create({
data: foreignKeyReplacement(input), data: foreignKeyReplacement(input),
}) })
} catch (e) { } catch (e) {
dbPromise = db.partReaction.delete({ dbPromise = db.projectReaction.delete({
where: { emote_userId_partId: inputClone }, where: { emote_userId_projectId: inputClone },
}) })
} }
return dbPromise return dbPromise
} }
export const updatePartReaction = ({ id, input }) => { export const updateProjectReaction = ({ id, input }) => {
return db.partReaction.update({ return db.projectReaction.update({
data: foreignKeyReplacement(input), data: foreignKeyReplacement(input),
where: { id }, where: { id },
}) })
} }
export const deletePartReaction = ({ id }) => { export const deleteProjectReaction = ({ id }) => {
return db.partReaction.delete({ return db.projectReaction.delete({
where: { id }, where: { id },
}) })
} }
export const PartReaction = { export const ProjectReaction = {
user: (_obj, { root }) => user: (_obj, { root }) =>
db.partReaction.findUnique({ where: { id: root.id } }).user(), db.projectReaction.findUnique({ where: { id: root.id } }).user(),
part: (_obj, { root }) => project: (_obj, { root }) =>
db.partReaction.findUnique({ where: { id: root.id } }).part(), 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 ` `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 = const imageToDestroy =
originalPart.image !== input.image && originalPart.image originalProject.image !== input.image && originalProject.image
const update = await db.user.update({ const update = await db.user.update({
data: input, data: input,
where: { userName }, where: { userName },
@@ -73,14 +73,14 @@ export const deleteUser = ({ id }) => {
} }
export const User = { export const User = {
Parts: (_obj, { root }) => Projects: (_obj, { root }) =>
db.user.findUnique({ where: { id: root.id } }).Part(), db.user.findUnique({ where: { id: root.id } }).Project(),
Part: (_obj, { root }) => Project: (_obj, { root }) =>
_obj.partTitle && _obj.projectTitle &&
db.part.findUnique({ db.project.findUnique({
where: { where: {
title_userId: { title_userId: {
title: _obj.partTitle, title: _obj.projectTitle,
userId: root.id, userId: root.id,
}, },
}, },

View File

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

View File

@@ -1,92 +1,9 @@
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = (config, { env }) => { module.exports = (config, { env }) => {
config.plugins.forEach((plugin) => { config.plugins.forEach((plugin) => {
if (plugin.constructor.name === 'HtmlWebpackPlugin') { if (plugin.constructor.name === 'HtmlWebpackPlugin') {
plugin.options.favicon = './src/favicon.svg' 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 return config
} }

View File

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

View File

@@ -44,26 +44,26 @@ const Routes = () => {
<Route notfound page={NotFoundPage} /> <Route notfound page={NotFoundPage} />
{/* Ownership enforced routes */} {/* 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"> <Private unauthenticated="home" role="user">
<Route path="/u/{userName}/edit" page={EditUserPage} name="editUser" /> <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> </Private>
{/* End ownership enforced routes */} {/* 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}" page={UserPage} name="user" />
<Route path="/u/{userName}/{partTitle}" page={PartPage} name="part" /> <Route path="/u/{userName}/{projectTitle}" page={ProjectPage} name="project" />
<Route path="/u/{userName}/{partTitle}/ide" page={IdePartPage} name="ide" /> <Route path="/u/{userName}/{projectTitle}/ide" page={IdeProjectPage} name="ide" />
<Private unauthenticated="home" role="admin"> <Private unauthenticated="home" role="admin">
<Route path="/admin/users" page={UsersPage} name="users" /> <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}/edit" page={EditSubjectAccessRequestPage} name="editSubjectAccessRequest" />
<Route path="/admin/subject-access-requests/{id}" page={SubjectAccessRequestPage} name="subjectAccessRequest" /> <Route path="/admin/subject-access-requests/{id}" page={SubjectAccessRequestPage} name="subjectAccessRequest" />
<Route path="/admin/subject-access-requests" page={SubjectAccessRequestsPage} name="subjectAccessRequests" /> <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" /> */} {/* <Route path="/admin/email" page={AdminEmailPage} name="adminEmail" /> */}
</Private> </Private>
</Router> </Router>

View File

@@ -2,11 +2,11 @@ import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast' import { toast } from '@redwoodjs/web/toast'
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { QUERY } from 'src/components/AdminPartsCell' import { QUERY } from 'src/components/AdminProjectsCell/AdminProjectsCell'
const DELETE_PART_MUTATION = gql` const DELETE_PROJECT_MUTATION_ADMIN = gql`
mutation DeletePartMutationAdmin($id: String!) { mutation DeleteProjectMutationAdmin($id: String!) {
deletePart(id: $id) { deleteProject(id: $id) {
id id
} }
} }
@@ -34,10 +34,10 @@ const checkboxInputTag = (checked) => {
return <input type="checkbox" checked={checked} disabled /> return <input type="checkbox" checked={checked} disabled />
} }
const AdminParts = ({ parts }) => { const AdminProjects = ({ projects }) => {
const [deletePart] = useMutation(DELETE_PART_MUTATION, { const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION_ADMIN, {
onCompleted: () => { onCompleted: () => {
toast.success('Part deleted.') toast.success('Project deleted.')
}, },
// This refetches the query on the list page. Read more about other ways to // This refetches the query on the list page. Read more about other ways to
// update the cache over here: // update the cache over here:
@@ -47,8 +47,8 @@ const AdminParts = ({ parts }) => {
}) })
const onDeleteClick = (id) => { const onDeleteClick = (id) => {
if (confirm('Are you sure you want to delete part ' + id + '?')) { if (confirm('Are you sure you want to delete project ' + id + '?')) {
deletePart({ variables: { id } }) deleteProject({ variables: { id } })
} }
} }
@@ -70,44 +70,44 @@ const AdminParts = ({ parts }) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{parts.map((part) => ( {projects.map((project) => (
<tr key={part.id}> <tr key={project.id}>
<td>{truncate(part.id)}</td> <td>{truncate(project.id)}</td>
<td>{truncate(part.title)}</td> <td>{truncate(project.title)}</td>
<td>{truncate(part.description)}</td> <td>{truncate(project.description)}</td>
<td>{truncate(part.code)}</td> <td>{truncate(project.code)}</td>
<td>{truncate(part.mainImage)}</td> <td>{truncate(project.mainImage)}</td>
<td>{timeTag(part.createdAt)}</td> <td>{timeTag(project.createdAt)}</td>
<td>{timeTag(part.updatedAt)}</td> <td>{timeTag(project.updatedAt)}</td>
<td>{truncate(part.userId)}</td> <td>{truncate(project.userId)}</td>
<td>{checkboxInputTag(part.deleted)}</td> <td>{checkboxInputTag(project.deleted)}</td>
<td> <td>
<nav className="rw-table-actions"> <nav className="rw-table-actions">
<Link <Link
to={routes.part({ to={routes.project({
userName: part?.user?.userName, userName: project?.user?.userName,
partTitle: part?.title, projectTitle: project?.title,
})} })}
title={'Show part ' + part.id + ' detail'} title={'Show project ' + project.id + ' detail'}
className="rw-button rw-button-small" className="rw-button rw-button-small"
> >
Show Show
</Link> </Link>
<Link <Link
to={routes.editPart({ to={routes.editProject({
userName: part?.user?.userName, userName: project?.user?.userName,
partTitle: part?.title, projectTitle: project?.title,
})} })}
title={'Edit part ' + part.id} title={'Edit project ' + project.id}
className="rw-button rw-button-small rw-button-blue" className="rw-button rw-button-small rw-button-blue"
> >
Edit Edit
</Link> </Link>
<a <a
href="#" href="#"
title={'Delete part ' + part.id} title={'Delete project ' + project.id}
className="rw-button rw-button-small rw-button-red" className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(part.id)} onClick={() => onDeleteClick(project.id)}
> >
Delete Delete
</a> </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 { Link, routes } from '@redwoodjs/router'
import AdminParts from 'src/components/AdminParts' import AdminProjects from 'src/components/AdminProjects/AdminProjects'
export const QUERY = gql` export const QUERY = gql`
query PARTS_ADMIN { query PROJECTS_ADMIN {
parts { projects {
id id
title title
description description
@@ -26,14 +26,14 @@ export const Loading = () => <div>Loading...</div>
export const Empty = () => { export const Empty = () => {
return ( return (
<div className="rw-text-center"> <div className="rw-text-center">
{'No parts yet. '} {'No projects yet. '}
<Link to={routes.newPart()} className="rw-link"> <Link to={routes.newProject()} className="rw-link">
{'Create one?'} {'Create one?'}
</Link> </Link>
</div> </div>
) )
} }
export const Success = ({ parts }) => { export const Success = ({ projects }) => {
return <AdminParts parts={parts} /> 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-red-600 bg-red-200 border border-red-600': type === 'danger',
'text-indigo-600': !type, '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': 'mx-px transform hover:-translate-y-px transition-all duration-150':
shouldAnimateHover && !disabled, shouldAnimateHover && !disabled,
@@ -29,7 +29,7 @@ const Button = ({
onClick={onClick} onClick={onClick}
> >
{children} {children}
<Svg className="w-6 ml-4" name={iconName} /> {iconName && <Svg className="w-6 ml-4" name={iconName} />}
</button> </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 { 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 Svg from 'src/components/Svg/Svg'
import { useRender } from 'src/components/IdeWrapper/useRender' import { useRender } from 'src/components/IdeWrapper/useRender'
import { makeStlDownloadHandler, PullTitleFromFirstLine } from './helpers' import { makeStlDownloadHandler, PullTitleFromFirstLine } from './helpers'
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
import CadPackage from 'src/components/CadPackage/CadPackage'
const EditorMenu = () => { const EditorMenu = () => {
const handleRender = useRender() const handleRender = useRender()
const saveCode = useSaveCode()
const { state, thunkDispatch } = useIdeContext() const { state, thunkDispatch } = useIdeContext()
const onRender = () => {
handleRender()
saveCode({ code: state.code })
}
const handleStlDownload = makeStlDownloadHandler({ const handleStlDownload = makeStlDownloadHandler({
type: state.objectData?.type, type: state.objectData?.type,
ideType: state.ideType, ideType: state.ideType,
@@ -16,9 +23,6 @@ const EditorMenu = () => {
fileName: PullTitleFromFirstLine(state.code || ''), fileName: PullTitleFromFirstLine(state.code || ''),
thunkDispatch, thunkDispatch,
}) })
const cadName = ideTypeNameMap[state.ideType] || ''
const isOpenScad = state.ideType === 'openScad'
const isCadQuery = state.ideType === 'cadQuery'
return ( return (
<div className="flex justify-between bg-ch-gray-760 text-gray-100"> <div className="flex justify-between bg-ch-gray-760 text-gray-100">
<div className="flex items-center h-9 w-full cursor-grab"> <div className="flex items-center h-9 w-full cursor-grab">
@@ -27,7 +31,7 @@ const EditorMenu = () => {
</div> </div>
<div className="flex gap-6 px-5"> <div className="flex gap-6 px-5">
<FileDropdown <FileDropdown
handleRender={handleRender} handleRender={onRender}
handleStlDownload={handleStlDownload} handleStlDownload={handleStlDownload}
/> />
<button className="cursor-not-allowed" disabled> <button className="cursor-not-allowed" disabled>
@@ -45,14 +49,7 @@ const EditorMenu = () => {
<Svg name="gear" className="w-6 p-px" /> <Svg name="gear" className="w-6 p-px" />
</button> </button>
</div> </div>
<div className="flex items-center cursor-default"> <CadPackage cadPackage={state.ideType} className="px-3" />
<div
className={`${isOpenScad && 'bg-yellow-200'} ${
isCadQuery && 'bg-blue-800'
} w-5 h-5 rounded-full`}
/>
<div className="px-2">{cadName}</div>
</div>
</div> </div>
) )
} }

View File

@@ -54,13 +54,13 @@ export const makeStlDownloadHandler =
if (geometry) { if (geometry) {
if ( if (
type === 'geometry' && type === 'geometry' &&
(quality === 'high' || ideType === 'openScad') (quality === 'high' || ideType === 'openscad')
) { ) {
saveFile(geometry) saveFile(geometry)
} else { } else {
thunkDispatch((dispatch, getState) => { thunkDispatch((dispatch, getState) => {
const state = getState() const state = getState()
const specialCadProcess = ideType === 'openScad' && 'stl' const specialCadProcess = ideType === 'openscad' && 'stl'
dispatch({ type: 'setLoading' }) dispatch({ type: 'setLoading' })
requestRender({ requestRender({
state, 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 Popover from '@material-ui/core/Popover'
import { useAuth } from '@redwoodjs/auth' import { useAuth } from '@redwoodjs/auth'
import Svg from 'src/components/Svg' import Svg from 'src/components/Svg/Svg'
const emojiMenu = ['❤️', '👍', '😄', '🙌'] const emojiMenu = ['❤️', '👍', '😄', '🙌']
// const emojiMenu = ['🏆', '❤️', '👍', '😊', '😄', '🚀', '👏', '🙌'] // const emojiMenu = ['🏆', '❤️', '👍', '😊', '😄', '🚀', '👏', '🙌']
@@ -20,7 +20,7 @@ const EmojiReaction = ({
emotes, emotes,
userEmotes, userEmotes,
onEmote = () => {}, onEmote = () => {},
onShowPartReactions, onShowProjectReactions,
className, className,
}) => { }) => {
const { currentUser } = useAuth() const { currentUser } = useAuth()
@@ -57,42 +57,41 @@ const EmojiReaction = ({
return ( return (
<> <>
<div <div
className={getActiveClasses( className={getActiveClasses('relative overflow-hidden pt-1', className)}
'h-10 relative overflow-hidden py-4',
className
)}
> >
<div className="absolute left-0 w-8 inset-y-0 z-10 flex items-center bg-gray-100"> <div className="z-10 flex items-center gap-4 h-10">
<div <div
className="h-8 w-8 relative" className="h-full w-10"
aria-describedby={popoverId} aria-describedby={popoverId}
onClick={togglePopover} 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"> <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 <Svg className="w-8 text-ch-gray-300" name="dots-vertical" />
className="h-8 w-8 pt-px mt-px text-gray-500"
name="dots-vertical"
/>
</button> </button>
</div> </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) => ( {(emotes.length ? emotes : noEmotes).map((emote, i) => (
<span <span
className={getActiveClasses( 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', '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',
{ border: currentUser && userEmotes?.includes(emote.emoji) } {
'bg-ch-gray-500 text-ch-gray-900':
currentUser && userEmotes?.includes(emote.emoji),
'bg-ch-gray-600': !(
currentUser && userEmotes?.includes(emote.emoji)
),
}
)} )}
style={textShadow} style={textShadow}
key={`${emote.emoji}--${i}`} key={`${emote.emoji}--${i}`}
onClick={() => handleEmojiClick(emote.emoji)} onClick={() => handleEmojiClick(emote.emoji)}
> >
<span className="text-lg pr-1">{emote.emoji}</span> <span className="text-lg pr-2">{emote.emoji}</span>
<span className="text-sm font-ropa-sans">{emote.count}</span> <span className="text-sm font-fira-code">{emote.count}</span>
</span> </span>
))} ))}
</div> </div>
<div className="whitespace-nowrap flex items-center flex-row-reverse"></div>
</div> </div>
<Popover <Popover
id={popoverId} id={popoverId}
@@ -121,7 +120,7 @@ const EmojiReaction = ({
</button> </button>
))} ))}
</div> </div>
<button className="text-gray-700" onClick={onShowPartReactions}> <button className="text-gray-700" onClick={onShowProjectReactions}>
View Reactions View Reactions
</button> </button>
</div> </div>

View File

@@ -32,14 +32,17 @@ export function makeExternalUrl(resourceUrl: string): string {
}#${fetchText}=${prepareDecodedUrl(resourceUrl)}` }#${fetchText}=${prepareDecodedUrl(resourceUrl)}`
} }
export function useIdeInit(cadPackage: string) { export function useIdeInit(cadPackage: string, code = '') {
const { thunkDispatch } = useIdeContext() const { thunkDispatch } = useIdeContext()
const handleRender = useRender() const handleRender = useRender()
useEffect(() => { useEffect(() => {
thunkDispatch({ thunkDispatch({
type: 'initIde', type: 'initIde',
payload: { cadPackage }, payload: { cadPackage, code },
}) })
if (code) {
return
}
// load code from hash if it's there // load code from hash if it's there
const triggerRender = () => const triggerRender = () =>
setTimeout(() => { 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 { Mosaic, MosaicWindow } from 'react-mosaic-component'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { requestRender } from 'src/helpers/hooks/useIdeState'
import IdeConsole from 'src/components/IdeConsole' import IdeConsole from 'src/components/IdeConsole'
import 'react-mosaic-component/react-mosaic-component.css' import 'react-mosaic-component/react-mosaic-component.css'
import EditorMenu from 'src/components/EditorMenu/EditorMenu' 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 IdeEditor = lazy(() => import('src/components/IdeEditor/IdeEditor'))
const IdeViewer = lazy(() => import('src/components/IdeViewer/IdeViewer')) const IdeViewer = lazy(() => import('src/components/IdeViewer/IdeViewer'))
@@ -54,54 +54,15 @@ const TOOLBAR_MAP = {
), ),
Console: ( Console: (
<div> <div>
<PanelToolbar panelName="Console" /> <PanelToolbar panelName="Console" showTopGradient />
</div> </div>
), ),
} }
const IdeContainer = () => { const IdeContainer = () => {
const { viewerDomRef, handleViewerSizeUpdate } = use3dViewerResize()
const { state, thunkDispatch } = useIdeContext() 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 ( return (
<div <div
@@ -117,7 +78,7 @@ const IdeContainer = () => {
className={`${id.toLowerCase()} ${id.toLowerCase()}-tile`} className={`${id.toLowerCase()} ${id.toLowerCase()}-tile`}
> >
{id === 'Viewer' ? ( {id === 'Viewer' ? (
<div id="view-wrapper" className="h-full" ref={viewerDOM}> <div id="view-wrapper" className="h-full" ref={viewerDomRef}>
{ELEMENT_MAP[id]} {ELEMENT_MAP[id]}
</div> </div>
) : ( ) : (

View File

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

View File

@@ -1,41 +1,135 @@
import { useAuth } from '@redwoodjs/auth'
import { Popover } from '@headlessui/react' 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 { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding' import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding'
import ExternalScript from 'src/components/EncodedUrl/ExternalScript' import ExternalScript from 'src/components/EncodedUrl/ExternalScript'
import Svg from 'src/components/Svg/Svg' 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 = ({ const TopButton = ({
onClick, onClick,
children, children,
className, className,
name, name,
Tag = 'button',
}: { }: {
onClick?: () => void onClick?: () => void
children: React.ReactNode children: React.ReactNode
className?: string className?: string
name: string name: string
}) => ( Tag?: string
<button }) => {
const FinalTag = Tag as unknown as keyof JSX.IntrinsicElements
return (
<FinalTag
onClick={onClick} 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} {children}
<span className="hidden md:block ml-2">{name}</span> <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 ( return (
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center"> <div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center text-lg">
<div className="bg-ch-gray-700 md:pr-48 h-full"></div> {_projectId ? (
<div className="text-gray-200 flex gap-4 mr-4"> <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 <TopButton
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300" className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
onClick={handleRender} 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> </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"> <Popover className="relative outline-none w-full h-full">
{({ open }) => { {({ open }) => {
@@ -43,6 +137,7 @@ const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
<> <>
<Popover.Button className="h-full w-full outline-none"> <Popover.Button className="h-full w-full outline-none">
<TopButton <TopButton
Tag="div"
name="Share" name="Share"
className=" bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300" 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> </Popover>
{/* <TopButton>Fork</TopButton> */} {/* <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>
</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: // Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({ export const standard = (/* vars, { ctx, req } */) => ({
idePart: { ideProject: {
id: 42, 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 { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartsOfUserCell' import { Loading, Empty, Success } from './IdeProjectCell'
import { standard } from './PartsOfUserCell.mock' import { standard } from './IdeProjectCell.mock'
describe('PartsOfUserCell', () => { describe('IdeProjectCell', () => {
test('Loading renders successfully', () => { test('Loading renders successfully', () => {
render(<Loading />) render(<Loading />)
// Use screen.debug() to see output // Use screen.debug() to see output
@@ -14,13 +14,8 @@ describe('PartsOfUserCell', () => {
expect(screen.getByText('Empty')).toBeInTheDocument() 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 () => { test('Success renders successfully', async () => {
render(<Success partsOfUser={standard().partsOfUser} />) render(<Success ideProject={standard().ideProject} />)
expect(screen.getByText(/42/i)).toBeInTheDocument() 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 { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { useRef, useState, useEffect } from 'react' import { useRef, useState, useEffect, useLayoutEffect } from 'react'
import { import { Canvas, extend, useFrame, useThree } from '@react-three/fiber'
Canvas,
extend,
useFrame,
useThree,
useUpdate,
} from 'react-three-fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { requestRender } from 'src/helpers/hooks/useIdeState' import { requestRender } from 'src/helpers/hooks/useIdeState'
@@ -20,9 +14,12 @@ extend({ OrbitControls })
function Asset({ geometry: incomingGeo }) { function Asset({ geometry: incomingGeo }) {
const mesh = useRef() const mesh = useRef()
const ref = useUpdate((geometry) => { const ref = useRef<any>({})
geometry.attributes = incomingGeo.attributes useLayoutEffect(() => {
}) if (incomingGeo?.attributes) {
ref.current.attributes = incomingGeo.attributes
}
}, [incomingGeo])
if (!incomingGeo) return null if (!incomingGeo) return null
return ( return (
<mesh ref={mesh} scale={[1, 1, 1]}> <mesh ref={mesh} scale={[1, 1, 1]}>
@@ -33,9 +30,13 @@ function Asset({ geometry: incomingGeo }) {
} }
let debounceTimeoutId let debounceTimeoutId
function Controls({ onCameraChange, onDragStart }) { function Controls({ onCameraChange, onDragStart, onInit }) {
const controls = useRef() const controls = useRef<any>()
const { camera, gl } = useThree() const threeInstance = useThree()
const { camera, gl } = threeInstance
useEffect(() => {
onInit(threeInstance)
}, [])
useEffect(() => { useEffect(() => {
// init camera position // init camera position
camera.position.x = 200 camera.position.x = 200
@@ -49,7 +50,7 @@ function Controls({ onCameraChange, onDragStart }) {
// in Three.js Y is the vertical axis (Z for openscad) // in Three.js Y is the vertical axis (Z for openscad)
camera.rotation._order = 'YXZ' camera.rotation._order = 'YXZ'
const getRotations = () => { const getRotations = () => {
const { x, y, z } = camera.rotation const { x, y, z } = camera?.rotation || {}
const rad2Deg = 180 / Math.PI const rad2Deg = 180 / Math.PI
const scadX = (x + Math.PI / 2) * rad2Deg const scadX = (x + Math.PI / 2) * rad2Deg
const scadZ = y * rad2Deg const scadZ = y * rad2Deg
@@ -100,8 +101,8 @@ function Controls({ onCameraChange, onDragStart }) {
onDragStart() onDragStart()
clearTimeout(debounceTimeoutId) clearTimeout(debounceTimeoutId)
} }
controls.current.addEventListener('end', dragCallback) controls?.current?.addEventListener('end', dragCallback)
controls.current.addEventListener('start', dragStart) controls?.current?.addEventListener('start', dragStart)
const oldCurrent = controls.current const oldCurrent = controls.current
dragCallback() dragCallback()
return () => { return () => {
@@ -141,11 +142,16 @@ function Sphere(props) {
</mesh> </mesh>
) )
} }
const IdeViewer = ({ Loading }) => { const IdeViewer = ({ Loading }) => {
const { state, thunkDispatch } = useIdeContext() const { state, thunkDispatch } = useIdeContext()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [image, setImage] = useState() const [image, setImage] = useState()
const onInit = (threeInstance) => {
thunkDispatch({ type: 'setThreeInstance', payload: threeInstance })
}
useEffect(() => { useEffect(() => {
setImage(state.objectData?.type === 'png' && state.objectData?.data) setImage(state.objectData?.type === 'png' && state.objectData?.data)
setIsDragging(false) setIsDragging(false)
@@ -164,7 +170,12 @@ const IdeViewer = ({ Loading }) => {
isDragging ? 'opacity-25' : 'opacity-100' 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>
)} )}
<div // eslint-disable-line jsx-a11y/no-static-element-interactions <div // eslint-disable-line jsx-a11y/no-static-element-interactions
@@ -178,6 +189,7 @@ const IdeViewer = ({ Loading }) => {
<Canvas> <Canvas>
<Controls <Controls
onDragStart={() => setIsDragging(true)} onDragStart={() => setIsDragging(true)}
onInit={onInit}
onCameraChange={(camera) => { onCameraChange={(camera) => {
thunkDispatch({ thunkDispatch({
type: 'updateCamera', 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 IdeContainer from 'src/components/IdeContainer/IdeContainer'
import { useRender } from './useRender' import { useRender } from './useRender'
import OutBound from 'src/components/OutBound/OutBound' 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 IdeHeader from 'src/components/IdeHeader/IdeHeader'
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
import { useIdeInit } from 'src/components/EncodedUrl/helpers' 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] = const [shouldShowConstructionMessage, setShouldShowConstructionMessage] =
useState(true) useState(true)
const { state, project } = useIdeContext()
const handleRender = useRender() const handleRender = useRender()
useIdeInit(cadPackage) const saveCode = useSaveCode()
const onRender = () => {
handleRender()
saveCode({ code: state.code })
}
useIdeInit(cadPackage, project?.code || state?.code)
return ( return (
<div className="h-full flex"> <div className="h-full flex">
@@ -20,7 +33,7 @@ const IdeToolbarNew = ({ cadPackage }) => {
</div> </div>
<div className="h-full flex flex-grow flex-col"> <div className="h-full flex flex-grow flex-col">
<nav className="flex"> <nav className="flex">
<IdeHeader handleRender={handleRender} /> <IdeHeader handleRender={onRender} />
</nav> </nav>
{shouldShowConstructionMessage && ( {shouldShowConstructionMessage && (
<div className="py-1 md:py-2 bg-pink-200 flex"> <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> </div>
<div className="max-w-6xl mx-auto px-2"> <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? What's the potential of Code-CAD?
</h2> </h2>
<MarketingPoint <MarketingPoint
@@ -140,9 +140,9 @@ const LandingSection = () => {
over the next 10 years. As coders proliferate, so will the number of over the next 10 years. As coders proliferate, so will the number of
areas in which they operate, including CAD. areas in which they operate, including CAD.
</p> </p>
</MarketingPoint> */} </MarketingPoint>
</div> </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"> <div className="mt-24">
<p className="text-center text-pink-400 max-w-xl text-2xl mx-auto font-medium"> <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 CadHub is a space to share cad projects and its our gift to the
@@ -164,13 +164,13 @@ const LandingSection = () => {
> >
CadQuery CadQuery
</OutBound>{' '} </OutBound>{' '}
{/* with more{' '} with more{' '}
<OutBound <OutBound
className="text-gray-600 underline" className="text-gray-600 underline"
to="https://github.com/Irev-Dev/cadhub/discussions/212" to="https://github.com/Irev-Dev/cadhub/discussions/212"
> >
features planned features planned
</OutBound> */} </OutBound>
. .
</p> </p>
<p className="text-2xl font-medium text-gray-600 px-8 pb-8"> <p className="text-2xl font-medium text-gray-600 px-8 pb-8">
@@ -183,7 +183,7 @@ const LandingSection = () => {
</OutBound>{' '} </OutBound>{' '}
or or
</p> </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"> <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"> <span className="font-bold text-2xl text-indigo-200">
Start Hacking Now Start Hacking Now
@@ -201,18 +201,6 @@ const LandingSection = () => {
/> />
</div> </div>
</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 <LoginModal
open={isLoginModalOpen} open={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)} onClose={() => setIsLoginModalOpen(false)}

View File

@@ -6,30 +6,24 @@ const NavPlusButton: React.FC = () => {
return ( return (
<Popover className="relative outline-none w-full h-full"> <Popover className="relative outline-none w-full h-full">
<Popover.Button className="h-full w-full outline-none"> <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.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"> <ul className="bg-gray-200 mt-4 rounded shadow-md overflow-hidden">
{[ {[
{ {
name: 'OpenSCAD', name: 'OpenSCAD',
sub: 'beta', sub: 'beta',
ideType: 'openScad', ideType: 'openscad',
}, },
{ name: 'CadQuery', sub: 'beta', ideType: 'cadQuery' }, { name: 'CadQuery', sub: 'beta', ideType: 'cadquery' },
].map(({ name, sub, ideType }) => ( ].map(({ name, sub, ideType }) => (
<li <li
key={name} key={name}
className="px-4 py-2 hover:bg-gray-400 text-gray-800" className="px-4 py-2 hover:bg-gray-400 text-gray-800"
> >
<Link <Link to={routes.draftProject({ cadPackage: ideType })}>
to={
name === 'CascadeStudio'
? routes.draftPart()
: routes.devIde({ cadPackage: ideType })
}
>
<div>{name}</div> <div>{name}</div>
<div className="text-xs text-gray-600 font-light">{sub}</div> <div className="text-xs text-gray-600 font-light">{sub}</div>
</Link> </Link>

View File

@@ -2,9 +2,19 @@ import { useContext } from 'react'
import { MosaicWindowContext } from 'react-mosaic-component' import { MosaicWindowContext } from 'react-mosaic-component'
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
const PanelToolbar = ({ panelName }: { panelName: string }) => { const PanelToolbar = ({
panelName,
showTopGradient,
}: {
panelName: string
showTopGradient?: boolean
}) => {
const { mosaicWindowActions } = useContext(MosaicWindowContext) const { mosaicWindowActions } = useContext(MosaicWindowContext)
return ( 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"> <div className="absolute top-0 right-0 flex items-center h-9">
<button <button
className="bg-ch-gray-760 text-ch-gray-300 px-3 rounded-bl-lg h-full cursor-not-allowed" 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>
)} )}
</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: // Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({ export const standard = (/* vars, { ctx, req } */) => ({
part: { project: {
id: 42, id: 42,
}, },
}) })

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './IdePartCell' import { Loading, Empty, Failure, Success } from './ProjectCell'
import { standard } from './IdePartCell.mock' import { standard } from './ProjectCell.mock'
export const loading = () => { export const loading = () => {
return Loading ? <Loading /> : null return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null 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 { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './IdePartCell' import { Loading, Empty, Failure, Success } from './ProjectCell'
import { standard } from './IdePartCell.mock' import { standard } from './ProjectCell.mock'
describe('IdePartCell', () => { describe('ProjectCell', () => {
test('Loading renders successfully', () => { test('Loading renders successfully', () => {
render(<Loading />) render(<Loading />)
// Use screen.debug() to see output // Use screen.debug() to see output
@@ -20,7 +20,7 @@ describe('IdePartCell', () => {
}) })
test('Success renders successfully', async () => { test('Success renders successfully', async () => {
render(<Success idePart={standard().idePart} />) render(<Success project={standard().project} />)
expect(screen.getByText(/42/i)).toBeInTheDocument() 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 { navigate, routes } from '@redwoodjs/router'
import { useAuth } from '@redwoodjs/auth' import { useAuth } from '@redwoodjs/auth'
import PartProfile from 'src/components/PartProfile' import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile'
import { QUERY as PART_REACTION_QUERY } from 'src/components/PartReactionsCell/PartReactionsCell' import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell'
export const QUERY = gql` export const QUERY = gql`
query FIND_PART_BY_USERNAME_TITLE( query FIND_PROJECT_BY_USERNAME_TITLE(
$userName: String! $userName: String!
$partTitle: String $projectTitle: String
$currentUserId: String $currentUserId: String
) { ) {
userPart: userName(userName: $userName) { userProject: userName(userName: $userName) {
id id
name name
userName userName
bio bio
image image
Part(partTitle: $partTitle) { Project(projectTitle: $projectTitle) {
id id
title title
description description
@@ -27,6 +27,7 @@ export const QUERY = gql`
createdAt createdAt
updatedAt updatedAt
userId userId
cadPackage
Reaction { Reaction {
emote emote
} }
@@ -36,6 +37,7 @@ export const QUERY = gql`
Comment { Comment {
id id
text text
createdAt
user { user {
userName userName
image image
@@ -46,9 +48,9 @@ export const QUERY = gql`
} }
` `
const UPDATE_PART_MUTATION = gql` const UPDATE_PROJECT_MUTATION = gql`
mutation UpdatePartMutation($id: String!, $input: UpdatePartInput!) { mutation UpdateProjectMutation($id: String!, $input: UpdateProjectInput!) {
updatePart: updatePart(id: $id, input: $input) { updateProject: updateProject(id: $id, input: $input) {
id id
title title
description description
@@ -65,9 +67,9 @@ const UPDATE_PART_MUTATION = gql`
} }
} }
` `
const CREATE_PART_MUTATION = gql` export const CREATE_PROJECT_MUTATION = gql`
mutation CreatePartMutation($input: CreatePartInput!) { mutation CreateProjectMutation($input: CreateProjectInput!) {
createPart(input: $input) { createProject(input: $input) {
id id
title title
user { user {
@@ -78,8 +80,8 @@ const CREATE_PART_MUTATION = gql`
} }
` `
const TOGGLE_REACTION_MUTATION = gql` const TOGGLE_REACTION_MUTATION = gql`
mutation ToggleReactionMutation($input: TogglePartReactionInput!) { mutation ToggleReactionMutation($input: ToggleProjectReactionInput!) {
togglePartReaction(input: $input) { toggleProjectReaction(input: $input) {
id id
emote emote
} }
@@ -93,9 +95,9 @@ const CREATE_COMMENT_MUTATION = gql`
} }
} }
` `
const DELETE_PART_MUTATION = gql` const DELETE_PROJECT_MUTATION = gql`
mutation DeletePartMutation($id: String!) { mutation DeleteProjectMutation($id: String!) {
deletePart(id: $id) { deleteProject(id: $id) {
id id
title title
user { 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 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 { currentUser } = useAuth()
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, { const [updateProject, { loading, error }] = useMutation(
onCompleted: ({ updatePart }) => { UPDATE_PROJECT_MUTATION,
{
onCompleted: ({ updateProject }) => {
navigate( navigate(
routes.part({ routes.project({
userName: updatePart.user.userName, userName: updateProject.user.userName,
partTitle: updatePart.title, 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( navigate(
routes.part({ routes.project({
userName: createPart?.user?.userName, userName: createProject?.user?.userName,
partTitle: createPart?.title, projectTitle: createProject?.title,
}) })
) )
toast.success('Part Created.') toast.success('Project Created.')
}, },
}) })
const onSave = async (id, input) => { const onSave = async (id, input) => {
if (!id) { if (!id) {
await createPart({ variables: { input } }) await createProject({ variables: { input } })
} else { } else {
await updatePart({ variables: { id, input } }) await updateProject({ variables: { id, input } })
} }
refetch() refetch()
} }
const [deletePart] = useMutation(DELETE_PART_MUTATION, { const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
onCompleted: ({ deletePart }) => { onCompleted: ({ deleteProject }) => {
navigate(routes.home()) navigate(routes.home())
toast.success('Part deleted.') toast.success('Project deleted.')
}, },
}) })
const onDelete = () => { 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, { const [toggleReaction] = useMutation(TOGGLE_REACTION_MUTATION, {
onCompleted: () => refetch(), onCompleted: () => refetch(),
refetchQueries: [ refetchQueries: [
{ {
query: PART_REACTION_QUERY, query: PROJECT_REACTION_QUERY,
variables: { partId: userPart?.Part?.id }, variables: { projectId: userProject?.Project?.id },
}, },
], ],
}) })
@@ -170,7 +180,7 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
input: { input: {
emote, emote,
userId: currentUser.sub, userId: currentUser.sub,
partId: userPart?.Part?.id, projectId: userProject?.Project?.id,
}, },
}, },
}) })
@@ -184,14 +194,14 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
input: { input: {
text, text,
userId: currentUser.sub, userId: currentUser.sub,
partId: userPart?.Part?.id, projectId: userProject?.Project?.id,
}, },
}, },
}) })
return ( return (
<PartProfile <ProjectProfile
userPart={userPart} userProject={userProject}
onSave={onSave} onSave={onSave}
onDelete={onDelete} onDelete={onDelete}
loading={loading} loading={loading}

View File

@@ -7,9 +7,9 @@ import {
Submit, Submit,
} from '@redwoodjs/forms' } from '@redwoodjs/forms'
const PartForm = (props) => { const ProjectForm = (props) => {
const onSubmit = (data) => { const onSubmit = (data) => {
props.onSave(data, props?.part?.id) props.onSave(data, props?.project?.id)
} }
return ( return (
@@ -31,7 +31,7 @@ const PartForm = (props) => {
</Label> </Label>
<TextField <TextField
name="title" name="title"
defaultValue={props.part?.title} defaultValue={props.project?.title}
className="rw-input" className="rw-input"
errorClassName="rw-input rw-input-error" errorClassName="rw-input rw-input-error"
validation={{ required: true }} validation={{ required: true }}
@@ -47,7 +47,7 @@ const PartForm = (props) => {
</Label> </Label>
<TextField <TextField
name="description" name="description"
defaultValue={props.part?.description} defaultValue={props.project?.description}
className="rw-input" className="rw-input"
errorClassName="rw-input rw-input-error" errorClassName="rw-input rw-input-error"
validation={{ required: true }} validation={{ required: true }}
@@ -63,7 +63,7 @@ const PartForm = (props) => {
</Label> </Label>
<TextField <TextField
name="code" name="code"
defaultValue={props.part?.code} defaultValue={props.project?.code}
className="rw-input" className="rw-input"
errorClassName="rw-input rw-input-error" errorClassName="rw-input rw-input-error"
validation={{ required: true }} validation={{ required: true }}
@@ -79,7 +79,7 @@ const PartForm = (props) => {
</Label> </Label>
<TextField <TextField
name="mainImage" name="mainImage"
defaultValue={props.part?.mainImage} defaultValue={props.project?.mainImage}
className="rw-input" className="rw-input"
errorClassName="rw-input rw-input-error" errorClassName="rw-input rw-input-error"
validation={{ required: true }} validation={{ required: true }}
@@ -95,7 +95,7 @@ const PartForm = (props) => {
</Label> </Label>
<TextField <TextField
name="userId" name="userId"
defaultValue={props.part?.userId} defaultValue={props.project?.userId}
className="rw-input" className="rw-input"
errorClassName="rw-input rw-input-error" errorClassName="rw-input rw-input-error"
validation={{ required: true }} 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 Tabs from '@material-ui/core/Tabs'
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { countEmotes } from 'src/helpers/emote' 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 emotes = countEmotes(reactions)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const onTabChange = (_, newValue) => { const onTabChange = (_, newValue) => {
@@ -36,17 +36,17 @@ const PartReactions = ({ reactions }) => {
.filter((reaction) => .filter((reaction) =>
tab === 0 ? true : reaction.emote === emotes[tab - 1].emoji tab === 0 ? true : reaction.emote === emotes[tab - 1].emoji
) )
.map((reactionPart, i) => ( .map((reactionProject, i) => (
<li <li
className="flex flex-row justify-between p-3 items-center" 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="flex items-center justify-center">
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow flex-shrink-0"> <div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow flex-shrink-0">
<ImageUploader <ImageUploader
className="" className=""
aspectRatio={1} aspectRatio={1}
imageUrl={reactionPart.user?.image} imageUrl={reactionProject.user?.image}
width={50} width={50}
/> />
</div> </div>
@@ -54,16 +54,16 @@ const PartReactions = ({ reactions }) => {
<div className="text-gray-800 font-normal text-md mb-1"> <div className="text-gray-800 font-normal text-md mb-1">
<Link <Link
to={routes.user({ to={routes.user({
userName: reactionPart.user?.userName, userName: reactionProject.user?.userName,
})} })}
> >
{reactionPart.user?.userName} {reactionProject.user?.userName}
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<span>{reactionPart.emote}</span> <span>{reactionProject.emote}</span>
</div> </div>
</li> </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: // Define your own mock data here:
export const standard = (/* vars, { ctx, req } */) => ({ export const standard = (/* vars, { ctx, req } */) => ({
partsOfUser: { projectReactions: {
id: 42, id: 42,
}, },
}) })

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './PartReactionsCell' import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
import { standard } from './PartReactionsCell.mock' import { standard } from './ProjectReactionsCell.mock'
export const loading = () => { export const loading = () => {
return Loading ? <Loading /> : null return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null 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 { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartCell' import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
import { standard } from './PartCell.mock' import { standard } from './ProjectReactionsCell.mock'
describe('PartCell', () => { describe('ProjectReactionsCell', () => {
test('Loading renders successfully', () => { test('Loading renders successfully', () => {
render(<Loading />) render(<Loading />)
// Use screen.debug() to see output // Use screen.debug() to see output
@@ -20,7 +20,7 @@ describe('PartCell', () => {
}) })
test('Success renders successfully', async () => { test('Success renders successfully', async () => {
render(<Success part={standard().part} />) render(<Success projectReactions={standard().projectReactions} />)
expect(screen.getByText(/42/i)).toBeInTheDocument() 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` export const QUERY = gql`
query PartReactionsQuery($partId: String!) { query ProjectReactionsQuery($projectId: String!) {
partReactionsByPartId(partId: $partId) { projectReactionsByProjectId(projectId: $projectId) {
id id
emote emote
user { user {
@@ -19,12 +19,12 @@ export const Loading = () => <div>Loading...</div>
export const Empty = () => ( export const Empty = () => (
<div className="text-center py-8 font-roboto text-gray-700"> <div className="text-center py-8 font-roboto text-gray-700">
No reactions to this part yet 😕 No reactions to this project yet 😕
</div> </div>
) )
export const Failure = ({ error }) => <div>Error: {error.message}</div> export const Failure = ({ error }) => <div>Error: {error.message}</div>
export const Success = ({ partReactionsByPartId }) => { export const Success = ({ projectReactionsByProjectId }) => {
return <PartReactions reactions={partReactionsByPartId} /> return <ProjectReactions reactions={projectReactionsByProjectId} />
} }

View File

@@ -4,19 +4,25 @@ import { Link, routes } from '@redwoodjs/router'
import { countEmotes } from 'src/helpers/emote' import { countEmotes } from 'src/helpers/emote'
import ImageUploader from 'src/components/ImageUploader' import ImageUploader from 'src/components/ImageUploader'
const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => { const ProjectsList = ({
// temporary filtering parts that don't have images until some kind of search is added and there are more things on the website 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. // it helps avoid the look of the website just being filled with dumby data.
// related issue-104 // related issue-104
const filteredParts = useMemo( const filteredProjects = useMemo(
() => () =>
(shouldFilterPartsWithoutImage (shouldFilterProjectsWithoutImage
? parts.filter(({ mainImage }) => mainImage) ? projects.filter(({ mainImage }) => mainImage)
: [...parts] : [...projects]
) )
// sort should probably be done on the service, but the filtering is temp too // 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)), .sort(
[parts, shouldFilterPartsWithoutImage] (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
),
[projects, shouldFilterProjectsWithoutImage]
) )
return ( return (
<section className="max-w-6xl mx-auto mt-8"> <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" className="grid gap-x-8 gap-y-12 items-center mx-4 relative"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
> >
{filteredParts.map(({ title, mainImage, user, Reaction }) => ( {filteredProjects.map(({ title, mainImage, user, Reaction }) => (
<li <li
className="rounded-lg shadow-md hover:shadow-lg mx-px transform hover:-translate-y-px transition-all duration-150" className="rounded-lg shadow-md hover:shadow-lg mx-px transform hover:-translate-y-px transition-all duration-150"
key={`${user?.userName}--${title}`} key={`${user?.userName}--${title}`}
> >
<Link <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="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"> <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 { Link, routes } from '@redwoodjs/router'
import Parts from 'src/components/Parts' import Projects from 'src/components/Projects/Projects'
export const QUERY = gql` export const QUERY = gql`
query PARTS { query PROJECTS {
parts { projects {
id id
title title
mainImage mainImage
@@ -26,8 +26,8 @@ export const Loading = () => <div>Loading...</div>
export const Empty = () => { export const Empty = () => {
return ( return (
<div className="rw-text-center"> <div className="rw-text-center">
{'No parts yet. '} {'No projects yet. '}
<Link to={routes.draftPart()} className="rw-link"> <Link to={routes.draftProject()} className="rw-link">
{'Create one?'} {'Create one?'}
</Link> </Link>
</div> </div>
@@ -35,13 +35,13 @@ export const Empty = () => {
} }
export const Success = ({ export const Success = ({
parts, projects,
variables: { shouldFilterPartsWithoutImage }, variables: { shouldFilterProjectsWithoutImage },
}) => { }) => {
return ( return (
<Parts <Projects
parts={parts} projects={projects}
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage} shouldFilterProjectsWithoutImage={shouldFilterProjectsWithoutImage}
/> />
) )
} }

View File

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

View File

@@ -1,5 +1,5 @@
import { Loading, Empty, Failure, Success } from './PartCell' import { Loading, Empty, Failure, Success } from './ProjectsOfUserCell'
import { standard } from './PartCell.mock' import { standard } from './ProjectsOfUserCell.mock'
export const loading = () => { export const loading = () => {
return Loading ? <Loading /> : null return Loading ? <Loading /> : null
@@ -17,4 +17,4 @@ export const success = () => {
return Success ? <Success {...standard()} /> : null 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 { render, screen } from '@redwoodjs/testing'
import { Loading, Empty, Failure, Success } from './PartReactionsCell' import { Loading, Empty, Success } from './ProjectsOfUserCell'
import { standard } from './PartReactionsCell.mock' import { standard } from './ProjectsOfUserCell.mock'
describe('PartReactionsCell', () => { describe('ProjectsOfUserCell', () => {
test('Loading renders successfully', () => { test('Loading renders successfully', () => {
render(<Loading />) render(<Loading />)
// Use screen.debug() to see output // Use screen.debug() to see output
@@ -14,13 +14,8 @@ describe('PartReactionsCell', () => {
expect(screen.getByText('Empty')).toBeInTheDocument() 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 () => { test('Success renders successfully', async () => {
render(<Success partReactions={standard().partReactions} />) render(<Success projectsOfUser={standard().projectsOfUser} />)
expect(screen.getByText(/42/i)).toBeInTheDocument() expect(screen.getByText(/42/i)).toBeInTheDocument()
}) })
}) })

View File

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

View File

@@ -1,22 +1,24 @@
type SvgNames = type SvgNames =
| 'arrow-down' | 'arrow-down'
| 'arrow' // | 'arrow'
| 'arrow-left' | 'arrow-left'
| 'big-gear' | 'big-gear'
| 'camera' | 'camera'
| 'checkmark' | 'check'
| 'chevron-down' | 'chevron-down'
| 'dots-vertical' | 'dots-vertical'
| 'drag-grid' | 'drag-grid'
| 'exclamation-circle' | 'exclamation-circle'
| 'favicon' | 'favicon'
| 'flag' | 'flag'
| 'floppy-disk'
| 'fork' | 'fork'
| 'gear' | 'gear'
| 'lightbulb' | 'lightbulb'
| 'logout' | 'logout'
| 'mac-cmd-key' | 'mac-cmd-key'
| 'pencil' | 'pencil'
| 'pencil-solid'
| 'photograph' | 'photograph'
| 'plus' | 'plus'
| 'plus-circle' | 'plus-circle'
@@ -84,13 +86,19 @@ const Svg = ({
/> />
</svg> </svg>
), ),
checkmark: ( check: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none"> <svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path <path
fillRule="evenodd" strokeLinecap="round"
clipRule="evenodd" strokeLinejoin="round"
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" strokeWidth={strokeWidth}
fill="currentColor" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
), ),
@@ -263,6 +271,16 @@ const Svg = ({
/> />
</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: ( fork: (
<svg <svg
viewBox="-3 -3 32 32" // TODO size this properly, or get a better icon viewBox="-3 -3 32 32" // TODO size this properly, or get a better icon
@@ -347,6 +365,16 @@ const Svg = ({
/> />
</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: ( photograph: (
<svg <svg
xmlns="http://www.w3.org/2000/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 ImageUploader from 'src/components/ImageUploader'
import Button from 'src/components/Button' import Button from 'src/components/Button'
import ProfileTextInput from 'src/components/ProfileTextInput' 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 { currentUser } = useAuth()
const canEdit = currentUser?.sub === user.id 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 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> </div>
<div className="mt-10"> <div className="mt-10">
<h3 className="text-3xl text-gray-500 font-ropa-sans">Parts:</h3> <h3 className="text-3xl text-gray-500 font-ropa-sans">Projects:</h3>
<PartsOfUser userName={user?.userName} /> <ProjectsOfUser userName={user?.userName} />
</div> </div>
</section> </section>
</> </>

View File

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

View File

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

View File

@@ -56,7 +56,10 @@ export const render = async ({ code, settings }: RenderArgs) => {
} }
const data = await response.json() const data = await response.json()
const type = data.type !== 'stl' ? 'png' : 'geometry' 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({ return createHealthyResponse({
type, type,
data: await newData, data: await newData,
@@ -102,12 +105,12 @@ export const stl = async ({ code, settings }: RenderArgs) => {
} }
} }
const openScad = { const openscad = {
render, render,
stl, stl,
} }
export default openScad export default openscad
function cleanError(error) { function cleanError(error) {
return error.replace(/["|']\/tmp\/.+\/main.scad["|']/g, "'main.scad'") 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 // TODO: create a tidy util for uploading to Cloudinary and returning the public ID
import axios from 'axios' 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_PRESET = 'CadHub_project_images'
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload' 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) 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