diff --git a/app/api/db/migrations/20210925001652_add_project_forking/migration.sql b/app/api/db/migrations/20210925001652_add_project_forking/migration.sql new file mode 100644 index 0000000..a5c9a3e --- /dev/null +++ b/app/api/db/migrations/20210925001652_add_project_forking/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "forkedFromId" TEXT; + +-- AddForeignKey +ALTER TABLE "Project" ADD FOREIGN KEY ("forkedFromId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/app/api/db/schema.prisma b/app/api/db/schema.prisma index 98cdfd0..d91dda8 100644 --- a/app/api/db/schema.prisma +++ b/app/api/db/schema.prisma @@ -53,9 +53,12 @@ model Project { deleted Boolean @default(false) cadPackage CadPackage @default(openscad) socialCard SocialCard? + forkedFromId String? + forkedFrom Project? @relation("Fork", fields: [forkedFromId], references: [id]) - Comment Comment[] - Reaction ProjectReaction[] + childForks Project[] @relation("Fork") + Comment Comment[] + Reaction ProjectReaction[] @@unique([title, userId]) } diff --git a/app/api/src/graphql/projects.sdl.ts b/app/api/src/graphql/projects.sdl.ts index 7faa41c..507d1db 100644 --- a/app/api/src/graphql/projects.sdl.ts +++ b/app/api/src/graphql/projects.sdl.ts @@ -14,6 +14,9 @@ export const schema = gql` socialCard: SocialCard Comment: [Comment]! Reaction(userId: String): [ProjectReaction]! + forkedFromId: String + forkedFrom: Project + childForks: [Project]! } enum CadPackage { @@ -37,6 +40,11 @@ export const schema = gql` cadPackage: CadPackage! } + input ForkProjectInput { + userId: String! + forkedFromId: String + } + input UpdateProjectInput { title: String description: String @@ -47,7 +55,7 @@ export const schema = gql` type Mutation { createProject(input: CreateProjectInput!): Project! - forkProject(input: CreateProjectInput!): Project! + forkProject(input: ForkProjectInput!): Project! updateProject(id: String!, input: UpdateProjectInput!): Project! updateProjectImages( id: String! diff --git a/app/api/src/services/projects/projects.ts b/app/api/src/services/projects/projects.ts index 2f0c806..bb83798 100644 --- a/app/api/src/services/projects/projects.ts +++ b/app/api/src/services/projects/projects.ts @@ -78,13 +78,27 @@ export const createProject = async ({ input }: CreateProjectArgs) => { } 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 + requireAuth() + const projectData = await db.project.findUnique({ + where: { + id: input.forkedFromId, + }, + }) const isUniqueCallback = isUniqueProjectTitle(input.userId) - const title = await generateUniqueString(input.title, isUniqueCallback) - // TODO change the description to `forked from userName/projectName ${rest of description}` + let title = projectData.title + + title = await generateUniqueString(title, isUniqueCallback) + + const { code, description, cadPackage } = projectData + return db.project.create({ - data: foreignKeyReplacement({ ...input, title }), + data: foreignKeyReplacement({ + ...input, + title, + code, + description, + cadPackage, + }), }) } @@ -252,6 +266,11 @@ export const deleteProject = async ({ id }: Prisma.ProjectWhereUniqueInput) => { } export const Project = { + forkedFrom: (_obj, { root }) => + root.forkedFromId && + db.project.findUnique({ where: { id: root.forkedFromId } }), + childForks: (_obj, { root }) => + db.project.findMany({ where: { forkedFromId: root.id } }), user: (_obj, { root }: ResolverArgs>) => db.project.findUnique({ where: { id: root.id } }).user(), socialCard: (_obj, { root }: ResolverArgs>) => diff --git a/app/web/src/components/IdeHeader/IdeHeader.tsx b/app/web/src/components/IdeHeader/IdeHeader.tsx index 7ca76a6..ecc4863 100644 --- a/app/web/src/components/IdeHeader/IdeHeader.tsx +++ b/app/web/src/components/IdeHeader/IdeHeader.tsx @@ -1,17 +1,30 @@ import { useAuth } from '@redwoodjs/auth' +import { useMutation } from '@redwoodjs/web' 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 { toast } from '@redwoodjs/web/toast' +import CaptureButton from 'src/components/CaptureButton/CaptureButton' +import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import Gravatar from 'src/components/Gravatar/Gravatar' import EditableProjectTitle from 'src/components/EditableProjecTitle/EditableProjecTitle' -import CaptureButton from 'src/components/CaptureButton/CaptureButton' -import { ReactNode } from 'react' + +const FORK_PROJECT_MUTATION = gql` + mutation ForkProjectMutation($input: ForkProjectInput!) { + forkProject(input: $input) { + id + title + user { + id + userName + } + } + } +` const TopButton = ({ onClick, @@ -38,180 +51,170 @@ const TopButton = ({ ) } -interface IdeHeaderProps { - handleRender: () => void - projectTitle?: string - projectOwner?: string - projectOwnerId?: string - projectOwnerImage?: string - projectId?: string - children?: ReactNode -} - -const IdeHeader = ({ +export default function IdeHeader({ handleRender, - projectOwner, - projectTitle, - projectOwnerImage, - projectId, - projectOwnerId, - children, -}: IdeHeaderProps) => { + context, +}: { + handleRender?: () => void + context: 'ide' | 'profile' +}) { const { currentUser } = useAuth() const { project } = useIdeContext() + + const isProfile = context === 'profile' const canEdit = - (currentUser && - currentUser?.sub === (project?.user?.id || projectOwnerId)) || - currentUser?.roles.includes('admin') - const _projectId = projectId || project?.id - const _projectOwner = project?.user?.userName || projectOwner + (currentUser && currentUser?.sub === project?.user?.id) || + currentUser?.roles?.includes('admin') + const projectOwner = project?.user?.userName - return ( -
-
-
- - - -
- {_projectId && ( - <> - - - - {_projectOwner} - - - - - )} -
-
- {!children ? ( - - ) : ( - children - )} - {/* Fork */} -
- -
- -
-
- ) -} + const [createFork] = useMutation(FORK_PROJECT_MUTATION, { + onCompleted: ({ forkProject }) => { + const params = { + userName: forkProject?.user?.userName, + projectTitle: forkProject?.title, + } + navigate(!isProfile ? routes.ide(params) : routes.project(params)) + }, + }) + const handleFork = () => { + const prom = createFork({ + variables: { + input: { + userId: currentUser.sub, + forkedFromId: project?.id, + }, + }, + }) + toast.promise(prom, { + loading: 'Forking...', + success: Forked successfully!, + error: Problem forking., + }) + } -export default IdeHeader - -function DefaultTopButtons({ - project, - projectTitle, - _projectOwner, - handleRender, - canEdit, -}) { return ( <> - {canEdit && !projectTitle && ( - ( +
+
+ {project?.id && ( + <> + + + + {projectOwner} + + + + + )} +
+
+ {canEdit && !isProfile && ( + ( + + + + )} + /> + )} + {!isProfile && ( - + )} - /> - )} - {!projectTitle && ( - - - - )} - {projectTitle && ( - - navigate(routes.ide({ userName: _projectOwner, projectTitle })) - } - name="Editor" - > - - - )} - - {({ open }) => { - return ( - <> - - - - - - {open && ( - - - - - - - - + {isProfile && ( + + navigate( + routes.ide({ + userName: projectOwner, + projectTitle: project.title, + }) + ) + } + name="Editor" + > + + + )} + + {({ open }) => { + return ( + <> + + + + + + {open && ( + + + + + + + + - - encoded script - external script - - - - )} - - ) - }} - + + encoded script + external script + + + + )} + + ) + }} + + {currentUser?.sub && ( + + + + )} +
+
) } diff --git a/app/web/src/components/IdeWrapper/IdeWrapper.tsx b/app/web/src/components/IdeWrapper/IdeWrapper.tsx index 7b52242..2e7f529 100644 --- a/app/web/src/components/IdeWrapper/IdeWrapper.tsx +++ b/app/web/src/components/IdeWrapper/IdeWrapper.tsx @@ -1,17 +1,17 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import IdeContainer from 'src/components/IdeContainer/IdeContainer' import { useRender } from './useRender' -import OutBound from 'src/components/OutBound/OutBound' import IdeSideBar from 'src/components/IdeSideBar/IdeSideBar' -import IdeHeader from 'src/components/IdeHeader/IdeHeader' -import Svg from 'src/components/Svg/Svg' +import TopNav from 'src/components/TopNav/TopNav' import { useIdeInit } from 'src/components/EncodedUrl/helpers' import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode' import { ShortcutsModalContext } from 'src/components/EditorMenu/AllShortcutsModal' +import IdeHeader from 'src/components/IdeHeader/IdeHeader' +import type { CadPackageType } from 'src/components/CadPackage/CadPackage' interface Props { - cadPackage: string + cadPackage: CadPackageType } const IdeWrapper = ({ cadPackage }: Props) => { @@ -33,7 +33,9 @@ const IdeWrapper = ({ cadPackage }: Props) => {
diff --git a/app/web/src/components/ProjectCard/ProjectCard.tsx b/app/web/src/components/ProjectCard/ProjectCard.tsx index 6652690..2463bcf 100644 --- a/app/web/src/components/ProjectCard/ProjectCard.tsx +++ b/app/web/src/components/ProjectCard/ProjectCard.tsx @@ -6,7 +6,14 @@ import { countEmotes } from 'src/helpers/emote' import ImageUploader from 'src/components/ImageUploader' import { ImageFallback } from '../ImageUploader/ImageUploader' -const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => ( +const ProjectCard = ({ + title, + mainImage, + user, + Reaction, + cadPackage, + childForks, +}) => (
  • ( {countEmotes(Reaction).reduce((prev, { count }) => prev + count, 0)}
  • - 0 + + {childForks?.length || 0}
    diff --git a/app/web/src/components/ProjectCell/ProjectCell.tsx b/app/web/src/components/ProjectCell/ProjectCell.tsx index ec591c0..75fc19b 100644 --- a/app/web/src/components/ProjectCell/ProjectCell.tsx +++ b/app/web/src/components/ProjectCell/ProjectCell.tsx @@ -2,6 +2,8 @@ import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { navigate, routes } from '@redwoodjs/router' import { useAuth } from '@redwoodjs/auth' +import { useIdeState } from 'src/helpers/hooks/useIdeState' +import { IdeContext } from 'src/helpers/hooks/useIdeContext' import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile' import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell' @@ -26,8 +28,14 @@ export const QUERY = gql` mainImage createdAt updatedAt - userId cadPackage + forkedFrom { + id + title + user { + userName + } + } Reaction { emote } @@ -114,26 +122,20 @@ export const Empty = () =>
    Empty
    export const Failure = ({ error }) =>
    Error: {error.message}
    -export const Success = ({ - userProject, - variables: { isEditable }, - refetch, -}) => { +export const Success = ({ userProject, refetch }) => { const { currentUser } = useAuth() - 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 [state, thunkDispatch] = useIdeState() + const [updateProject] = 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( @@ -154,7 +156,7 @@ export const Success = ({ refetch() } const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, { - onCompleted: ({ deleteProject }) => { + onCompleted: () => { navigate(routes.home()) toast.success('Project deleted.') }, @@ -200,15 +202,27 @@ export const Success = ({ }) return ( - + + + ) } diff --git a/app/web/src/components/ProjectProfile/ProjectProfile.tsx b/app/web/src/components/ProjectProfile/ProjectProfile.tsx index 75ad9d4..9fe52dd 100644 --- a/app/web/src/components/ProjectProfile/ProjectProfile.tsx +++ b/app/web/src/components/ProjectProfile/ProjectProfile.tsx @@ -10,6 +10,7 @@ import Button from 'src/components/Button/Button' import ProjectReactionsCell from '../ProjectReactionsCell' import { countEmotes } from 'src/helpers/emote' import { getActiveClasses } from 'get-active-classes' +import TopNav from 'src/components/TopNav/TopNav' import IdeHeader from 'src/components/IdeHeader/IdeHeader' import CadPackage from 'src/components/CadPackage/CadPackage' import Gravatar from 'src/components/Gravatar/Gravatar' @@ -38,6 +39,7 @@ const ProjectProfile = ({ const hasPermissionToEdit = 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(() => { @@ -49,7 +51,7 @@ const ProjectProfile = ({ projectTitle: project?.title, }) ) - }, [currentUser]) + }, [currentUser, project?.title, userProject.userName]) useIdeInit(project?.cadPackage, project?.code, 'viewer') const [newDescription, setNewDescription] = useState(project?.description) const onDescriptionChange = (description) => setNewDescription(description()) @@ -69,14 +71,9 @@ const ProjectProfile = ({ <>
    - {}} - projectTitle={project?.title} - projectOwner={userProject?.userName} - projectOwnerImage={userProject?.image} - projectOwnerId={userProject?.id} - projectId={project?.id} - /> + + +
    @@ -145,6 +142,20 @@ const ProjectProfile = ({ {new Date(project?.updatedAt).toDateString()} + {project.forkedFrom && ( + + + {project.forkedFrom.title} + {' '} + by {project.forkedFrom.user.userName} + + )}
    {filteredProjects.map( - ({ title, mainImage, user, Reaction, cadPackage }, index) => ( + ( + { title, mainImage, user, Reaction, cadPackage, childForks }, + index + ) => ( ) )} diff --git a/app/web/src/components/ProjectsCell/ProjectsCell.tsx b/app/web/src/components/ProjectsCell/ProjectsCell.tsx index 63ee987..4acb694 100644 --- a/app/web/src/components/ProjectsCell/ProjectsCell.tsx +++ b/app/web/src/components/ProjectsCell/ProjectsCell.tsx @@ -9,6 +9,9 @@ export const QUERY = gql` title cadPackage mainImage + childForks { + id + } createdAt updatedAt user { diff --git a/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx b/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx index e77725c..b3a7b22 100644 --- a/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx +++ b/app/web/src/components/ProjectsOfUserCell/ProjectsOfUserCell.tsx @@ -9,6 +9,9 @@ export const QUERY = gql` title mainImage cadPackage + childForks { + id + } createdAt updatedAt user { diff --git a/app/web/src/components/TopNav/TopNav.tsx b/app/web/src/components/TopNav/TopNav.tsx new file mode 100644 index 0000000..22a743d --- /dev/null +++ b/app/web/src/components/TopNav/TopNav.tsx @@ -0,0 +1,31 @@ +import { Link, routes } from '@redwoodjs/router' +import Svg from 'src/components/Svg/Svg' +import NavPlusButton from 'src/components/NavPlusButton' +import ProfileSlashLogin from 'src/components/ProfileSlashLogin' +import { ReactNode } from 'react' +interface IdeHeaderProps { + children?: ReactNode +} + +const TopNav = ({ children }: IdeHeaderProps) => { + return ( +
    +
    +
    + + + +
    +
    + {children} +
    +
    + +
    + +
    +
    + ) +} + +export default TopNav diff --git a/app/web/src/components/UserProfile/UserProfile.tsx b/app/web/src/components/UserProfile/UserProfile.tsx index ae6cfb2..507b3c7 100644 --- a/app/web/src/components/UserProfile/UserProfile.tsx +++ b/app/web/src/components/UserProfile/UserProfile.tsx @@ -1,9 +1,8 @@ import { useEffect, useReducer } from 'react' import { useAuth } from '@redwoodjs/auth' -import { Link, navigate, routes } from '@redwoodjs/router' +import { navigate, routes } from '@redwoodjs/router' import ProjectsOfUser from 'src/components/ProjectsOfUserCell' -import IdeHeader from 'src/components/IdeHeader/IdeHeader' -import Svg from 'src/components/Svg/Svg' +import TopNav from 'src/components/TopNav/TopNav' import { fieldComponents, fieldReducer, @@ -27,13 +26,7 @@ function buildFieldsConfig(fieldsConfig, user, hasPermissionToEdit) { ) } -const UserProfile = ({ - user, - isEditing, - loading, - onSave, - error, -}: UserProfileType) => { +const UserProfile = ({ user, isEditing, onSave }: UserProfileType) => { const { currentUser } = useAuth() const hasPermissionToEdit = currentUser?.sub === user.id useEffect(() => { @@ -60,14 +53,7 @@ const UserProfile = ({ <>
    - {}} - projectOwner={user?.userName} - projectOwnerImage={user?.image} - projectOwnerId={user?.id} - > - - +
    diff --git a/app/web/src/index.css b/app/web/src/index.css index a0cd4de..d1ccb29 100644 --- a/app/web/src/index.css +++ b/app/web/src/index.css @@ -52,6 +52,7 @@ .markdown-overrides div { @apply text-ch-gray-300 bg-transparent; } +.pink-link, .markdown-overrides a, .markdown-overrides div a { @apply text-ch-pink-500 underline bg-transparent; diff --git a/app/web/src/pages/DevIdePage/DevIdePage.tsx b/app/web/src/pages/DevIdePage/DevIdePage.tsx index 0c98170..a39195f 100644 --- a/app/web/src/pages/DevIdePage/DevIdePage.tsx +++ b/app/web/src/pages/DevIdePage/DevIdePage.tsx @@ -4,6 +4,7 @@ import { Toaster } from '@redwoodjs/web/toast' import { useIdeState } from 'src/helpers/hooks/useIdeState' import type { Project } from 'src/components/IdeProjectCell/IdeProjectCell' import { IdeContext } from 'src/helpers/hooks/useIdeContext' +import type { CadPackageType } from 'src/components/CadPackage/CadPackage' interface Props { cadPackage: string @@ -21,7 +22,9 @@ const DevIdePage = ({ cadPackage, project }: Props) => { /> - +
    ) diff --git a/app/web/src/pages/ProjectPage/ProjectPage.tsx b/app/web/src/pages/ProjectPage/ProjectPage.tsx index 4bd6c23..3110a8f 100644 --- a/app/web/src/pages/ProjectPage/ProjectPage.tsx +++ b/app/web/src/pages/ProjectPage/ProjectPage.tsx @@ -2,14 +2,11 @@ import { useAuth } from '@redwoodjs/auth' import ProjectCell from 'src/components/ProjectCell' import Seo from 'src/components/Seo/Seo' -import { useIdeState } from 'src/helpers/hooks/useIdeState' -import { IdeContext } from 'src/helpers/hooks/useIdeContext' import { Toaster } from '@redwoodjs/web/toast' import { makeSocialPublicId } from 'src/helpers/hooks/useUpdateProjectImages' const ProjectPage = ({ userName, projectTitle }) => { const { currentUser } = useAuth() - const [state, thunkDispatch] = useIdeState() const socialImageUrl = `http://res.cloudinary.com/irevdev/image/upload/c_scale,w_1200/v1/CadHub/${makeSocialPublicId( userName, projectTitle @@ -23,13 +20,11 @@ const ProjectPage = ({ userName, projectTitle }) => { lang="en-US" /> - - - + ) }