massive refactor toDrop cascadeStudio and add CadQuery + OpenSCAD
resolves #400
This commit is contained in:
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "web/src/cascade"]
|
||||
path = app/web/src/cascade
|
||||
url = https://github.com/Irev-Dev/CascadeStudio.git
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Hutten",
|
||||
"cadquery",
|
||||
"openscad",
|
||||
"sendmail"
|
||||
]
|
||||
}
|
||||
|
||||
10
README.md
10
README.md
@@ -3,6 +3,12 @@
|
||||
# [C a d H u b](https://cadhub.xyz)
|
||||
|
||||
<!-- [](https://app.netlify.com/sites/cadhubxyz/deploys) -->
|
||||
|
||||
Let's help Code-CAD reach its [full potential!](https://cadhub.xyz) We're making a ~~cad~~hub for the Code-CAD community, think of it as model-repository crossed with a live editor. We have integrations in progress for [OpenSCAD](https://cadhub.xyz/dev-ide/openscad) and [CadQuery](https://cadhub.xyz/dev-ide/cadquery) with [more coming soon](https://github.com/Irev-Dev/curated-code-cad).
|
||||
|
||||
If you want to be involved in anyway, checkout the [Road Map](https://github.com/Irev-Dev/cadhub/discussions/212) and get in touch via, [twitter](https://twitter.com/IrevDev), [discord](https://discord.gg/SD7zFRNjGH) or [discussions](https://github.com/Irev-Dev/cadhub/discussions).
|
||||
|
||||
<img src="https://raw.githubusercontent.com/Irev-Dev/repo-images/main/images/fullcadhubshot.jpg">
|
||||
<img src="https://raw.githubusercontent.com/Irev-Dev/cadhub/main/docs/static/img/blog/curated-code-cad/CadHubSS.jpg">
|
||||
|
||||
## Getting your dev environment setup
|
||||
@@ -10,9 +16,9 @@
|
||||
Because we're integrating cascadeStudio, this is done some what crudely for the time being, so you'll need to clone the repo with submodules.
|
||||
|
||||
```terminal
|
||||
git clone --recurse-submodules -j8 git@github.com:Irev-Dev/cadhub.git
|
||||
git clone git@github.com:Irev-Dev/cadhub.git
|
||||
# or
|
||||
git clone --recurse-submodules -j8 https://github.com/Irev-Dev/cadhub.git
|
||||
git clone https://github.com/Irev-Dev/cadhub.git
|
||||
```
|
||||
|
||||
Install dependencies
|
||||
|
||||
@@ -1 +1 @@
|
||||
/web/src/cascade/*
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -1,2 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -14,8 +14,7 @@ generator client {
|
||||
// ADMIN
|
||||
// }
|
||||
|
||||
// enum PartType {
|
||||
// CASCADESTUDIO
|
||||
// enum ProjectType {
|
||||
// JSCAD
|
||||
// }
|
||||
|
||||
@@ -33,15 +32,20 @@ model User {
|
||||
|
||||
image String? // url maybe id or file storage service? cloudinary?
|
||||
bio String? //mark down
|
||||
Part Part[]
|
||||
Reaction PartReaction[]
|
||||
Project Project[]
|
||||
Reaction ProjectReaction[]
|
||||
Comment Comment[]
|
||||
SubjectAccessRequest SubjectAccessRequest[]
|
||||
}
|
||||
|
||||
model Part {
|
||||
enum CadPackage {
|
||||
openscad
|
||||
cadquery
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
title String @db.VarChar(25)
|
||||
description String? // markdown string
|
||||
code String?
|
||||
mainImage String? // link to cloudinary
|
||||
@@ -50,23 +54,24 @@ model Part {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
deleted Boolean @default(false)
|
||||
cadPackage CadPackage @default(openscad)
|
||||
|
||||
Comment Comment[]
|
||||
Reaction PartReaction[]
|
||||
Reaction ProjectReaction[]
|
||||
@@unique([title, userId])
|
||||
}
|
||||
|
||||
model PartReaction {
|
||||
model ProjectReaction {
|
||||
id String @id @default(uuid())
|
||||
emote String // an emoji
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
part Part @relation(fields: [partId], references: [id])
|
||||
partId String
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
projectId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@unique([emote, userId, partId])
|
||||
@@unique([emote, userId, projectId])
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -74,8 +79,8 @@ model Comment {
|
||||
text String // the comment, should I allow mark down?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
part Part @relation(fields: [partId], references: [id])
|
||||
partId String
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
projectId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -91,3 +96,10 @@ model SubjectAccessRequest {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RW_DataMigration {
|
||||
version String @id
|
||||
name String
|
||||
startedAt DateTime
|
||||
finishedAt DateTime
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ async function main() {
|
||||
})
|
||||
}
|
||||
|
||||
const parts = [
|
||||
const projects = [
|
||||
{
|
||||
title: 'demo-part1',
|
||||
title: 'demo-project1',
|
||||
description: '# can be markdown',
|
||||
mainImage: 'CadHub/kjdlgjnu0xmwksia7xox',
|
||||
user: {
|
||||
@@ -62,7 +62,7 @@ async function main() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'demo-part2',
|
||||
title: 'demo-project2',
|
||||
description: '## [hey](www.google.com)',
|
||||
user: {
|
||||
connect: {
|
||||
@@ -72,39 +72,43 @@ async function main() {
|
||||
},
|
||||
]
|
||||
|
||||
existing = await db.part.findMany({where: { title: parts[0].title}})
|
||||
existing = await db.project.findMany({where: { title: projects[0].title}})
|
||||
if(!existing.length) {
|
||||
await db.part.create({
|
||||
data: parts[0],
|
||||
await db.project.create({
|
||||
data: projects[0],
|
||||
})
|
||||
}
|
||||
existing = await db.part.findMany({where: { title: parts[1].title}})
|
||||
existing = await db.project.findMany({where: { title: projects[1].title}})
|
||||
if(!existing.length) {
|
||||
await db.part.create({
|
||||
data: parts[1],
|
||||
await db.project.create({
|
||||
data: projects[1],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const aPart = await db.part.findUnique({where: {
|
||||
const aProject = await db.project.findUnique({where: {
|
||||
title_userId: {
|
||||
title: parts[0].title,
|
||||
title: projects[0].title,
|
||||
userId: users[0].id,
|
||||
}
|
||||
}})
|
||||
await db.comment.create({
|
||||
data: {
|
||||
text: "nice part, I like it",
|
||||
user: {connect: { id: users[0].id}},
|
||||
part: {connect: { id: aPart.id}},
|
||||
text: "nice project, I like it",
|
||||
userId: users[0].id,
|
||||
projectId: aProject.id,
|
||||
// user: {connect: { id: users[0].id}},
|
||||
// project: {connect: { id: aProject.id}},
|
||||
}
|
||||
})
|
||||
await db.partReaction.create({
|
||||
await db.projectReaction.create({
|
||||
data: {
|
||||
emote: "❤️",
|
||||
user: {connect: { id: users[0].id}},
|
||||
part: {connect: { id: aPart.id}},
|
||||
userId: users[0].id,
|
||||
projectId: aProject.id,
|
||||
// user: {connect: { id: users[0].id}},
|
||||
// project: {connect: { id: aProject.id}},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"@redwoodjs/api": "^0.34.1",
|
||||
"@sentry/node": "^6.5.1",
|
||||
"cloudinary": "^1.23.0",
|
||||
"human-id": "^2.0.1",
|
||||
"nodemailer": "^6.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
39
app/api/src/graphql/ProjectReactions.sdl.js
Normal file
39
app/api/src/graphql/ProjectReactions.sdl.js
Normal 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!
|
||||
}
|
||||
`
|
||||
@@ -4,8 +4,8 @@ export const schema = gql`
|
||||
text: String!
|
||||
user: User!
|
||||
userId: String!
|
||||
part: Part!
|
||||
partId: String!
|
||||
project: Project!
|
||||
projectId: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
@@ -18,13 +18,13 @@ export const schema = gql`
|
||||
input CreateCommentInput {
|
||||
text: String!
|
||||
userId: String!
|
||||
partId: String!
|
||||
projectId: String!
|
||||
}
|
||||
|
||||
input UpdateCommentInput {
|
||||
text: String
|
||||
userId: String
|
||||
partId: String
|
||||
projectId: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
`
|
||||
@@ -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!
|
||||
}
|
||||
`
|
||||
52
app/api/src/graphql/projects.sdl.ts
Normal file
52
app/api/src/graphql/projects.sdl.ts
Normal 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!
|
||||
}
|
||||
`
|
||||
@@ -8,9 +8,9 @@ export const schema = gql`
|
||||
updatedAt: DateTime!
|
||||
image: String
|
||||
bio: String
|
||||
Parts: [Part]!
|
||||
Part(partTitle: String): Part
|
||||
Reaction: [PartReaction]!
|
||||
Projects: [Project]!
|
||||
Project(projectTitle: String): Project
|
||||
Reaction: [ProjectReaction]!
|
||||
Comment: [Comment]!
|
||||
SubjectAccessRequest: [SubjectAccessRequest]!
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { AuthenticationError, ForbiddenError } from '@redwoodjs/api'
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const requireOwnership = async ({ userId, userName, partId } = {}) => {
|
||||
export const requireOwnership = async ({
|
||||
userId,
|
||||
userName,
|
||||
projectId,
|
||||
}: { userId?: string; userName?: string; projectId?: string } = {}) => {
|
||||
// IMPORTANT, don't forget to await this function, as it will only block
|
||||
// unwanted db actions if it has time to look up resources in the db.
|
||||
if (!context.currentUser) {
|
||||
throw new AuthenticationError("You don't have permission to do that.")
|
||||
}
|
||||
if (!userId && !userName && !partId) {
|
||||
if (!userId && !userName && !projectId) {
|
||||
throw new ForbiddenError("You don't have access to do that.")
|
||||
}
|
||||
|
||||
@@ -33,10 +37,10 @@ export const requireOwnership = async ({ userId, userName, partId } = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (partId) {
|
||||
const user = await db.part
|
||||
if (projectId) {
|
||||
const user = await db.project
|
||||
.findUnique({
|
||||
where: { id: partId },
|
||||
where: { id: projectId },
|
||||
})
|
||||
.user()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
import { comments } from './comments'
|
||||
*/
|
||||
|
||||
describe('comments', () => {
|
||||
it('returns true', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,6 @@ export const deleteComment = ({ id }) => {
|
||||
export const Comment = {
|
||||
user: (_obj, { root }) =>
|
||||
db.comment.findUnique({ where: { id: root.id } }).user(),
|
||||
part: (_obj, { root }) =>
|
||||
db.comment.findUnique({ where: { id: root.id } }).part(),
|
||||
project: (_obj, { root }) =>
|
||||
db.comment.findUnique({ where: { id: root.id } }).project(),
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { v2 as cloudinary } from 'cloudinary'
|
||||
import humanId from 'human-id'
|
||||
|
||||
cloudinary.config({
|
||||
cloud_name: 'irevdev',
|
||||
api_key: process.env.CLOUDINARY_API_KEY,
|
||||
@@ -36,6 +38,26 @@ export const generateUniqueString = async (
|
||||
return generateUniqueString(newSeed, isUniqueCallback, count)
|
||||
}
|
||||
|
||||
export const generateUniqueStringWithoutSeed = async (
|
||||
isUniqueCallback: (seed: string) => Promise<any>,
|
||||
count = 0
|
||||
) => {
|
||||
const seed = humanId({
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
})
|
||||
const isUnique = !(await isUniqueCallback(seed))
|
||||
if (isUnique) {
|
||||
return seed
|
||||
}
|
||||
count += 1
|
||||
if (count > 100) {
|
||||
console.log('trouble finding unique')
|
||||
return `very-unique-${seed}`.slice(0, 10)
|
||||
}
|
||||
return generateUniqueStringWithoutSeed(isUniqueCallback, count)
|
||||
}
|
||||
|
||||
export const destroyImage = ({ publicId }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
cloudinary.uploader.destroy(publicId, (error, result) => {
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
import { partReactions } from './partReactions'
|
||||
*/
|
||||
|
||||
describe('partReactions', () => {
|
||||
it('returns true', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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 } }),
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
import { parts } from './parts'
|
||||
*/
|
||||
|
||||
describe('parts', () => {
|
||||
it('returns true', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -5,24 +5,24 @@ import { requireOwnership } from 'src/lib/owner'
|
||||
import { db } from 'src/lib/db'
|
||||
import { foreignKeyReplacement } from 'src/services/helpers'
|
||||
|
||||
export const partReactions = () => {
|
||||
return db.partReaction.findMany()
|
||||
export const projectReactions = () => {
|
||||
return db.projectReaction.findMany()
|
||||
}
|
||||
|
||||
export const partReaction = ({ id }) => {
|
||||
return db.partReaction.findUnique({
|
||||
export const projectReaction = ({ id }) => {
|
||||
return db.projectReaction.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const partReactionsByPartId = ({ partId }) => {
|
||||
return db.partReaction.findMany({
|
||||
where: { partId: partId },
|
||||
export const projectReactionsByProjectId = ({ projectId }) => {
|
||||
return db.projectReaction.findMany({
|
||||
where: { projectId },
|
||||
})
|
||||
}
|
||||
|
||||
export const togglePartReaction = async ({ input }) => {
|
||||
// if write fails emote_userId_partId @@unique constraint, then delete it instead
|
||||
export const toggleProjectReaction = async ({ input }) => {
|
||||
// if write fails emote_userId_projectId @@unique constraint, then delete it instead
|
||||
requireAuth()
|
||||
await requireOwnership({ userId: input?.userId })
|
||||
const legalReactions = ['❤️', '👍', '😄', '🙌'] // TODO figure out a way of sharing code between FE and BE, so this is consistent with web/src/components/EmojiReaction/EmojiReaction.js
|
||||
@@ -36,33 +36,33 @@ export const togglePartReaction = async ({ input }) => {
|
||||
let dbPromise
|
||||
const inputClone = { ...input } // TODO foreignKeyReplacement mutates input, which I should fix but am lazy right now
|
||||
try {
|
||||
dbPromise = await db.partReaction.create({
|
||||
dbPromise = await db.projectReaction.create({
|
||||
data: foreignKeyReplacement(input),
|
||||
})
|
||||
} catch (e) {
|
||||
dbPromise = db.partReaction.delete({
|
||||
where: { emote_userId_partId: inputClone },
|
||||
dbPromise = db.projectReaction.delete({
|
||||
where: { emote_userId_projectId: inputClone },
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
export const updatePartReaction = ({ id, input }) => {
|
||||
return db.partReaction.update({
|
||||
export const updateProjectReaction = ({ id, input }) => {
|
||||
return db.projectReaction.update({
|
||||
data: foreignKeyReplacement(input),
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePartReaction = ({ id }) => {
|
||||
return db.partReaction.delete({
|
||||
export const deleteProjectReaction = ({ id }) => {
|
||||
return db.projectReaction.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const PartReaction = {
|
||||
export const ProjectReaction = {
|
||||
user: (_obj, { root }) =>
|
||||
db.partReaction.findUnique({ where: { id: root.id } }).user(),
|
||||
part: (_obj, { root }) =>
|
||||
db.partReaction.findUnique({ where: { id: root.id } }).part(),
|
||||
db.projectReaction.findUnique({ where: { id: root.id } }).user(),
|
||||
project: (_obj, { root }) =>
|
||||
db.projectReaction.findUnique({ where: { id: root.id } }).project(),
|
||||
}
|
||||
141
app/api/src/services/projects/projects.ts
Normal file
141
app/api/src/services/projects/projects.ts
Normal 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 } }),
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
import { subjectAccessRequests } from './subjectAccessRequests'
|
||||
*/
|
||||
|
||||
describe('subjectAccessRequests', () => {
|
||||
it('returns true', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
import { users } from './users'
|
||||
*/
|
||||
|
||||
describe('users', () => {
|
||||
it('returns true', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -51,9 +51,9 @@ export const updateUserByUserName = async ({ userName, input }) => {
|
||||
`You've tried to used a protected word as you userName, try something other than `
|
||||
)
|
||||
}
|
||||
const originalPart = await db.user.findUnique({ where: { userName } })
|
||||
const originalProject = await db.user.findUnique({ where: { userName } })
|
||||
const imageToDestroy =
|
||||
originalPart.image !== input.image && originalPart.image
|
||||
originalProject.image !== input.image && originalProject.image
|
||||
const update = await db.user.update({
|
||||
data: input,
|
||||
where: { userName },
|
||||
@@ -73,14 +73,14 @@ export const deleteUser = ({ id }) => {
|
||||
}
|
||||
|
||||
export const User = {
|
||||
Parts: (_obj, { root }) =>
|
||||
db.user.findUnique({ where: { id: root.id } }).Part(),
|
||||
Part: (_obj, { root }) =>
|
||||
_obj.partTitle &&
|
||||
db.part.findUnique({
|
||||
Projects: (_obj, { root }) =>
|
||||
db.user.findUnique({ where: { id: root.id } }).Project(),
|
||||
Project: (_obj, { root }) =>
|
||||
_obj.projectTitle &&
|
||||
db.project.findUnique({
|
||||
where: {
|
||||
title_userId: {
|
||||
title: _obj.partTitle,
|
||||
title: _obj.projectTitle,
|
||||
userId: root.id,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
open = true
|
||||
|
||||
[experimental]
|
||||
esbuild = false
|
||||
esbuild = true
|
||||
|
||||
|
||||
@@ -1,92 +1,9 @@
|
||||
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
|
||||
|
||||
module.exports = (config, { env }) => {
|
||||
config.plugins.forEach((plugin) => {
|
||||
if (plugin.constructor.name === 'HtmlWebpackPlugin') {
|
||||
plugin.options.favicon = './src/favicon.svg'
|
||||
} else if (plugin.constructor.name === 'CopyPlugin') {
|
||||
plugin.patterns.push({
|
||||
from: './src/cascade/js/StandardLibraryIntellisense.ts',
|
||||
to: 'js/StandardLibraryIntellisense.ts',
|
||||
})
|
||||
plugin.patterns.push({
|
||||
from: './src/cascade/static_node_modules/opencascade.js/dist/oc.d.ts',
|
||||
to: 'opencascade.d.ts',
|
||||
})
|
||||
plugin.patterns.push({
|
||||
from: '../node_modules/three/src/Three.d.ts',
|
||||
to: 'Three.d.ts',
|
||||
})
|
||||
plugin.patterns.push({
|
||||
from: './src/cascade/fonts',
|
||||
to: 'fonts',
|
||||
})
|
||||
plugin.patterns.push({
|
||||
from: './src/cascade/textures',
|
||||
to: 'textures',
|
||||
})
|
||||
}
|
||||
})
|
||||
config.plugins.push(
|
||||
new MonacoWebpackPlugin({
|
||||
languages: ['typescript'],
|
||||
features: [
|
||||
'accessibilityHelp',
|
||||
'anchorSelect',
|
||||
'bracketMatching',
|
||||
'caretOperations',
|
||||
'clipboard',
|
||||
'codeAction',
|
||||
'codelens',
|
||||
'comment',
|
||||
'contextmenu',
|
||||
'coreCommands',
|
||||
'cursorUndo',
|
||||
'documentSymbols',
|
||||
'find',
|
||||
'folding',
|
||||
'fontZoom',
|
||||
'format',
|
||||
'gotoError',
|
||||
'gotoLine',
|
||||
'gotoSymbol',
|
||||
'hover',
|
||||
'inPlaceReplace',
|
||||
'indentation',
|
||||
'inlineHints',
|
||||
'inspectTokens',
|
||||
'linesOperations',
|
||||
'linkedEditing',
|
||||
'links',
|
||||
'multicursor',
|
||||
'parameterHints',
|
||||
'quickCommand',
|
||||
'quickHelp',
|
||||
'quickOutline',
|
||||
'referenceSearch',
|
||||
'rename',
|
||||
'smartSelect',
|
||||
'snippets',
|
||||
'suggest',
|
||||
'toggleHighContrast',
|
||||
'toggleTabFocusMode',
|
||||
'transpose',
|
||||
'unusualLineTerminators',
|
||||
'viewportSemanticTokens',
|
||||
'wordHighlighter',
|
||||
'wordOperations',
|
||||
'wordPartOperations',
|
||||
],
|
||||
})
|
||||
)
|
||||
config.module.rules[0].oneOf.push({
|
||||
test: /opencascade\.wasm\.wasm$/,
|
||||
type: 'javascript/auto',
|
||||
loader: 'file-loader',
|
||||
})
|
||||
config.node = {
|
||||
fs: 'empty',
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -16,23 +16,19 @@
|
||||
"@headlessui/react": "^1.0.0",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@monaco-editor/react": "^4.0.11",
|
||||
"@react-three/fiber": "^7.0.5",
|
||||
"@redwoodjs/auth": "^0.34.1",
|
||||
"@redwoodjs/forms": "^0.34.1",
|
||||
"@redwoodjs/router": "^0.34.1",
|
||||
"@redwoodjs/web": "^0.34.1",
|
||||
"@sentry/browser": "^6.5.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
||||
"browser-fs-access": "^0.17.2",
|
||||
"cloudinary-react": "^1.6.7",
|
||||
"controlkit": "^0.1.9",
|
||||
"get-active-classes": "^0.0.11",
|
||||
"golden-layout": "^1.5.9",
|
||||
"gotrue-js": "^0.9.27",
|
||||
"jquery": "^3.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"monaco-editor": "^0.20.0",
|
||||
"monaco-editor-webpack-plugin": "^1.9.1",
|
||||
"netlify-identity-widget": "^1.9.1",
|
||||
"opencascade.js": "^0.1.15",
|
||||
"pako": "^2.0.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.2",
|
||||
@@ -43,19 +39,16 @@
|
||||
"react-image-crop": "^8.6.6",
|
||||
"react-mosaic-component": "^4.1.1",
|
||||
"react-tabs": "^3.2.2",
|
||||
"react-three-fiber": "^5.3.19",
|
||||
"rich-markdown-editor": "^11.0.2",
|
||||
"styled-components": "^5.2.0",
|
||||
"three": "^0.118.3"
|
||||
"three": "^0.130.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.170",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"opentype.js": "^1.3.3",
|
||||
"postcss": "^8.2.13",
|
||||
"postcss-loader": "4.0.2",
|
||||
"tailwindcss": "^2.1.2",
|
||||
"worker-loader": "^3.0.7"
|
||||
"tailwindcss": "^2.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,26 +44,26 @@ const Routes = () => {
|
||||
<Route notfound page={NotFoundPage} />
|
||||
|
||||
{/* Ownership enforced routes */}
|
||||
<Route path="/u/{userName}/new" page={NewPartPage} name="newPart" />
|
||||
<Route path="/u/{userName}/new" page={NewProjectPage} name="newProject" />
|
||||
<Private unauthenticated="home" role="user">
|
||||
<Route path="/u/{userName}/edit" page={EditUserPage} name="editUser" />
|
||||
<Route path="/u/{userName}/{partTitle}/edit" page={EditPartPage} name="editPart" />
|
||||
<Route path="/u/{userName}/{projectTitle}/edit" page={EditProjectPage} name="editProject" />
|
||||
</Private>
|
||||
{/* End ownership enforced routes */}
|
||||
|
||||
<Route path="/draft" page={DraftPartPage} name="draftPart" />
|
||||
<Route path="/draft/{cadPackage}" page={DraftProjectPage} name="draftProject" />
|
||||
<Route path="/u/{userName}" page={UserPage} name="user" />
|
||||
<Route path="/u/{userName}/{partTitle}" page={PartPage} name="part" />
|
||||
<Route path="/u/{userName}/{partTitle}/ide" page={IdePartPage} name="ide" />
|
||||
<Route path="/u/{userName}/{projectTitle}" page={ProjectPage} name="project" />
|
||||
<Route path="/u/{userName}/{projectTitle}/ide" page={IdeProjectPage} name="ide" />
|
||||
|
||||
<Private unauthenticated="home" role="admin">
|
||||
<Route path="/admin/users" page={UsersPage} name="users" />
|
||||
<Route path="/admin/parts" page={AdminPartsPage} name="parts" />
|
||||
<Route path="/admin/projects" page={AdminProjectsPage} name="projects" />
|
||||
<Route path="/admin/subject-access-requests/{id}/edit" page={EditSubjectAccessRequestPage} name="editSubjectAccessRequest" />
|
||||
<Route path="/admin/subject-access-requests/{id}" page={SubjectAccessRequestPage} name="subjectAccessRequest" />
|
||||
<Route path="/admin/subject-access-requests" page={SubjectAccessRequestsPage} name="subjectAccessRequests" />
|
||||
|
||||
{/* Retired for now but might want to bring it back, delete if older that I danno late 2021 */}
|
||||
{/* Retired for now but might want to bring it back, delete if older that I dunno late 2021 */}
|
||||
{/* <Route path="/admin/email" page={AdminEmailPage} name="adminEmail" /> */}
|
||||
</Private>
|
||||
</Router>
|
||||
|
||||
Submodule app/web/src/cascade deleted from cd23a8e673
@@ -2,11 +2,11 @@ import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { QUERY } from 'src/components/AdminPartsCell'
|
||||
import { QUERY } from 'src/components/AdminProjectsCell/AdminProjectsCell'
|
||||
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutationAdmin($id: String!) {
|
||||
deletePart(id: $id) {
|
||||
const DELETE_PROJECT_MUTATION_ADMIN = gql`
|
||||
mutation DeleteProjectMutationAdmin($id: String!) {
|
||||
deleteProject(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@ const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const AdminParts = ({ parts }) => {
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
const AdminProjects = ({ projects }) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION_ADMIN, {
|
||||
onCompleted: () => {
|
||||
toast.success('Part deleted.')
|
||||
toast.success('Project deleted.')
|
||||
},
|
||||
// This refetches the query on the list page. Read more about other ways to
|
||||
// update the cache over here:
|
||||
@@ -47,8 +47,8 @@ const AdminParts = ({ parts }) => {
|
||||
})
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (confirm('Are you sure you want to delete part ' + id + '?')) {
|
||||
deletePart({ variables: { id } })
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?')) {
|
||||
deleteProject({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,44 +70,44 @@ const AdminParts = ({ parts }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{truncate(part.id)}</td>
|
||||
<td>{truncate(part.title)}</td>
|
||||
<td>{truncate(part.description)}</td>
|
||||
<td>{truncate(part.code)}</td>
|
||||
<td>{truncate(part.mainImage)}</td>
|
||||
<td>{timeTag(part.createdAt)}</td>
|
||||
<td>{timeTag(part.updatedAt)}</td>
|
||||
<td>{truncate(part.userId)}</td>
|
||||
<td>{checkboxInputTag(part.deleted)}</td>
|
||||
{projects.map((project) => (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.id)}</td>
|
||||
<td>{truncate(project.title)}</td>
|
||||
<td>{truncate(project.description)}</td>
|
||||
<td>{truncate(project.code)}</td>
|
||||
<td>{truncate(project.mainImage)}</td>
|
||||
<td>{timeTag(project.createdAt)}</td>
|
||||
<td>{timeTag(project.updatedAt)}</td>
|
||||
<td>{truncate(project.userId)}</td>
|
||||
<td>{checkboxInputTag(project.deleted)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<Link
|
||||
to={routes.part({
|
||||
userName: part?.user?.userName,
|
||||
partTitle: part?.title,
|
||||
to={routes.project({
|
||||
userName: project?.user?.userName,
|
||||
projectTitle: project?.title,
|
||||
})}
|
||||
title={'Show part ' + part.id + ' detail'}
|
||||
title={'Show project ' + project.id + ' detail'}
|
||||
className="rw-button rw-button-small"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editPart({
|
||||
userName: part?.user?.userName,
|
||||
partTitle: part?.title,
|
||||
to={routes.editProject({
|
||||
userName: project?.user?.userName,
|
||||
projectTitle: project?.title,
|
||||
})}
|
||||
title={'Edit part ' + part.id}
|
||||
title={'Edit project ' + project.id}
|
||||
className="rw-button rw-button-small rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<a
|
||||
href="#"
|
||||
title={'Delete part ' + part.id}
|
||||
title={'Delete project ' + project.id}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(part.id)}
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
@@ -121,4 +121,4 @@ const AdminParts = ({ parts }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminParts
|
||||
export default AdminProjects
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import AdminParts from 'src/components/AdminParts'
|
||||
import AdminProjects from 'src/components/AdminProjects/AdminProjects'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS_ADMIN {
|
||||
parts {
|
||||
query PROJECTS_ADMIN {
|
||||
projects {
|
||||
id
|
||||
title
|
||||
description
|
||||
@@ -26,14 +26,14 @@ export const Loading = () => <div>Loading...</div>
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No parts yet. '}
|
||||
<Link to={routes.newPart()} className="rw-link">
|
||||
{'No projects yet. '}
|
||||
<Link to={routes.newProject()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Success = ({ parts }) => {
|
||||
return <AdminParts parts={parts} />
|
||||
export const Success = ({ projects }) => {
|
||||
return <AdminProjects projects={projects} />
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
|
||||
export const generated = () => {
|
||||
return <Breadcrumb />
|
||||
}
|
||||
|
||||
export default { title: 'Components/Breadcrumb' }
|
||||
@@ -19,7 +19,7 @@ const Button = ({
|
||||
'text-red-600 bg-red-200 border border-red-600': type === 'danger',
|
||||
'text-indigo-600': !type,
|
||||
},
|
||||
'flex items-center bg-opacity-50 rounded-xl p-2 px-6',
|
||||
'flex items-center bg-opacity-50 rounded p-2 px-6',
|
||||
{
|
||||
'mx-px transform hover:-translate-y-px transition-all duration-150':
|
||||
shouldAnimateHover && !disabled,
|
||||
@@ -29,7 +29,7 @@ const Button = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<Svg className="w-6 ml-4" name={iconName} />
|
||||
{iconName && <Svg className="w-6 ml-4" name={iconName} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
30
app/web/src/components/CadPackage/CadPackage.tsx
Normal file
30
app/web/src/components/CadPackage/CadPackage.tsx
Normal 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
|
||||
199
app/web/src/components/CaptureButton/CaptureButton.tsx
Normal file
199
app/web/src/components/CaptureButton/CaptureButton.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Menu } from '@headlessui/react'
|
||||
|
||||
import { useIdeContext, ideTypeNameMap } from 'src/helpers/hooks/useIdeContext'
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
import { useRender } from 'src/components/IdeWrapper/useRender'
|
||||
import { makeStlDownloadHandler, PullTitleFromFirstLine } from './helpers'
|
||||
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
|
||||
import CadPackage from 'src/components/CadPackage/CadPackage'
|
||||
|
||||
const EditorMenu = () => {
|
||||
const handleRender = useRender()
|
||||
const saveCode = useSaveCode()
|
||||
const { state, thunkDispatch } = useIdeContext()
|
||||
const onRender = () => {
|
||||
handleRender()
|
||||
saveCode({ code: state.code })
|
||||
}
|
||||
const handleStlDownload = makeStlDownloadHandler({
|
||||
type: state.objectData?.type,
|
||||
ideType: state.ideType,
|
||||
@@ -16,9 +23,6 @@ const EditorMenu = () => {
|
||||
fileName: PullTitleFromFirstLine(state.code || ''),
|
||||
thunkDispatch,
|
||||
})
|
||||
const cadName = ideTypeNameMap[state.ideType] || ''
|
||||
const isOpenScad = state.ideType === 'openScad'
|
||||
const isCadQuery = state.ideType === 'cadQuery'
|
||||
return (
|
||||
<div className="flex justify-between bg-ch-gray-760 text-gray-100">
|
||||
<div className="flex items-center h-9 w-full cursor-grab">
|
||||
@@ -27,7 +31,7 @@ const EditorMenu = () => {
|
||||
</div>
|
||||
<div className="flex gap-6 px-5">
|
||||
<FileDropdown
|
||||
handleRender={handleRender}
|
||||
handleRender={onRender}
|
||||
handleStlDownload={handleStlDownload}
|
||||
/>
|
||||
<button className="cursor-not-allowed" disabled>
|
||||
@@ -45,14 +49,7 @@ const EditorMenu = () => {
|
||||
<Svg name="gear" className="w-6 p-px" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center cursor-default">
|
||||
<div
|
||||
className={`${isOpenScad && 'bg-yellow-200'} ${
|
||||
isCadQuery && 'bg-blue-800'
|
||||
} w-5 h-5 rounded-full`}
|
||||
/>
|
||||
<div className="px-2">{cadName}</div>
|
||||
</div>
|
||||
<CadPackage cadPackage={state.ideType} className="px-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ export const makeStlDownloadHandler =
|
||||
if (geometry) {
|
||||
if (
|
||||
type === 'geometry' &&
|
||||
(quality === 'high' || ideType === 'openScad')
|
||||
(quality === 'high' || ideType === 'openscad')
|
||||
) {
|
||||
saveFile(geometry)
|
||||
} else {
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
const specialCadProcess = ideType === 'openScad' && 'stl'
|
||||
const specialCadProcess = ideType === 'openscad' && 'stl'
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import EmojiReaction from './EmojiReaction'
|
||||
|
||||
export const generated = () => {
|
||||
return <EmojiReaction />
|
||||
}
|
||||
|
||||
export default { title: 'Components/EmojiReaction' }
|
||||
@@ -3,7 +3,7 @@ import { getActiveClasses } from 'get-active-classes'
|
||||
import Popover from '@material-ui/core/Popover'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
|
||||
import Svg from 'src/components/Svg'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
|
||||
const emojiMenu = ['❤️', '👍', '😄', '🙌']
|
||||
// const emojiMenu = ['🏆', '❤️', '👍', '😊', '😄', '🚀', '👏', '🙌']
|
||||
@@ -20,7 +20,7 @@ const EmojiReaction = ({
|
||||
emotes,
|
||||
userEmotes,
|
||||
onEmote = () => {},
|
||||
onShowPartReactions,
|
||||
onShowProjectReactions,
|
||||
className,
|
||||
}) => {
|
||||
const { currentUser } = useAuth()
|
||||
@@ -57,42 +57,41 @@ const EmojiReaction = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={getActiveClasses(
|
||||
'h-10 relative overflow-hidden py-4',
|
||||
className
|
||||
)}
|
||||
className={getActiveClasses('relative overflow-hidden pt-1', className)}
|
||||
>
|
||||
<div className="absolute left-0 w-8 inset-y-0 z-10 flex items-center bg-gray-100">
|
||||
<div className="z-10 flex items-center gap-4 h-10">
|
||||
<div
|
||||
className="h-8 w-8 relative"
|
||||
className="h-full w-10"
|
||||
aria-describedby={popoverId}
|
||||
onClick={togglePopover}
|
||||
>
|
||||
<button className="bg-gray-200 border-2 m-px w-full h-full border-gray-300 rounded-full flex justify-center items-center shadow-md hover:shadow-lg hover:border-indigo-200 transform hover:-translate-y-px transition-all duration-150">
|
||||
<Svg
|
||||
className="h-8 w-8 pt-px mt-px text-gray-500"
|
||||
name="dots-vertical"
|
||||
/>
|
||||
<button className="bg-ch-gray-600 w-full h-full flex justify-center items-center shadow-md hover:shadow-lg transform hover:-translate-y-px transition-all duration-150 rounded">
|
||||
<Svg className="w-8 text-ch-gray-300" name="dots-vertical" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="whitespace-nowrap absolute right-0 inset-y-0 flex items-center flex-row-reverse">
|
||||
{(emotes.length ? emotes : noEmotes).map((emote, i) => (
|
||||
<span
|
||||
className={getActiveClasses(
|
||||
'rounded-full tracking-wide hover:bg-indigo-100 p-1 mx-px transform hover:-translate-y-px transition-all duration-150 border-indigo-400',
|
||||
{ border: currentUser && userEmotes?.includes(emote.emoji) }
|
||||
'tracking-wide border border-transparent hover:border-ch-gray-300 h-full p-1 px-4 transform hover:-translate-y-px transition-all duration-150 flex items-center rounded',
|
||||
{
|
||||
'bg-ch-gray-500 text-ch-gray-900':
|
||||
currentUser && userEmotes?.includes(emote.emoji),
|
||||
'bg-ch-gray-600': !(
|
||||
currentUser && userEmotes?.includes(emote.emoji)
|
||||
),
|
||||
}
|
||||
)}
|
||||
style={textShadow}
|
||||
key={`${emote.emoji}--${i}`}
|
||||
onClick={() => handleEmojiClick(emote.emoji)}
|
||||
>
|
||||
<span className="text-lg pr-1">{emote.emoji}</span>
|
||||
<span className="text-sm font-ropa-sans">{emote.count}</span>
|
||||
<span className="text-lg pr-2">{emote.emoji}</span>
|
||||
<span className="text-sm font-fira-code">{emote.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="whitespace-nowrap flex items-center flex-row-reverse"></div>
|
||||
</div>
|
||||
<Popover
|
||||
id={popoverId}
|
||||
@@ -121,7 +120,7 @@ const EmojiReaction = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="text-gray-700" onClick={onShowPartReactions}>
|
||||
<button className="text-gray-700" onClick={onShowProjectReactions}>
|
||||
View Reactions
|
||||
</button>
|
||||
</div>
|
||||
@@ -32,14 +32,17 @@ export function makeExternalUrl(resourceUrl: string): string {
|
||||
}#${fetchText}=${prepareDecodedUrl(resourceUrl)}`
|
||||
}
|
||||
|
||||
export function useIdeInit(cadPackage: string) {
|
||||
export function useIdeInit(cadPackage: string, code = '') {
|
||||
const { thunkDispatch } = useIdeContext()
|
||||
const handleRender = useRender()
|
||||
useEffect(() => {
|
||||
thunkDispatch({
|
||||
type: 'initIde',
|
||||
payload: { cadPackage },
|
||||
payload: { cadPackage, code },
|
||||
})
|
||||
if (code) {
|
||||
return
|
||||
}
|
||||
// load code from hash if it's there
|
||||
const triggerRender = () =>
|
||||
setTimeout(() => {
|
||||
|
||||
27
app/web/src/components/Gravatar/Gravatar.tsx
Normal file
27
app/web/src/components/Gravatar/Gravatar.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
import IdeCascadeStudio from './IdeCascadeStudio'
|
||||
|
||||
export const generated = () => {
|
||||
return <IdeCascadeStudio />
|
||||
}
|
||||
|
||||
export default { title: 'Components/IdeCascadeStudio' }
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useRef, useEffect, Suspense, lazy } from 'react'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Mosaic, MosaicWindow } from 'react-mosaic-component'
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
import IdeConsole from 'src/components/IdeConsole'
|
||||
import 'react-mosaic-component/react-mosaic-component.css'
|
||||
import EditorMenu from 'src/components/EditorMenu/EditorMenu'
|
||||
import PanelToolbar from 'src/components/PanelToolbar'
|
||||
import PanelToolbar from 'src/components/PanelToolbar/PanelToolbar'
|
||||
import { use3dViewerResize } from 'src/helpers/hooks/use3dViewerResize'
|
||||
|
||||
const IdeEditor = lazy(() => import('src/components/IdeEditor/IdeEditor'))
|
||||
const IdeViewer = lazy(() => import('src/components/IdeViewer/IdeViewer'))
|
||||
@@ -54,54 +54,15 @@ const TOOLBAR_MAP = {
|
||||
),
|
||||
Console: (
|
||||
<div>
|
||||
<PanelToolbar panelName="Console" />
|
||||
<PanelToolbar panelName="Console" showTopGradient />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const IdeContainer = () => {
|
||||
const { viewerDomRef, handleViewerSizeUpdate } = use3dViewerResize()
|
||||
|
||||
const { state, thunkDispatch } = useIdeContext()
|
||||
const viewerDOM = useRef(null)
|
||||
const debounceTimeoutId = useRef
|
||||
|
||||
useEffect(handleViewerSizeUpdate, [viewerDOM])
|
||||
|
||||
function handleViewerSizeUpdate() {
|
||||
if (viewerDOM !== null && viewerDOM.current) {
|
||||
const { width, height } = viewerDOM.current.getBoundingClientRect()
|
||||
thunkDispatch({
|
||||
type: 'updateViewerSize',
|
||||
payload: { viewerSize: { width, height } },
|
||||
})
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
if (['png', 'INIT'].includes(state.objectData?.type)) {
|
||||
dispatch({ type: 'setLoading' })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
code: state.code,
|
||||
viewerSize: { width, height },
|
||||
camera: state.camera,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedViewerSizeUpdate = () => {
|
||||
clearTimeout(debounceTimeoutId.current)
|
||||
debounceTimeoutId.current = setTimeout(() => {
|
||||
handleViewerSizeUpdate()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', debouncedViewerSizeUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedViewerSizeUpdate)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -117,7 +78,7 @@ const IdeContainer = () => {
|
||||
className={`${id.toLowerCase()} ${id.toLowerCase()}-tile`}
|
||||
>
|
||||
{id === 'Viewer' ? (
|
||||
<div id="view-wrapper" className="h-full" ref={viewerDOM}>
|
||||
<div id="view-wrapper" className="h-full" ref={viewerDomRef}>
|
||||
{ELEMENT_MAP[id]}
|
||||
</div>
|
||||
) : (
|
||||
@@ -3,15 +3,18 @@ import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { makeCodeStoreKey, requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
import Editor, { useMonaco } from '@monaco-editor/react'
|
||||
import { theme } from 'src/../tailwind.config'
|
||||
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
|
||||
|
||||
const colors = theme.extend.colors
|
||||
|
||||
const IdeEditor = ({ Loading }) => {
|
||||
const { state, thunkDispatch } = useIdeContext()
|
||||
const [theme, setTheme] = useState('vs-dark')
|
||||
const saveCode = useSaveCode()
|
||||
|
||||
const ideTypeToLanguageMap = {
|
||||
cadQuery: 'python',
|
||||
openScad: 'cpp',
|
||||
cadquery: 'python',
|
||||
openscad: 'cpp',
|
||||
}
|
||||
const monaco = useMonaco()
|
||||
useEffect(() => {
|
||||
@@ -49,6 +52,7 @@ const IdeEditor = ({ Loading }) => {
|
||||
thunkDispatch((dispatch, getState) => {
|
||||
const state = getState()
|
||||
dispatch({ type: 'setLoading' })
|
||||
saveCode({ code: state.code })
|
||||
requestRender({
|
||||
state,
|
||||
dispatch,
|
||||
|
||||
@@ -1,41 +1,135 @@
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { Link, navigate, routes } from '@redwoodjs/router'
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
|
||||
import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding'
|
||||
import ExternalScript from 'src/components/EncodedUrl/ExternalScript'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
import NavPlusButton from 'src/components/NavPlusButton'
|
||||
import ProfileSlashLogin from 'src/components/ProfileSlashLogin'
|
||||
import Gravatar from 'src/components/Gravatar/Gravatar'
|
||||
import EditableProjectTitle from 'src/components/EditableProjecTitle/EditableProjecTitle'
|
||||
import CaptureButton from 'src/components/CaptureButton/CaptureButton'
|
||||
|
||||
const TopButton = ({
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
name,
|
||||
Tag = 'button',
|
||||
}: {
|
||||
onClick?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
name: string
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex bg-gray-200 h-10 justify-center items-center px-4 rounded ${className}`}
|
||||
>
|
||||
{children}
|
||||
<span className="hidden md:block ml-2">{name}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
|
||||
Tag?: string
|
||||
}) => {
|
||||
const FinalTag = Tag as unknown as keyof JSX.IntrinsicElements
|
||||
return (
|
||||
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center">
|
||||
<div className="bg-ch-gray-700 md:pr-48 h-full"></div>
|
||||
<div className="text-gray-200 flex gap-4 mr-4">
|
||||
<TopButton
|
||||
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||
onClick={handleRender}
|
||||
name="Preview"
|
||||
>
|
||||
<Svg name="photograph" className="w-6 h-6 text-ch-pink-500" />
|
||||
</TopButton>
|
||||
<FinalTag
|
||||
onClick={onClick}
|
||||
className={`flex bg-gray-200 h-10 flex-shrink-0 justify-center items-center px-4 rounded ${className} whitespace-nowrap`}
|
||||
>
|
||||
{children}
|
||||
<span className="hidden md:block ml-2">{name}</span>
|
||||
</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
|
||||
|
||||
return (
|
||||
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center text-lg">
|
||||
{_projectId ? (
|
||||
<div className="h-full text-gray-300 flex items-center">
|
||||
<span className="bg-ch-gray-700 h-full flex items-center gap-2 px-4">
|
||||
<Gravatar
|
||||
image={project?.user?.image || projectOwnerImage}
|
||||
className="w-10"
|
||||
/>
|
||||
<Link
|
||||
to={routes.user({
|
||||
userName: _projectOwner,
|
||||
})}
|
||||
>
|
||||
{_projectOwner}
|
||||
</Link>
|
||||
</span>
|
||||
<EditableProjectTitle
|
||||
id={_projectId}
|
||||
userName={_projectOwner}
|
||||
projectTitle={project?.title || projectTitle}
|
||||
canEdit={canEdit}
|
||||
shouldRouteToIde={!projectTitle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="text-gray-200 flex gap-4 mr-4 items-center">
|
||||
{canEdit && !projectTitle && (
|
||||
<CaptureButton
|
||||
canEdit={canEdit}
|
||||
shouldUpdateImage={!project?.mainImage}
|
||||
TheButton={({ onClick }) => (
|
||||
<TopButton
|
||||
onClick={onClick}
|
||||
name="Save Project Image"
|
||||
className=" bg-ch-gray-300 bg-opacity-70 hover:bg-opacity-90 text-ch-gray-900"
|
||||
>
|
||||
<Svg name="camera" className="w-6 h-6" />
|
||||
</TopButton>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!projectTitle && (
|
||||
<TopButton
|
||||
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||
onClick={handleRender}
|
||||
name={canEdit ? 'Save' : 'Preview'}
|
||||
>
|
||||
<Svg
|
||||
name={canEdit ? 'floppy-disk' : 'photograph'}
|
||||
className="w-6 h-6 text-ch-pink-500"
|
||||
/>
|
||||
</TopButton>
|
||||
)}
|
||||
{projectTitle && (
|
||||
<TopButton
|
||||
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||
onClick={() =>
|
||||
navigate(routes.ide({ userName: _projectOwner, projectTitle }))
|
||||
}
|
||||
name="Editor"
|
||||
>
|
||||
<Svg name="terminal" className="w-6 h-6 text-ch-pink-500" />
|
||||
</TopButton>
|
||||
)}
|
||||
|
||||
<Popover className="relative outline-none w-full h-full">
|
||||
{({ open }) => {
|
||||
@@ -43,6 +137,7 @@ const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
|
||||
<>
|
||||
<Popover.Button className="h-full w-full outline-none">
|
||||
<TopButton
|
||||
Tag="div"
|
||||
name="Share"
|
||||
className=" bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||
>
|
||||
@@ -77,6 +172,10 @@ const IdeHeader = ({ handleRender }: { handleRender: () => void }) => {
|
||||
}}
|
||||
</Popover>
|
||||
{/* <TopButton>Fork</TopButton> */}
|
||||
<div className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 flex items-center justify-center">
|
||||
<NavPlusButton />
|
||||
</div>
|
||||
<ProfileSlashLogin />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
idePart: {
|
||||
ideProject: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -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' }
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartsOfUserCell'
|
||||
import { standard } from './PartsOfUserCell.mock'
|
||||
import { Loading, Empty, Success } from './IdeProjectCell'
|
||||
import { standard } from './IdeProjectCell.mock'
|
||||
|
||||
describe('PartsOfUserCell', () => {
|
||||
describe('IdeProjectCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
@@ -14,13 +14,8 @@ describe('PartsOfUserCell', () => {
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success partsOfUser={standard().partsOfUser} />)
|
||||
render(<Success ideProject={standard().ideProject} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
120
app/web/src/components/IdeProjectCell/IdeProjectCell.tsx
Normal file
120
app/web/src/components/IdeProjectCell/IdeProjectCell.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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
|
||||
@@ -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' }
|
||||
@@ -1,12 +1,6 @@
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import {
|
||||
Canvas,
|
||||
extend,
|
||||
useFrame,
|
||||
useThree,
|
||||
useUpdate,
|
||||
} from 'react-three-fiber'
|
||||
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
|
||||
import { Canvas, extend, useFrame, useThree } from '@react-three/fiber'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { Vector3 } from 'three'
|
||||
import { requestRender } from 'src/helpers/hooks/useIdeState'
|
||||
@@ -20,9 +14,12 @@ extend({ OrbitControls })
|
||||
|
||||
function Asset({ geometry: incomingGeo }) {
|
||||
const mesh = useRef()
|
||||
const ref = useUpdate((geometry) => {
|
||||
geometry.attributes = incomingGeo.attributes
|
||||
})
|
||||
const ref = useRef<any>({})
|
||||
useLayoutEffect(() => {
|
||||
if (incomingGeo?.attributes) {
|
||||
ref.current.attributes = incomingGeo.attributes
|
||||
}
|
||||
}, [incomingGeo])
|
||||
if (!incomingGeo) return null
|
||||
return (
|
||||
<mesh ref={mesh} scale={[1, 1, 1]}>
|
||||
@@ -33,9 +30,13 @@ function Asset({ geometry: incomingGeo }) {
|
||||
}
|
||||
|
||||
let debounceTimeoutId
|
||||
function Controls({ onCameraChange, onDragStart }) {
|
||||
const controls = useRef()
|
||||
const { camera, gl } = useThree()
|
||||
function Controls({ onCameraChange, onDragStart, onInit }) {
|
||||
const controls = useRef<any>()
|
||||
const threeInstance = useThree()
|
||||
const { camera, gl } = threeInstance
|
||||
useEffect(() => {
|
||||
onInit(threeInstance)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
// init camera position
|
||||
camera.position.x = 200
|
||||
@@ -49,7 +50,7 @@ function Controls({ onCameraChange, onDragStart }) {
|
||||
// in Three.js Y is the vertical axis (Z for openscad)
|
||||
camera.rotation._order = 'YXZ'
|
||||
const getRotations = () => {
|
||||
const { x, y, z } = camera.rotation
|
||||
const { x, y, z } = camera?.rotation || {}
|
||||
const rad2Deg = 180 / Math.PI
|
||||
const scadX = (x + Math.PI / 2) * rad2Deg
|
||||
const scadZ = y * rad2Deg
|
||||
@@ -100,8 +101,8 @@ function Controls({ onCameraChange, onDragStart }) {
|
||||
onDragStart()
|
||||
clearTimeout(debounceTimeoutId)
|
||||
}
|
||||
controls.current.addEventListener('end', dragCallback)
|
||||
controls.current.addEventListener('start', dragStart)
|
||||
controls?.current?.addEventListener('end', dragCallback)
|
||||
controls?.current?.addEventListener('start', dragStart)
|
||||
const oldCurrent = controls.current
|
||||
dragCallback()
|
||||
return () => {
|
||||
@@ -141,11 +142,16 @@ function Sphere(props) {
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
const IdeViewer = ({ Loading }) => {
|
||||
const { state, thunkDispatch } = useIdeContext()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [image, setImage] = useState()
|
||||
|
||||
const onInit = (threeInstance) => {
|
||||
thunkDispatch({ type: 'setThreeInstance', payload: threeInstance })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setImage(state.objectData?.type === 'png' && state.objectData?.data)
|
||||
setIsDragging(false)
|
||||
@@ -164,7 +170,12 @@ const IdeViewer = ({ Loading }) => {
|
||||
isDragging ? 'opacity-25' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img alt="code-cad preview" src={image} className="h-full w-full" />
|
||||
<img
|
||||
alt="code-cad preview"
|
||||
id="special"
|
||||
src={URL.createObjectURL(image)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
|
||||
@@ -178,6 +189,7 @@ const IdeViewer = ({ Loading }) => {
|
||||
<Canvas>
|
||||
<Controls
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onInit={onInit}
|
||||
onCameraChange={(camera) => {
|
||||
thunkDispatch({
|
||||
type: 'updateCamera',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import IdeContainer from 'src/components/IdeContainer/IdeContainer'
|
||||
import { useRender } from './useRender'
|
||||
import OutBound from 'src/components/OutBound/OutBound'
|
||||
@@ -6,12 +6,25 @@ import IdeSideBar from 'src/components/IdeSideBar/IdeSideBar'
|
||||
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
import { useIdeInit } from 'src/components/EncodedUrl/helpers'
|
||||
import type { Project } from 'src/components/IdeProjectCell/IdeProjectCell'
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
|
||||
|
||||
const IdeToolbarNew = ({ cadPackage }) => {
|
||||
interface Props {
|
||||
cadPackage: string
|
||||
}
|
||||
|
||||
const IdeWrapper = ({ cadPackage }: Props) => {
|
||||
const [shouldShowConstructionMessage, setShouldShowConstructionMessage] =
|
||||
useState(true)
|
||||
const { state, project } = useIdeContext()
|
||||
const handleRender = useRender()
|
||||
useIdeInit(cadPackage)
|
||||
const saveCode = useSaveCode()
|
||||
const onRender = () => {
|
||||
handleRender()
|
||||
saveCode({ code: state.code })
|
||||
}
|
||||
useIdeInit(cadPackage, project?.code || state?.code)
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
@@ -20,7 +33,7 @@ const IdeToolbarNew = ({ cadPackage }) => {
|
||||
</div>
|
||||
<div className="h-full flex flex-grow flex-col">
|
||||
<nav className="flex">
|
||||
<IdeHeader handleRender={handleRender} />
|
||||
<IdeHeader handleRender={onRender} />
|
||||
</nav>
|
||||
{shouldShowConstructionMessage && (
|
||||
<div className="py-1 md:py-2 bg-pink-200 flex">
|
||||
@@ -48,4 +61,4 @@ const IdeToolbarNew = ({ cadPackage }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeToolbarNew
|
||||
export default IdeWrapper
|
||||
|
||||
27
app/web/src/components/IdeWrapper/useSaveCode.ts
Normal file
27
app/web/src/components/IdeWrapper/useSaveCode.ts
Normal 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 } })
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const LandingSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto px-2">
|
||||
{/* <h2 className="text-indigo-700 text-5xl font-roboto my-16 tracking-widest font-light">
|
||||
<h2 className="text-indigo-700 text-5xl font-roboto my-16 tracking-widest font-light">
|
||||
What's the potential of Code-CAD?
|
||||
</h2>
|
||||
<MarketingPoint
|
||||
@@ -140,9 +140,9 @@ const LandingSection = () => {
|
||||
over the next 10 years. As coders proliferate, so will the number of
|
||||
areas in which they operate, including CAD.
|
||||
</p>
|
||||
</MarketingPoint> */}
|
||||
</MarketingPoint>
|
||||
</div>
|
||||
{/* <div className="w-3/4 mx-auto h-px bg-pink-400 mt-32" /> */}
|
||||
<div className="w-3/4 mx-auto h-px bg-pink-400 mt-32" />
|
||||
<div className="mt-24">
|
||||
<p className="text-center text-pink-400 max-w-xl text-2xl mx-auto font-medium">
|
||||
CadHub is a space to share cad projects and it’s our gift to the
|
||||
@@ -164,13 +164,13 @@ const LandingSection = () => {
|
||||
>
|
||||
CadQuery
|
||||
</OutBound>{' '}
|
||||
{/* with more{' '}
|
||||
with more{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/Irev-Dev/cadhub/discussions/212"
|
||||
>
|
||||
features planned
|
||||
</OutBound> */}
|
||||
</OutBound>
|
||||
.
|
||||
</p>
|
||||
<p className="text-2xl font-medium text-gray-600 px-8 pb-8">
|
||||
@@ -183,7 +183,7 @@ const LandingSection = () => {
|
||||
</OutBound>{' '}
|
||||
or
|
||||
</p>
|
||||
<Link to={routes.devIde({ cadPackage: 'openScad' })}>
|
||||
<Link to={routes.devIde({ cadPackage: 'openscad' })}>
|
||||
<div className="bg-texture bg-purple-800 text-center w-full py-6 rounded-b-md border border-indigo-300 border-opacity-0 hover:border-opacity-100 hover:shadow-xl">
|
||||
<span className="font-bold text-2xl text-indigo-200">
|
||||
Start Hacking Now
|
||||
@@ -201,18 +201,6 @@ const LandingSection = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mb-32 max-w-xl mx-auto text-gray-500 pr-6">
|
||||
caveat; the following projects are based on a project we're depricating
|
||||
support for, it's a{' '}
|
||||
<OutBound
|
||||
className="text-gray-600 underline"
|
||||
to="https://github.com/Irev-Dev/cadhub/discussions/261"
|
||||
>
|
||||
long story
|
||||
</OutBound>
|
||||
, though rest-assured saving projects with OpenSCAD and CadQuery will be
|
||||
available soon
|
||||
</div>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
|
||||
@@ -6,30 +6,24 @@ const NavPlusButton: React.FC = () => {
|
||||
return (
|
||||
<Popover className="relative outline-none w-full h-full">
|
||||
<Popover.Button className="h-full w-full outline-none">
|
||||
<Svg name="plus" className="text-indigo-300" />
|
||||
<Svg name="plus" className="text-gray-200" />
|
||||
</Popover.Button>
|
||||
|
||||
<Popover.Panel className="absolute z-10">
|
||||
<Popover.Panel className="absolute z-10 right-0">
|
||||
<ul className="bg-gray-200 mt-4 rounded shadow-md overflow-hidden">
|
||||
{[
|
||||
{
|
||||
name: 'OpenSCAD',
|
||||
sub: 'beta',
|
||||
ideType: 'openScad',
|
||||
ideType: 'openscad',
|
||||
},
|
||||
{ name: 'CadQuery', sub: 'beta', ideType: 'cadQuery' },
|
||||
{ name: 'CadQuery', sub: 'beta', ideType: 'cadquery' },
|
||||
].map(({ name, sub, ideType }) => (
|
||||
<li
|
||||
key={name}
|
||||
className="px-4 py-2 hover:bg-gray-400 text-gray-800"
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
name === 'CascadeStudio'
|
||||
? routes.draftPart()
|
||||
: routes.devIde({ cadPackage: ideType })
|
||||
}
|
||||
>
|
||||
<Link to={routes.draftProject({ cadPackage: ideType })}>
|
||||
<div>{name}</div>
|
||||
<div className="text-xs text-gray-600 font-light">{sub}</div>
|
||||
</Link>
|
||||
|
||||
@@ -2,23 +2,34 @@ import { useContext } from 'react'
|
||||
import { MosaicWindowContext } from 'react-mosaic-component'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
|
||||
const PanelToolbar = ({ panelName }: { panelName: string }) => {
|
||||
const PanelToolbar = ({
|
||||
panelName,
|
||||
showTopGradient,
|
||||
}: {
|
||||
panelName: string
|
||||
showTopGradient?: boolean
|
||||
}) => {
|
||||
const { mosaicWindowActions } = useContext(MosaicWindowContext)
|
||||
return (
|
||||
<div className="absolute top-0 right-0 flex items-center h-9">
|
||||
<button
|
||||
className="bg-ch-gray-760 text-ch-gray-300 px-3 rounded-bl-lg h-full cursor-not-allowed"
|
||||
aria-label={`${panelName} settings`}
|
||||
disabled
|
||||
>
|
||||
<Svg name="gear" className="w-7 p-px" />
|
||||
</button>
|
||||
{mosaicWindowActions.connectDragSource(
|
||||
<div className=" text-ch-gray-760 bg-ch-gray-300 cursor-grab px-2 h-full flex items-center">
|
||||
<Svg name="drag-grid" className="w-4 p-px" />
|
||||
</div>
|
||||
<>
|
||||
{showTopGradient && (
|
||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-ch-gray-800 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 flex items-center h-9">
|
||||
<button
|
||||
className="bg-ch-gray-760 text-ch-gray-300 px-3 rounded-bl-lg h-full cursor-not-allowed"
|
||||
aria-label={`${panelName} settings`}
|
||||
disabled
|
||||
>
|
||||
<Svg name="gear" className="w-7 p-px" />
|
||||
</button>
|
||||
{mosaicWindowActions.connectDragSource(
|
||||
<div className=" text-ch-gray-760 bg-ch-gray-300 cursor-grab px-2 h-full flex items-center">
|
||||
<Svg name="drag-grid" className="w-4 p-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
import PartProfile from './PartProfile'
|
||||
|
||||
export const generated = () => {
|
||||
return <PartProfile />
|
||||
}
|
||||
|
||||
export default { title: 'Components/PartProfile' }
|
||||
@@ -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' }
|
||||
@@ -0,0 +1,7 @@
|
||||
import ProfileSlashLogin from './ProfileSlashLogin'
|
||||
|
||||
export const generated = () => {
|
||||
return <ProfileSlashLogin />
|
||||
}
|
||||
|
||||
export default { title: 'Components/ProfileSlashLogin' }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render } from '@redwoodjs/testing'
|
||||
|
||||
import ProfileSlashLogin from './ProfileSlashLogin'
|
||||
|
||||
describe('ProfileSlashLogin', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<ProfileSlashLogin />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
114
app/web/src/components/ProfileSlashLogin/ProfileSlashLogin.tsx
Normal file
114
app/web/src/components/ProfileSlashLogin/ProfileSlashLogin.tsx
Normal 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
|
||||
22
app/web/src/components/ProfileViewer/ProfileViewer.tsx
Normal file
22
app/web/src/components/ProfileViewer/ProfileViewer.tsx
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
part: {
|
||||
project: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loading, Empty, Failure, Success } from './IdePartCell'
|
||||
import { standard } from './IdePartCell.mock'
|
||||
import { Loading, Empty, Failure, Success } from './ProjectCell'
|
||||
import { standard } from './ProjectCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
@@ -17,4 +17,4 @@ export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/IdePartCell' }
|
||||
export default { title: 'Cells/ProjectCell' }
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './IdePartCell'
|
||||
import { standard } from './IdePartCell.mock'
|
||||
import { Loading, Empty, Failure, Success } from './ProjectCell'
|
||||
import { standard } from './ProjectCell.mock'
|
||||
|
||||
describe('IdePartCell', () => {
|
||||
describe('ProjectCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
@@ -20,7 +20,7 @@ describe('IdePartCell', () => {
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success idePart={standard().idePart} />)
|
||||
render(<Success project={standard().project} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,22 +3,22 @@ import { toast } from '@redwoodjs/web/toast'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import { useAuth } from '@redwoodjs/auth'
|
||||
|
||||
import PartProfile from 'src/components/PartProfile'
|
||||
import { QUERY as PART_REACTION_QUERY } from 'src/components/PartReactionsCell/PartReactionsCell'
|
||||
import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile'
|
||||
import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FIND_PART_BY_USERNAME_TITLE(
|
||||
query FIND_PROJECT_BY_USERNAME_TITLE(
|
||||
$userName: String!
|
||||
$partTitle: String
|
||||
$projectTitle: String
|
||||
$currentUserId: String
|
||||
) {
|
||||
userPart: userName(userName: $userName) {
|
||||
userProject: userName(userName: $userName) {
|
||||
id
|
||||
name
|
||||
userName
|
||||
bio
|
||||
image
|
||||
Part(partTitle: $partTitle) {
|
||||
Project(projectTitle: $projectTitle) {
|
||||
id
|
||||
title
|
||||
description
|
||||
@@ -27,6 +27,7 @@ export const QUERY = gql`
|
||||
createdAt
|
||||
updatedAt
|
||||
userId
|
||||
cadPackage
|
||||
Reaction {
|
||||
emote
|
||||
}
|
||||
@@ -36,6 +37,7 @@ export const QUERY = gql`
|
||||
Comment {
|
||||
id
|
||||
text
|
||||
createdAt
|
||||
user {
|
||||
userName
|
||||
image
|
||||
@@ -46,9 +48,9 @@ export const QUERY = gql`
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_PART_MUTATION = gql`
|
||||
mutation UpdatePartMutation($id: String!, $input: UpdatePartInput!) {
|
||||
updatePart: updatePart(id: $id, input: $input) {
|
||||
const UPDATE_PROJECT_MUTATION = gql`
|
||||
mutation UpdateProjectMutation($id: String!, $input: UpdateProjectInput!) {
|
||||
updateProject: updateProject(id: $id, input: $input) {
|
||||
id
|
||||
title
|
||||
description
|
||||
@@ -65,9 +67,9 @@ const UPDATE_PART_MUTATION = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
const CREATE_PART_MUTATION = gql`
|
||||
mutation CreatePartMutation($input: CreatePartInput!) {
|
||||
createPart(input: $input) {
|
||||
export const CREATE_PROJECT_MUTATION = gql`
|
||||
mutation CreateProjectMutation($input: CreateProjectInput!) {
|
||||
createProject(input: $input) {
|
||||
id
|
||||
title
|
||||
user {
|
||||
@@ -78,8 +80,8 @@ const CREATE_PART_MUTATION = gql`
|
||||
}
|
||||
`
|
||||
const TOGGLE_REACTION_MUTATION = gql`
|
||||
mutation ToggleReactionMutation($input: TogglePartReactionInput!) {
|
||||
togglePartReaction(input: $input) {
|
||||
mutation ToggleReactionMutation($input: ToggleProjectReactionInput!) {
|
||||
toggleProjectReaction(input: $input) {
|
||||
id
|
||||
emote
|
||||
}
|
||||
@@ -93,9 +95,9 @@ const CREATE_COMMENT_MUTATION = gql`
|
||||
}
|
||||
}
|
||||
`
|
||||
const DELETE_PART_MUTATION = gql`
|
||||
mutation DeletePartMutation($id: String!) {
|
||||
deletePart(id: $id) {
|
||||
const DELETE_PROJECT_MUTATION = gql`
|
||||
mutation DeleteProjectMutation($id: String!) {
|
||||
deleteProject(id: $id) {
|
||||
id
|
||||
title
|
||||
user {
|
||||
@@ -112,55 +114,63 @@ export const Empty = () => <div className="h-full">Empty</div>
|
||||
|
||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||
|
||||
export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
|
||||
export const Success = ({
|
||||
userProject,
|
||||
variables: { isEditable },
|
||||
refetch,
|
||||
}) => {
|
||||
const { currentUser } = useAuth()
|
||||
const [updatePart, { loading, error }] = useMutation(UPDATE_PART_MUTATION, {
|
||||
onCompleted: ({ updatePart }) => {
|
||||
const [updateProject, { loading, error }] = useMutation(
|
||||
UPDATE_PROJECT_MUTATION,
|
||||
{
|
||||
onCompleted: ({ updateProject }) => {
|
||||
navigate(
|
||||
routes.project({
|
||||
userName: updateProject.user.userName,
|
||||
projectTitle: updateProject.title,
|
||||
})
|
||||
)
|
||||
toast.success('Project updated.')
|
||||
},
|
||||
}
|
||||
)
|
||||
const [createProject] = useMutation(CREATE_PROJECT_MUTATION, {
|
||||
onCompleted: ({ createProject }) => {
|
||||
navigate(
|
||||
routes.part({
|
||||
userName: updatePart.user.userName,
|
||||
partTitle: updatePart.title,
|
||||
routes.project({
|
||||
userName: createProject?.user?.userName,
|
||||
projectTitle: createProject?.title,
|
||||
})
|
||||
)
|
||||
toast.success('Part updated.')
|
||||
},
|
||||
})
|
||||
const [createPart] = useMutation(CREATE_PART_MUTATION, {
|
||||
onCompleted: ({ createPart }) => {
|
||||
navigate(
|
||||
routes.part({
|
||||
userName: createPart?.user?.userName,
|
||||
partTitle: createPart?.title,
|
||||
})
|
||||
)
|
||||
toast.success('Part Created.')
|
||||
toast.success('Project Created.')
|
||||
},
|
||||
})
|
||||
const onSave = async (id, input) => {
|
||||
if (!id) {
|
||||
await createPart({ variables: { input } })
|
||||
await createProject({ variables: { input } })
|
||||
} else {
|
||||
await updatePart({ variables: { id, input } })
|
||||
await updateProject({ variables: { id, input } })
|
||||
}
|
||||
refetch()
|
||||
}
|
||||
const [deletePart] = useMutation(DELETE_PART_MUTATION, {
|
||||
onCompleted: ({ deletePart }) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||
onCompleted: ({ deleteProject }) => {
|
||||
navigate(routes.home())
|
||||
toast.success('Part deleted.')
|
||||
toast.success('Project deleted.')
|
||||
},
|
||||
})
|
||||
|
||||
const onDelete = () => {
|
||||
userPart?.Part?.id && deletePart({ variables: { id: userPart?.Part?.id } })
|
||||
userProject?.Project?.id &&
|
||||
deleteProject({ variables: { id: userProject?.Project?.id } })
|
||||
}
|
||||
|
||||
const [toggleReaction] = useMutation(TOGGLE_REACTION_MUTATION, {
|
||||
onCompleted: () => refetch(),
|
||||
refetchQueries: [
|
||||
{
|
||||
query: PART_REACTION_QUERY,
|
||||
variables: { partId: userPart?.Part?.id },
|
||||
query: PROJECT_REACTION_QUERY,
|
||||
variables: { projectId: userProject?.Project?.id },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -170,7 +180,7 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
|
||||
input: {
|
||||
emote,
|
||||
userId: currentUser.sub,
|
||||
partId: userPart?.Part?.id,
|
||||
projectId: userProject?.Project?.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -184,14 +194,14 @@ export const Success = ({ userPart, variables: { isEditable }, refetch }) => {
|
||||
input: {
|
||||
text,
|
||||
userId: currentUser.sub,
|
||||
partId: userPart?.Part?.id,
|
||||
projectId: userProject?.Project?.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PartProfile
|
||||
userPart={userPart}
|
||||
<ProjectProfile
|
||||
userProject={userProject}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
loading={loading}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
Submit,
|
||||
} from '@redwoodjs/forms'
|
||||
|
||||
const PartForm = (props) => {
|
||||
const ProjectForm = (props) => {
|
||||
const onSubmit = (data) => {
|
||||
props.onSave(data, props?.part?.id)
|
||||
props.onSave(data, props?.project?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -31,7 +31,7 @@ const PartForm = (props) => {
|
||||
</Label>
|
||||
<TextField
|
||||
name="title"
|
||||
defaultValue={props.part?.title}
|
||||
defaultValue={props.project?.title}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
@@ -47,7 +47,7 @@ const PartForm = (props) => {
|
||||
</Label>
|
||||
<TextField
|
||||
name="description"
|
||||
defaultValue={props.part?.description}
|
||||
defaultValue={props.project?.description}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
@@ -63,7 +63,7 @@ const PartForm = (props) => {
|
||||
</Label>
|
||||
<TextField
|
||||
name="code"
|
||||
defaultValue={props.part?.code}
|
||||
defaultValue={props.project?.code}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
@@ -79,7 +79,7 @@ const PartForm = (props) => {
|
||||
</Label>
|
||||
<TextField
|
||||
name="mainImage"
|
||||
defaultValue={props.part?.mainImage}
|
||||
defaultValue={props.project?.mainImage}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
@@ -95,7 +95,7 @@ const PartForm = (props) => {
|
||||
</Label>
|
||||
<TextField
|
||||
name="userId"
|
||||
defaultValue={props.part?.userId}
|
||||
defaultValue={props.project?.userId}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
@@ -112,4 +112,4 @@ const PartForm = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default PartForm
|
||||
export default ProjectForm
|
||||
293
app/web/src/components/ProjectProfile/ProjectProfile.tsx
Normal file
293
app/web/src/components/ProjectProfile/ProjectProfile.tsx
Normal 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
|
||||
@@ -3,9 +3,9 @@ import Tab from '@material-ui/core/Tab'
|
||||
import Tabs from '@material-ui/core/Tabs'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { countEmotes } from 'src/helpers/emote'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
import ImageUploader from 'src/components/ImageUploader/ImageUploader'
|
||||
|
||||
const PartReactions = ({ reactions }) => {
|
||||
const ProjectReactions = ({ reactions }) => {
|
||||
const emotes = countEmotes(reactions)
|
||||
const [tab, setTab] = useState(0)
|
||||
const onTabChange = (_, newValue) => {
|
||||
@@ -36,17 +36,17 @@ const PartReactions = ({ reactions }) => {
|
||||
.filter((reaction) =>
|
||||
tab === 0 ? true : reaction.emote === emotes[tab - 1].emoji
|
||||
)
|
||||
.map((reactionPart, i) => (
|
||||
.map((reactionProject, i) => (
|
||||
<li
|
||||
className="flex flex-row justify-between p-3 items-center"
|
||||
key={`${reactionPart.emote}-${i}}`}
|
||||
key={`${reactionProject.emote}-${i}}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow flex-shrink-0">
|
||||
<ImageUploader
|
||||
className=""
|
||||
aspectRatio={1}
|
||||
imageUrl={reactionPart.user?.image}
|
||||
imageUrl={reactionProject.user?.image}
|
||||
width={50}
|
||||
/>
|
||||
</div>
|
||||
@@ -54,16 +54,16 @@ const PartReactions = ({ reactions }) => {
|
||||
<div className="text-gray-800 font-normal text-md mb-1">
|
||||
<Link
|
||||
to={routes.user({
|
||||
userName: reactionPart.user?.userName,
|
||||
userName: reactionProject.user?.userName,
|
||||
})}
|
||||
>
|
||||
{reactionPart.user?.userName}
|
||||
{reactionProject.user?.userName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>{reactionPart.emote}</span>
|
||||
<span>{reactionProject.emote}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -72,4 +72,4 @@ const PartReactions = ({ reactions }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default PartReactions
|
||||
export default ProjectReactions
|
||||
@@ -1,6 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
partsOfUser: {
|
||||
projectReactions: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
|
||||
import { standard } from './PartReactionsCell.mock'
|
||||
import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
|
||||
import { standard } from './ProjectReactionsCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
@@ -17,4 +17,4 @@ export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/PartReactionsCell' }
|
||||
export default { title: 'Cells/ProjectReactionsCell' }
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartCell'
|
||||
import { standard } from './PartCell.mock'
|
||||
import { Loading, Empty, Failure, Success } from './ProjectReactionsCell'
|
||||
import { standard } from './ProjectReactionsCell.mock'
|
||||
|
||||
describe('PartCell', () => {
|
||||
describe('ProjectReactionsCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
@@ -20,7 +20,7 @@ describe('PartCell', () => {
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success part={standard().part} />)
|
||||
render(<Success projectReactions={standard().projectReactions} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import PartReactions from 'src/components/PartReactions'
|
||||
import ProjectReactions from 'src/components/ProjectReactions/ProjectReactions'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PartReactionsQuery($partId: String!) {
|
||||
partReactionsByPartId(partId: $partId) {
|
||||
query ProjectReactionsQuery($projectId: String!) {
|
||||
projectReactionsByProjectId(projectId: $projectId) {
|
||||
id
|
||||
emote
|
||||
user {
|
||||
@@ -19,12 +19,12 @@ export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="text-center py-8 font-roboto text-gray-700">
|
||||
No reactions to this part yet 😕
|
||||
No reactions to this project yet 😕
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||
|
||||
export const Success = ({ partReactionsByPartId }) => {
|
||||
return <PartReactions reactions={partReactionsByPartId} />
|
||||
export const Success = ({ projectReactionsByProjectId }) => {
|
||||
return <ProjectReactions reactions={projectReactionsByProjectId} />
|
||||
}
|
||||
@@ -4,19 +4,25 @@ import { Link, routes } from '@redwoodjs/router'
|
||||
import { countEmotes } from 'src/helpers/emote'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
|
||||
const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
|
||||
// temporary filtering parts that don't have images until some kind of search is added and there are more things on the website
|
||||
const ProjectsList = ({
|
||||
projects,
|
||||
shouldFilterProjectsWithoutImage = false,
|
||||
}) => {
|
||||
// temporary filtering projects that don't have images until some kind of search is added and there are more things on the website
|
||||
// it helps avoid the look of the website just being filled with dumby data.
|
||||
// related issue-104
|
||||
const filteredParts = useMemo(
|
||||
const filteredProjects = useMemo(
|
||||
() =>
|
||||
(shouldFilterPartsWithoutImage
|
||||
? parts.filter(({ mainImage }) => mainImage)
|
||||
: [...parts]
|
||||
(shouldFilterProjectsWithoutImage
|
||||
? projects.filter(({ mainImage }) => mainImage)
|
||||
: [...projects]
|
||||
)
|
||||
// sort should probably be done on the service, but the filtering is temp too
|
||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)),
|
||||
[parts, shouldFilterPartsWithoutImage]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
),
|
||||
[projects, shouldFilterProjectsWithoutImage]
|
||||
)
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto mt-8">
|
||||
@@ -24,13 +30,16 @@ const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
|
||||
className="grid gap-x-8 gap-y-12 items-center mx-4 relative"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
|
||||
>
|
||||
{filteredParts.map(({ title, mainImage, user, Reaction }) => (
|
||||
{filteredProjects.map(({ title, mainImage, user, Reaction }) => (
|
||||
<li
|
||||
className="rounded-lg shadow-md hover:shadow-lg mx-px transform hover:-translate-y-px transition-all duration-150"
|
||||
key={`${user?.userName}--${title}`}
|
||||
>
|
||||
<Link
|
||||
to={routes.part({ userName: user?.userName, partTitle: title })}
|
||||
to={routes.project({
|
||||
userName: user?.userName,
|
||||
projectTitle: title,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center p-2 bg-gray-200 border-gray-300 rounded-t-lg border-t border-l border-r">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full border border-indigo-300 shadow">
|
||||
@@ -81,4 +90,4 @@ const PartsList = ({ parts, shouldFilterPartsWithoutImage = false }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default PartsList
|
||||
export default ProjectsList
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Parts from 'src/components/Parts'
|
||||
import Projects from 'src/components/Projects/Projects'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS {
|
||||
parts {
|
||||
query PROJECTS {
|
||||
projects {
|
||||
id
|
||||
title
|
||||
mainImage
|
||||
@@ -26,8 +26,8 @@ export const Loading = () => <div>Loading...</div>
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No parts yet. '}
|
||||
<Link to={routes.draftPart()} className="rw-link">
|
||||
{'No projects yet. '}
|
||||
<Link to={routes.draftProject()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -35,13 +35,13 @@ export const Empty = () => {
|
||||
}
|
||||
|
||||
export const Success = ({
|
||||
parts,
|
||||
variables: { shouldFilterPartsWithoutImage },
|
||||
projects,
|
||||
variables: { shouldFilterProjectsWithoutImage },
|
||||
}) => {
|
||||
return (
|
||||
<Parts
|
||||
parts={parts}
|
||||
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
|
||||
<Projects
|
||||
projects={projects}
|
||||
shouldFilterProjectsWithoutImage={shouldFilterProjectsWithoutImage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
partReactions: {
|
||||
projectsOfUser: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Loading, Empty, Failure, Success } from './PartCell'
|
||||
import { standard } from './PartCell.mock'
|
||||
import { Loading, Empty, Failure, Success } from './ProjectsOfUserCell'
|
||||
import { standard } from './ProjectsOfUserCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
@@ -17,4 +17,4 @@ export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/PartCell' }
|
||||
export default { title: 'Cells/ProjectsOfUserCell' }
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './PartReactionsCell'
|
||||
import { standard } from './PartReactionsCell.mock'
|
||||
import { Loading, Empty, Success } from './ProjectsOfUserCell'
|
||||
import { standard } from './ProjectsOfUserCell.mock'
|
||||
|
||||
describe('PartReactionsCell', () => {
|
||||
describe('ProjectsOfUserCell', () => {
|
||||
test('Loading renders successfully', () => {
|
||||
render(<Loading />)
|
||||
// Use screen.debug() to see output
|
||||
@@ -14,13 +14,8 @@ describe('PartReactionsCell', () => {
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Failure renders successfully', async () => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
expect(screen.getByText(/Oh no/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Success renders successfully', async () => {
|
||||
render(<Success partReactions={standard().partReactions} />)
|
||||
render(<Success projectsOfUser={standard().projectsOfUser} />)
|
||||
expect(screen.getByText(/42/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Parts from 'src/components/Parts'
|
||||
import Projects from 'src/components/Projects/Projects'
|
||||
|
||||
export const QUERY = gql`
|
||||
query PARTS_OF_USER($userName: String!) {
|
||||
parts(userName: $userName) {
|
||||
query PROJECTS_OF_USER($userName: String!) {
|
||||
projects(userName: $userName) {
|
||||
id
|
||||
title
|
||||
mainImage
|
||||
@@ -24,17 +24,17 @@ export const QUERY = gql`
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return <div className="rw-text-center">No parts yet.</div>
|
||||
return <div className="rw-text-center">No projects yet.</div>
|
||||
}
|
||||
|
||||
export const Success = ({
|
||||
parts,
|
||||
variables: { shouldFilterPartsWithoutImage },
|
||||
projects,
|
||||
variables: { shouldFilterProjectsWithoutImage },
|
||||
}) => {
|
||||
return (
|
||||
<Parts
|
||||
parts={parts}
|
||||
shouldFilterPartsWithoutImage={shouldFilterPartsWithoutImage}
|
||||
<Projects
|
||||
projects={projects}
|
||||
shouldFilterProjectsWithoutImage={shouldFilterProjectsWithoutImage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
type SvgNames =
|
||||
| 'arrow-down'
|
||||
| 'arrow'
|
||||
// | 'arrow'
|
||||
| 'arrow-left'
|
||||
| 'big-gear'
|
||||
| 'camera'
|
||||
| 'checkmark'
|
||||
| 'check'
|
||||
| 'chevron-down'
|
||||
| 'dots-vertical'
|
||||
| 'drag-grid'
|
||||
| 'exclamation-circle'
|
||||
| 'favicon'
|
||||
| 'flag'
|
||||
| 'floppy-disk'
|
||||
| 'fork'
|
||||
| 'gear'
|
||||
| 'lightbulb'
|
||||
| 'logout'
|
||||
| 'mac-cmd-key'
|
||||
| 'pencil'
|
||||
| 'pencil-solid'
|
||||
| 'photograph'
|
||||
| 'plus'
|
||||
| 'plus-circle'
|
||||
@@ -84,13 +86,19 @@ const Svg = ({
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
checkmark: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
|
||||
check: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.3438 19.6875C15.7803 19.6875 20.1875 15.2803 20.1875 9.84375C20.1875 4.4072 15.7803 0 10.3438 0C4.9072 0 0.5 4.4072 0.5 9.84375C0.5 15.2803 4.9072 19.6875 10.3438 19.6875ZM15.3321 6.5547C15.6384 6.09517 15.5142 5.4743 15.0547 5.16795C14.5952 4.8616 13.9743 4.98577 13.6679 5.4453L9.34457 11.9304L7.20711 9.79289C6.81658 9.40237 6.18342 9.40237 5.79289 9.79289C5.40237 10.1834 5.40237 10.8166 5.79289 11.2071L8.79289 14.2071C9.00474 14.419 9.3004 14.5247 9.59854 14.4951C9.89667 14.4656 10.1659 14.304 10.3321 14.0547L15.3321 6.5547Z"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={strokeWidth}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
@@ -263,6 +271,16 @@ const Svg = ({
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'floppy-disk': (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.0293 19.0684H16.0293C17.1339 19.0684 18.0293 18.173 18.0293 17.0684V5.88782L13.4281 0.910156H12.0956V6.52001H2.88691V0.910156H2.0293C0.924727 0.910156 0.0292969 1.80559 0.0292969 2.91016V17.0684C0.0292969 18.1729 0.924727 19.0684 2.0293 19.0684ZM2.88691 10.8961H14.9981V17.249H2.88691V10.8961ZM8.02924 2.88855C8.02924 2.32878 8.48302 1.875 9.04278 1.875C9.60255 1.875 10.0563 2.32878 10.0563 2.88854V4.53734C10.0563 5.0971 9.60255 5.55088 9.04278 5.55088C8.48302 5.55088 8.02924 5.0971 8.02924 4.53734V2.88855Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
fork: (
|
||||
<svg
|
||||
viewBox="-3 -3 32 32" // TODO size this properly, or get a better icon
|
||||
@@ -347,6 +365,16 @@ const Svg = ({
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'pencil-solid': (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
),
|
||||
photograph: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -5,9 +5,16 @@ import Editor from 'rich-markdown-editor'
|
||||
import ImageUploader from 'src/components/ImageUploader'
|
||||
import Button from 'src/components/Button'
|
||||
import ProfileTextInput from 'src/components/ProfileTextInput'
|
||||
import PartsOfUser from 'src/components/PartsOfUserCell'
|
||||
import ProjectsOfUser from 'src/components/ProjectsOfUserCell'
|
||||
|
||||
const UserProfile = ({ user, isEditable, loading, onSave, error, parts }) => {
|
||||
const UserProfile = ({
|
||||
user,
|
||||
isEditable,
|
||||
loading,
|
||||
onSave,
|
||||
error,
|
||||
projects,
|
||||
}) => {
|
||||
const { currentUser } = useAuth()
|
||||
const canEdit = currentUser?.sub === user.id
|
||||
const isImageEditable = !isEditable && canEdit // image is editable when not in profile edit mode in order to separate them as it's too hard too to upload an image to cloudinary temporarily until the use saves (and maybe have to clean up) for the time being
|
||||
@@ -95,8 +102,8 @@ const UserProfile = ({ user, isEditable, loading, onSave, error, parts }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-3xl text-gray-500 font-ropa-sans">Parts:</h3>
|
||||
<PartsOfUser userName={user?.userName} />
|
||||
<h3 className="text-3xl text-gray-500 font-ropa-sans">Projects:</h3>
|
||||
<ProjectsOfUser userName={user?.userName} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -52,9 +52,9 @@ export const render = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const openScad = {
|
||||
const openscad = {
|
||||
render,
|
||||
// more functions to come
|
||||
}
|
||||
|
||||
export default openScad
|
||||
export default openscad
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import openScad from './openScadController'
|
||||
import cadQuery from './cadQueryController'
|
||||
import openscad from './openScadController'
|
||||
import cadquery from './cadQueryController'
|
||||
|
||||
export const cadPackages = {
|
||||
openScad,
|
||||
cadQuery,
|
||||
openscad,
|
||||
cadquery,
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ export const render = async ({ code, settings }: RenderArgs) => {
|
||||
}
|
||||
const data = await response.json()
|
||||
const type = data.type !== 'stl' ? 'png' : 'geometry'
|
||||
const newData = data.type !== 'stl' ? data.url : stlToGeometry(data.url)
|
||||
const newData =
|
||||
data.type !== 'stl'
|
||||
? fetch(data.url).then((a) => a.blob())
|
||||
: stlToGeometry(data.url)
|
||||
return createHealthyResponse({
|
||||
type,
|
||||
data: await newData,
|
||||
@@ -102,12 +105,12 @@ export const stl = async ({ code, settings }: RenderArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openScad = {
|
||||
const openscad = {
|
||||
render,
|
||||
stl,
|
||||
}
|
||||
|
||||
export default openScad
|
||||
export default openscad
|
||||
|
||||
function cleanError(error) {
|
||||
return error.replace(/["|']\/tmp\/.+\/main.scad["|']/g, "'main.scad'")
|
||||
|
||||
@@ -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()
|
||||
@@ -1,7 +1,5 @@
|
||||
// TODO: create a tidy util for uploading to Cloudinary and returning the public ID
|
||||
import axios from 'axios'
|
||||
import { threejsViewport } from 'src/cascade/js/MainPage/CascadeState'
|
||||
import CascadeController from 'src/helpers/cascadeController'
|
||||
|
||||
const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images'
|
||||
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload'
|
||||
@@ -21,12 +19,3 @@ export async function uploadToCloudinary(imgBlob) {
|
||||
console.error('ERROR', e)
|
||||
}
|
||||
}
|
||||
|
||||
export const captureAndSaveViewport = async () => {
|
||||
// Get the canvas image as a Data URL
|
||||
const imgBlob = await CascadeController.capture(threejsViewport.environment)
|
||||
|
||||
// Upload the image to Cloudinary
|
||||
const { public_id: publicId } = await uploadToCloudinary(imgBlob)
|
||||
return { publicId, imgBlob }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user