Add project forking #533
@@ -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;
|
||||||
@@ -53,9 +53,12 @@ model Project {
|
|||||||
deleted Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
cadPackage CadPackage @default(openscad)
|
cadPackage CadPackage @default(openscad)
|
||||||
socialCard SocialCard?
|
socialCard SocialCard?
|
||||||
|
forkedFromId String?
|
||||||
|
forkedFrom Project? @relation("Fork", fields: [forkedFromId], references: [id])
|
||||||
|
|
||||||
Comment Comment[]
|
childForks Project[] @relation("Fork")
|
||||||
Reaction ProjectReaction[]
|
Comment Comment[]
|
||||||
|
Reaction ProjectReaction[]
|
||||||
@@unique([title, userId])
|
@@unique([title, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const schema = gql`
|
|||||||
socialCard: SocialCard
|
socialCard: SocialCard
|
||||||
Comment: [Comment]!
|
Comment: [Comment]!
|
||||||
Reaction(userId: String): [ProjectReaction]!
|
Reaction(userId: String): [ProjectReaction]!
|
||||||
|
forkedFromId: String
|
||||||
|
forkedFrom: Project
|
||||||
|
childForks: [Project]!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CadPackage {
|
enum CadPackage {
|
||||||
@@ -37,6 +40,11 @@ export const schema = gql`
|
|||||||
cadPackage: CadPackage!
|
cadPackage: CadPackage!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ForkProjectInput {
|
||||||
|
userId: String!
|
||||||
|
forkedFromId: String
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateProjectInput {
|
input UpdateProjectInput {
|
||||||
title: String
|
title: String
|
||||||
description: String
|
description: String
|
||||||
@@ -47,7 +55,7 @@ export const schema = gql`
|
|||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
createProject(input: CreateProjectInput!): Project!
|
createProject(input: CreateProjectInput!): Project!
|
||||||
forkProject(input: CreateProjectInput!): Project!
|
forkProject(input: ForkProjectInput!): Project!
|
||||||
updateProject(id: String!, input: UpdateProjectInput!): Project!
|
updateProject(id: String!, input: UpdateProjectInput!): Project!
|
||||||
updateProjectImages(
|
updateProjectImages(
|
||||||
id: String!
|
id: String!
|
||||||
|
|||||||
@@ -78,13 +78,27 @@ export const createProject = async ({ input }: CreateProjectArgs) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const forkProject = async ({ input }) => {
|
export const forkProject = async ({ input }) => {
|
||||||
// Only difference between create and fork project is that fork project will generate a unique title
|
requireAuth()
|
||||||
// (for the user) if there is a conflict
|
const projectData = await db.project.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.forkedFromId,
|
||||||
|
},
|
||||||
|
})
|
||||||
const isUniqueCallback = isUniqueProjectTitle(input.userId)
|
const isUniqueCallback = isUniqueProjectTitle(input.userId)
|
||||||
const title = await generateUniqueString(input.title, isUniqueCallback)
|
let title = projectData.title
|
||||||
// TODO change the description to `forked from userName/projectName ${rest of description}`
|
|
||||||
|
title = await generateUniqueString(title, isUniqueCallback)
|
||||||
|
|
||||||
|
const { code, description, cadPackage } = projectData
|
||||||
|
|
||||||
return db.project.create({
|
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 = {
|
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<ReturnType<typeof project>>) =>
|
user: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
|
||||||
db.project.findUnique({ where: { id: root.id } }).user(),
|
db.project.findUnique({ where: { id: root.id } }).user(),
|
||||||
socialCard: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
|
socialCard: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { useAuth } from '@redwoodjs/auth'
|
import { useAuth } from '@redwoodjs/auth'
|
||||||
|
import { useMutation } from '@redwoodjs/web'
|
||||||
import { Popover } from '@headlessui/react'
|
import { Popover } from '@headlessui/react'
|
||||||
import { Link, navigate, routes } from '@redwoodjs/router'
|
import { Link, navigate, routes } from '@redwoodjs/router'
|
||||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
|
||||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
|
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
|
||||||
|
|
||||||
import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding'
|
import FullScriptEncoding from 'src/components/EncodedUrl/FullScriptEncoding'
|
||||||
import ExternalScript from 'src/components/EncodedUrl/ExternalScript'
|
import ExternalScript from 'src/components/EncodedUrl/ExternalScript'
|
||||||
import Svg from 'src/components/Svg/Svg'
|
import Svg from 'src/components/Svg/Svg'
|
||||||
import NavPlusButton from 'src/components/NavPlusButton'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
import ProfileSlashLogin from 'src/components/ProfileSlashLogin'
|
import CaptureButton from 'src/components/CaptureButton/CaptureButton'
|
||||||
|
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||||
import Gravatar from 'src/components/Gravatar/Gravatar'
|
import Gravatar from 'src/components/Gravatar/Gravatar'
|
||||||
import EditableProjectTitle from 'src/components/EditableProjecTitle/EditableProjecTitle'
|
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 = ({
|
const TopButton = ({
|
||||||
onClick,
|
onClick,
|
||||||
@@ -38,180 +51,170 @@ const TopButton = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IdeHeaderProps {
|
export default function IdeHeader({
|
||||||
handleRender: () => void
|
|
||||||
projectTitle?: string
|
|
||||||
projectOwner?: string
|
|
||||||
projectOwnerId?: string
|
|
||||||
projectOwnerImage?: string
|
|
||||||
projectId?: string
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const IdeHeader = ({
|
|
||||||
handleRender,
|
handleRender,
|
||||||
projectOwner,
|
context,
|
||||||
projectTitle,
|
}: {
|
||||||
projectOwnerImage,
|
handleRender?: () => void
|
||||||
projectId,
|
context: 'ide' | 'profile'
|
||||||
projectOwnerId,
|
}) {
|
||||||
children,
|
|
||||||
}: IdeHeaderProps) => {
|
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
const { project } = useIdeContext()
|
const { project } = useIdeContext()
|
||||||
|
|
||||||
|
const isProfile = context === 'profile'
|
||||||
const canEdit =
|
const canEdit =
|
||||||
(currentUser &&
|
(currentUser && currentUser?.sub === project?.user?.id) ||
|
||||||
currentUser?.sub === (project?.user?.id || projectOwnerId)) ||
|
currentUser?.roles?.includes('admin')
|
||||||
currentUser?.roles.includes('admin')
|
const projectOwner = project?.user?.userName
|
||||||
const _projectId = projectId || project?.id
|
|
||||||
const _projectOwner = project?.user?.userName || projectOwner
|
|
||||||
|
|
||||||
return (
|
const [createFork] = useMutation(FORK_PROJECT_MUTATION, {
|
||||||
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center text-lg">
|
onCompleted: ({ forkProject }) => {
|
||||||
<div className="h-full text-gray-300 flex items-center">
|
const params = {
|
||||||
<div className="w-14 h-16 flex items-center justify-center bg-ch-gray-900">
|
userName: forkProject?.user?.userName,
|
||||||
<Link to={routes.home()}>
|
projectTitle: forkProject?.title,
|
||||||
<Svg className="w-12 p-0.5" name="favicon" />
|
}
|
||||||
</Link>
|
navigate(!isProfile ? routes.ide(params) : routes.project(params))
|
||||||
</div>
|
},
|
||||||
{_projectId && (
|
})
|
||||||
<>
|
const handleFork = () => {
|
||||||
<span className="bg-ch-gray-700 h-full grid grid-flow-col-dense items-center gap-2 px-4">
|
const prom = createFork({
|
||||||
<Gravatar
|
variables: {
|
||||||
image={project?.user?.image || projectOwnerImage}
|
input: {
|
||||||
className="w-10"
|
userId: currentUser.sub,
|
||||||
/>
|
forkedFromId: project?.id,
|
||||||
<Link
|
},
|
||||||
to={routes.user({
|
},
|
||||||
userName: _projectOwner,
|
})
|
||||||
})}
|
toast.promise(prom, {
|
||||||
>
|
loading: 'Forking...',
|
||||||
{_projectOwner}
|
success: <b>Forked successfully!</b>,
|
||||||
</Link>
|
error: <b>Problem forking.</b>,
|
||||||
</span>
|
})
|
||||||
<EditableProjectTitle
|
}
|
||||||
id={_projectId}
|
|
||||||
userName={_projectOwner}
|
|
||||||
projectTitle={project?.title || projectTitle}
|
|
||||||
canEdit={canEdit}
|
|
||||||
shouldRouteToIde={!projectTitle}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-200 grid grid-flow-col-dense gap-4 mr-4 items-center">
|
|
||||||
{!children ? (
|
|
||||||
<DefaultTopButtons
|
|
||||||
project={project}
|
|
||||||
projectTitle={projectTitle}
|
|
||||||
_projectOwner={_projectOwner}
|
|
||||||
handleRender={handleRender}
|
|
||||||
canEdit={canEdit}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
{/* <TopButton>Fork</TopButton> */}
|
|
||||||
<div className="h-8 w-8">
|
|
||||||
<NavPlusButton />
|
|
||||||
</div>
|
|
||||||
<ProfileSlashLogin />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IdeHeader
|
|
||||||
|
|
||||||
function DefaultTopButtons({
|
|
||||||
project,
|
|
||||||
projectTitle,
|
|
||||||
_projectOwner,
|
|
||||||
handleRender,
|
|
||||||
canEdit,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canEdit && !projectTitle && (
|
<div className="flex justify-between flex-grow h-full">
|
||||||
<CaptureButton
|
<div className="flex h-full items-center text-gray-300">
|
||||||
canEdit={canEdit}
|
{project?.id && (
|
||||||
projectTitle={project?.title}
|
<>
|
||||||
userName={project?.user?.userName}
|
<span className="bg-ch-gray-700 h-full grid grid-flow-col-dense items-center gap-2 px-4">
|
||||||
shouldUpdateImage={!project?.mainImage}
|
<Gravatar image={project?.user?.image} className="w-10" />
|
||||||
TheButton={({ onClick }) => (
|
<Link
|
||||||
|
to={routes.user({
|
||||||
|
userName: projectOwner,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{projectOwner}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<EditableProjectTitle
|
||||||
|
id={project?.id}
|
||||||
|
userName={projectOwner}
|
||||||
|
projectTitle={project?.title}
|
||||||
|
canEdit={canEdit}
|
||||||
|
shouldRouteToIde={!isProfile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-flow-col-dense gap-4 items-center mr-4">
|
||||||
|
{canEdit && !isProfile && (
|
||||||
|
<CaptureButton
|
||||||
|
canEdit={canEdit}
|
||||||
|
projectTitle={project?.title}
|
||||||
|
userName={project?.user?.userName}
|
||||||
|
shouldUpdateImage={!project?.mainImage}
|
||||||
|
TheButton={({ onClick }) => (
|
||||||
|
<TopButton
|
||||||
|
onClick={onClick}
|
||||||
|
name="Save Project Image"
|
||||||
|
className=" bg-ch-blue-650 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||||
|
>
|
||||||
|
<Svg name="camera" className="w-6 h-6 text-ch-blue-400" />
|
||||||
|
</TopButton>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isProfile && (
|
||||||
<TopButton
|
<TopButton
|
||||||
onClick={onClick}
|
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||||
name="Save Project Image"
|
onClick={handleRender}
|
||||||
|
|
|||||||
className=" bg-ch-blue-650 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
name={canEdit ? 'Save' : 'Preview'}
|
||||||
>
|
>
|
||||||
<Svg name="camera" className="w-6 h-6 text-ch-blue-400" />
|
<Svg
|
||||||
|
name={canEdit ? 'floppy-disk' : 'photograph'}
|
||||||
|
className="w-6 h-6 text-ch-pink-500"
|
||||||
|
/>
|
||||||
</TopButton>
|
</TopButton>
|
||||||
)}
|
)}
|
||||||
/>
|
{isProfile && (
|
||||||
)}
|
<TopButton
|
||||||
{!projectTitle && (
|
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||||
<TopButton
|
onClick={() =>
|
||||||
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
navigate(
|
||||||
onClick={handleRender}
|
routes.ide({
|
||||||
name={canEdit ? 'Save' : 'Preview'}
|
userName: projectOwner,
|
||||||
>
|
projectTitle: project.title,
|
||||||
<Svg
|
})
|
||||||
name={canEdit ? 'floppy-disk' : 'photograph'}
|
)
|
||||||
className="w-6 h-6 text-ch-pink-500"
|
}
|
||||||
/>
|
name="Editor"
|
||||||
</TopButton>
|
>
|
||||||
)}
|
<Svg name="terminal" className="w-6 h-6 text-ch-pink-500" />
|
||||||
{projectTitle && (
|
</TopButton>
|
||||||
<TopButton
|
)}
|
||||||
className="bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
<Popover className="relative outline-none w-full h-full">
|
||||||
onClick={() =>
|
{({ open }) => {
|
||||||
navigate(routes.ide({ userName: _projectOwner, projectTitle }))
|
return (
|
||||||
}
|
<>
|
||||||
name="Editor"
|
<Popover.Button className="h-full outline-none">
|
||||||
>
|
<TopButton
|
||||||
<Svg name="terminal" className="w-6 h-6 text-ch-pink-500" />
|
Tag="div"
|
||||||
</TopButton>
|
name="Share"
|
||||||
)}
|
className=" bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||||
<Popover className="relative outline-none w-full h-full">
|
>
|
||||||
{({ open }) => {
|
<Svg
|
||||||
return (
|
name="share"
|
||||||
<>
|
className="w-6 h-6 text-ch-purple-500 mt-1"
|
||||||
<Popover.Button className="h-full w-full outline-none">
|
/>
|
||||||
<TopButton
|
</TopButton>
|
||||||
Tag="div"
|
</Popover.Button>
|
||||||
name="Share"
|
{open && (
|
||||||
className=" bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
<Popover.Panel className="absolute z-10 mt-4 right-0">
|
||||||
>
|
<Tabs
|
||||||
<Svg
|
className="bg-ch-purple-gray-200 rounded-md shadow-md overflow-hidden text-gray-700"
|
||||||
name="share"
|
selectedTabClassName="bg-ch-gray-700 text-white"
|
||||||
className="w-6 h-6 text-ch-purple-500 mt-1"
|
>
|
||||||
/>
|
<TabPanel>
|
||||||
</TopButton>
|
<FullScriptEncoding />
|
||||||
</Popover.Button>
|
</TabPanel>
|
||||||
{open && (
|
<TabPanel>
|
||||||
<Popover.Panel className="absolute z-10 mt-4 right-0">
|
<ExternalScript />
|
||||||
<Tabs
|
</TabPanel>
|
||||||
className="bg-ch-purple-gray-200 rounded-md shadow-md overflow-hidden text-gray-700"
|
|
||||||
selectedTabClassName="bg-ch-gray-700 text-white"
|
|
||||||
>
|
|
||||||
<TabPanel>
|
|
||||||
<FullScriptEncoding />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel>
|
|
||||||
<ExternalScript />
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabList className="flex whitespace-nowrap text-gray-700 border-t border-gray-700">
|
<TabList className="flex whitespace-nowrap text-gray-700 border-t border-gray-700">
|
||||||
<Tab className="p-3 px-5">encoded script</Tab>
|
<Tab className="p-3 px-5">encoded script</Tab>
|
||||||
<Tab className="p-3 px-5">external script</Tab>
|
<Tab className="p-3 px-5">external script</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{currentUser?.sub && (
|
||||||
|
<TopButton
|
||||||
|
onClick={handleFork}
|
||||||
|
name="Fork"
|
||||||
|
className=" bg-ch-blue-650 bg-opacity-30 hover:bg-opacity-80 text-ch-gray-300"
|
||||||
|
>
|
||||||
|
<Svg name="fork-new" className="w-6 h-6 text-ch-blue-400" />
|
||||||
|
</TopButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import IdeContainer from 'src/components/IdeContainer/IdeContainer'
|
import IdeContainer from 'src/components/IdeContainer/IdeContainer'
|
||||||
import { useRender } from './useRender'
|
import { useRender } from './useRender'
|
||||||
import OutBound from 'src/components/OutBound/OutBound'
|
|
||||||
import IdeSideBar from 'src/components/IdeSideBar/IdeSideBar'
|
import IdeSideBar from 'src/components/IdeSideBar/IdeSideBar'
|
||||||
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
import TopNav from 'src/components/TopNav/TopNav'
|
||||||
import Svg from 'src/components/Svg/Svg'
|
|
||||||
import { useIdeInit } from 'src/components/EncodedUrl/helpers'
|
import { useIdeInit } from 'src/components/EncodedUrl/helpers'
|
||||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||||
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
|
import { useSaveCode } from 'src/components/IdeWrapper/useSaveCode'
|
||||||
import { ShortcutsModalContext } from 'src/components/EditorMenu/AllShortcutsModal'
|
import { ShortcutsModalContext } from 'src/components/EditorMenu/AllShortcutsModal'
|
||||||
|
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
||||||
|
import type { CadPackageType } from 'src/components/CadPackage/CadPackage'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cadPackage: string
|
cadPackage: CadPackageType
|
||||||
}
|
}
|
||||||
|
|
||||||
const IdeWrapper = ({ cadPackage }: Props) => {
|
const IdeWrapper = ({ cadPackage }: Props) => {
|
||||||
@@ -33,7 +33,9 @@ const IdeWrapper = ({ cadPackage }: Props) => {
|
|||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<ShortcutsModalContext.Provider value={shortcutModalContextValues}>
|
<ShortcutsModalContext.Provider value={shortcutModalContextValues}>
|
||||||
<nav className="flex">
|
<nav className="flex">
|
||||||
<IdeHeader handleRender={onRender} />
|
<TopNav>
|
||||||
|
<IdeHeader handleRender={onRender} context="ide" />
|
||||||
|
</TopNav>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="h-full flex flex-grow bg-ch-gray-900">
|
<div className="h-full flex flex-grow bg-ch-gray-900">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { countEmotes } from 'src/helpers/emote'
|
|||||||
import ImageUploader from 'src/components/ImageUploader'
|
import ImageUploader from 'src/components/ImageUploader'
|
||||||
import { ImageFallback } from '../ImageUploader/ImageUploader'
|
import { ImageFallback } from '../ImageUploader/ImageUploader'
|
||||||
|
|
||||||
const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => (
|
const ProjectCard = ({
|
||||||
|
title,
|
||||||
|
mainImage,
|
||||||
|
user,
|
||||||
|
Reaction,
|
||||||
|
cadPackage,
|
||||||
|
childForks,
|
||||||
|
}) => (
|
||||||
<li
|
<li
|
||||||
className="rounded p-1.5 bg-ch-gray-760 hover:bg-ch-gray-710 shadow-ch"
|
className="rounded p-1.5 bg-ch-gray-760 hover:bg-ch-gray-710 shadow-ch"
|
||||||
key={`${user?.userName}--${title}`}
|
key={`${user?.userName}--${title}`}
|
||||||
@@ -45,7 +52,8 @@ const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => (
|
|||||||
{countEmotes(Reaction).reduce((prev, { count }) => prev + count, 0)}
|
{countEmotes(Reaction).reduce((prev, { count }) => prev + count, 0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 flex items-center bg-ch-blue-650 bg-opacity-30 text-ch-gray-300 rounded-sm">
|
<div className="px-2 flex items-center bg-ch-blue-650 bg-opacity-30 text-ch-gray-300 rounded-sm">
|
||||||
<Svg name="fork-new" className="w-4 mr-2" />0
|
<Svg name="fork-new" className="w-4 mr-2" />
|
||||||
|
{childForks?.length || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useMutation } from '@redwoodjs/web'
|
|||||||
import { toast } from '@redwoodjs/web/toast'
|
import { toast } from '@redwoodjs/web/toast'
|
||||||
import { navigate, routes } from '@redwoodjs/router'
|
import { navigate, routes } from '@redwoodjs/router'
|
||||||
import { useAuth } from '@redwoodjs/auth'
|
import { useAuth } from '@redwoodjs/auth'
|
||||||
|
import { useIdeState } from 'src/helpers/hooks/useIdeState'
|
||||||
|
import { IdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||||
|
|
||||||
import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile'
|
import ProjectProfile from 'src/components/ProjectProfile/ProjectProfile'
|
||||||
import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell'
|
import { QUERY as PROJECT_REACTION_QUERY } from 'src/components/ProjectReactionsCell'
|
||||||
@@ -26,8 +28,14 @@ export const QUERY = gql`
|
|||||||
mainImage
|
mainImage
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
userId
|
|
||||||
cadPackage
|
cadPackage
|
||||||
|
forkedFrom {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
user {
|
||||||
|
userName
|
||||||
|
}
|
||||||
|
}
|
||||||
Reaction {
|
Reaction {
|
||||||
emote
|
emote
|
||||||
}
|
}
|
||||||
@@ -114,26 +122,20 @@ export const Empty = () => <div className="h-full">Empty</div>
|
|||||||
|
|
||||||
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
export const Failure = ({ error }) => <div>Error: {error.message}</div>
|
||||||
|
|
||||||
export const Success = ({
|
export const Success = ({ userProject, refetch }) => {
|
||||||
userProject,
|
|
||||||
variables: { isEditable },
|
|
||||||
refetch,
|
|
||||||
}) => {
|
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
const [updateProject, { loading, error }] = useMutation(
|
const [state, thunkDispatch] = useIdeState()
|
||||||
UPDATE_PROJECT_MUTATION,
|
const [updateProject] = useMutation(UPDATE_PROJECT_MUTATION, {
|
||||||
{
|
onCompleted: ({ updateProject }) => {
|
||||||
onCompleted: ({ updateProject }) => {
|
navigate(
|
||||||
navigate(
|
routes.project({
|
||||||
routes.project({
|
userName: updateProject.user.userName,
|
||||||
userName: updateProject.user.userName,
|
projectTitle: updateProject.title,
|
||||||
projectTitle: updateProject.title,
|
})
|
||||||
})
|
)
|
||||||
)
|
toast.success('Project updated.')
|
||||||
toast.success('Project updated.')
|
},
|
||||||
},
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
const [createProject] = useMutation(CREATE_PROJECT_MUTATION, {
|
const [createProject] = useMutation(CREATE_PROJECT_MUTATION, {
|
||||||
onCompleted: ({ createProject }) => {
|
onCompleted: ({ createProject }) => {
|
||||||
navigate(
|
navigate(
|
||||||
@@ -154,7 +156,7 @@ export const Success = ({
|
|||||||
refetch()
|
refetch()
|
||||||
}
|
}
|
||||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||||
onCompleted: ({ deleteProject }) => {
|
onCompleted: () => {
|
||||||
navigate(routes.home())
|
navigate(routes.home())
|
||||||
toast.success('Project deleted.')
|
toast.success('Project deleted.')
|
||||||
},
|
},
|
||||||
@@ -200,15 +202,27 @@ export const Success = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectProfile
|
<IdeContext.Provider
|
||||||
userProject={userProject}
|
value={{
|
||||||
onSave={onSave}
|
state,
|
||||||
onDelete={onDelete}
|
thunkDispatch,
|
||||||
loading={loading}
|
project: {
|
||||||
error={error}
|
...userProject?.Project,
|
||||||
isEditable={isEditable}
|
user: {
|
||||||
onReaction={onReaction}
|
id: userProject.id,
|
||||||
onComment={onComment}
|
image: userProject.image,
|
||||||
/>
|
userName: userProject.userName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectProfile
|
||||||
|
userProject={userProject}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onReaction={onReaction}
|
||||||
|
onComment={onComment}
|
||||||
|
/>
|
||||||
|
</IdeContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Button from 'src/components/Button/Button'
|
|||||||
import ProjectReactionsCell from '../ProjectReactionsCell'
|
import ProjectReactionsCell from '../ProjectReactionsCell'
|
||||||
import { countEmotes } from 'src/helpers/emote'
|
import { countEmotes } from 'src/helpers/emote'
|
||||||
import { getActiveClasses } from 'get-active-classes'
|
import { getActiveClasses } from 'get-active-classes'
|
||||||
|
import TopNav from 'src/components/TopNav/TopNav'
|
||||||
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
||||||
import CadPackage from 'src/components/CadPackage/CadPackage'
|
import CadPackage from 'src/components/CadPackage/CadPackage'
|
||||||
import Gravatar from 'src/components/Gravatar/Gravatar'
|
import Gravatar from 'src/components/Gravatar/Gravatar'
|
||||||
@@ -38,6 +39,7 @@ const ProjectProfile = ({
|
|||||||
const hasPermissionToEdit =
|
const hasPermissionToEdit =
|
||||||
currentUser?.sub === userProject.id || currentUser?.roles.includes('admin')
|
currentUser?.sub === userProject.id || currentUser?.roles.includes('admin')
|
||||||
const project = userProject?.Project
|
const project = userProject?.Project
|
||||||
|
|
||||||
const emotes = countEmotes(project?.Reaction)
|
const emotes = countEmotes(project?.Reaction)
|
||||||
const userEmotes = project?.userReactions.map(({ emote }) => emote)
|
const userEmotes = project?.userReactions.map(({ emote }) => emote)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,7 +51,7 @@ const ProjectProfile = ({
|
|||||||
projectTitle: project?.title,
|
projectTitle: project?.title,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}, [currentUser])
|
}, [currentUser, project?.title, userProject.userName])
|
||||||
useIdeInit(project?.cadPackage, project?.code, 'viewer')
|
useIdeInit(project?.cadPackage, project?.code, 'viewer')
|
||||||
const [newDescription, setNewDescription] = useState(project?.description)
|
const [newDescription, setNewDescription] = useState(project?.description)
|
||||||
const onDescriptionChange = (description) => setNewDescription(description())
|
const onDescriptionChange = (description) => setNewDescription(description())
|
||||||
@@ -69,14 +71,9 @@ const ProjectProfile = ({
|
|||||||
<>
|
<>
|
||||||
<div className="h-screen flex flex-col text-lg font-fira-sans">
|
<div className="h-screen flex flex-col text-lg font-fira-sans">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<IdeHeader
|
<TopNav>
|
||||||
handleRender={() => {}}
|
<IdeHeader context="profile" />
|
||||||
projectTitle={project?.title}
|
</TopNav>
|
||||||
projectOwner={userProject?.userName}
|
|
||||||
projectOwnerImage={userProject?.image}
|
|
||||||
projectOwnerId={userProject?.id}
|
|
||||||
projectId={project?.id}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-grow h-full">
|
<div className="relative flex-grow h-full">
|
||||||
<div className="grid grid-cols-1 md:auto-cols-preview-layout grid-flow-row-dense absolute inset-0 h-full">
|
<div className="grid grid-cols-1 md:auto-cols-preview-layout grid-flow-row-dense absolute inset-0 h-full">
|
||||||
@@ -145,6 +142,20 @@ const ProjectProfile = ({
|
|||||||
<KeyValue keyName="Updated on">
|
<KeyValue keyName="Updated on">
|
||||||
{new Date(project?.updatedAt).toDateString()}
|
{new Date(project?.updatedAt).toDateString()}
|
||||||
</KeyValue>
|
</KeyValue>
|
||||||
|
{project.forkedFrom && (
|
||||||
|
<KeyValue keyName="Forked from">
|
||||||
|
<Link
|
||||||
|
className="pink-link"
|
||||||
|
to={routes.project({
|
||||||
|
userName: project.forkedFrom.user.userName,
|
||||||
|
projectTitle: project.forkedFrom.title,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{project.forkedFrom.title}
|
||||||
|
</Link>{' '}
|
||||||
|
by {project.forkedFrom.user.userName}
|
||||||
|
</KeyValue>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<KeyValue keyName="Reactions">
|
<KeyValue keyName="Reactions">
|
||||||
<EmojiReaction
|
<EmojiReaction
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const ProjectsList = ({
|
|||||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
|
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
|
||||||
>
|
>
|
||||||
{filteredProjects.map(
|
{filteredProjects.map(
|
||||||
({ title, mainImage, user, Reaction, cadPackage }, index) => (
|
(
|
||||||
|
{ title, mainImage, user, Reaction, cadPackage, childForks },
|
||||||
|
index
|
||||||
|
) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={index}
|
key={index}
|
||||||
title={title}
|
title={title}
|
||||||
@@ -48,6 +51,7 @@ const ProjectsList = ({
|
|||||||
user={user}
|
user={user}
|
||||||
Reaction={Reaction}
|
Reaction={Reaction}
|
||||||
cadPackage={cadPackage}
|
cadPackage={cadPackage}
|
||||||
|
childForks={childForks}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const QUERY = gql`
|
|||||||
title
|
title
|
||||||
cadPackage
|
cadPackage
|
||||||
mainImage
|
mainImage
|
||||||
|
childForks {
|
||||||
|
id
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
user {
|
user {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const QUERY = gql`
|
|||||||
title
|
title
|
||||||
mainImage
|
mainImage
|
||||||
cadPackage
|
cadPackage
|
||||||
|
childForks {
|
||||||
|
id
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
user {
|
user {
|
||||||
|
|||||||
31
app/web/src/components/TopNav/TopNav.tsx
Normal file
31
app/web/src/components/TopNav/TopNav.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="h-16 w-full bg-ch-gray-900 flex justify-between items-center text-lg">
|
||||||
|
<div className="h-full text-gray-300 flex items-center">
|
||||||
|
<div className="w-14 h-16 flex items-center justify-center bg-ch-gray-900">
|
||||||
|
<Link to={routes.home()}>
|
||||||
|
<Svg className="w-12 p-0.5" name="favicon" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="text-gray-200 grid grid-flow-col-dense gap-4 mr-4 items-center">
|
||||||
|
<div className="h-8 w-8">
|
||||||
|
<NavPlusButton />
|
||||||
|
</div>
|
||||||
|
<ProfileSlashLogin />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopNav
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useReducer } from 'react'
|
import { useEffect, useReducer } from 'react'
|
||||||
import { useAuth } from '@redwoodjs/auth'
|
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 ProjectsOfUser from 'src/components/ProjectsOfUserCell'
|
||||||
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
|
import TopNav from 'src/components/TopNav/TopNav'
|
||||||
import Svg from 'src/components/Svg/Svg'
|
|
||||||
import {
|
import {
|
||||||
fieldComponents,
|
fieldComponents,
|
||||||
fieldReducer,
|
fieldReducer,
|
||||||
@@ -27,13 +26,7 @@ function buildFieldsConfig(fieldsConfig, user, hasPermissionToEdit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfile = ({
|
const UserProfile = ({ user, isEditing, onSave }: UserProfileType) => {
|
||||||
user,
|
|
||||||
isEditing,
|
|
||||||
loading,
|
|
||||||
onSave,
|
|
||||||
error,
|
|
||||||
}: UserProfileType) => {
|
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
const hasPermissionToEdit = currentUser?.sub === user.id
|
const hasPermissionToEdit = currentUser?.sub === user.id
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,14 +53,7 @@ const UserProfile = ({
|
|||||||
<>
|
<>
|
||||||
<div className="md:h-screen flex flex-col text-lg font-fira-sans">
|
<div className="md:h-screen flex flex-col text-lg font-fira-sans">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<IdeHeader
|
<TopNav />
|
||||||
handleRender={() => {}}
|
|
||||||
projectOwner={user?.userName}
|
|
||||||
projectOwnerImage={user?.image}
|
|
||||||
projectOwnerId={user?.id}
|
|
||||||
>
|
|
||||||
<span></span>
|
|
||||||
</IdeHeader>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-grow h-full">
|
<div className="relative flex-grow h-full">
|
||||||
<div className="grid md:grid-cols-profile-layout grid-flow-row-dense absolute inset-0">
|
<div className="grid md:grid-cols-profile-layout grid-flow-row-dense absolute inset-0">
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
.markdown-overrides div {
|
.markdown-overrides div {
|
||||||
@apply text-ch-gray-300 bg-transparent;
|
@apply text-ch-gray-300 bg-transparent;
|
||||||
}
|
}
|
||||||
|
.pink-link,
|
||||||
|
Made this a utility class and naming things is hard. Made this a utility class and naming things is hard.
|
|||||||
.markdown-overrides a,
|
.markdown-overrides a,
|
||||||
.markdown-overrides div a {
|
.markdown-overrides div a {
|
||||||
@apply text-ch-pink-500 underline bg-transparent;
|
@apply text-ch-pink-500 underline bg-transparent;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Toaster } from '@redwoodjs/web/toast'
|
|||||||
import { useIdeState } from 'src/helpers/hooks/useIdeState'
|
import { useIdeState } from 'src/helpers/hooks/useIdeState'
|
||||||
import type { Project } from 'src/components/IdeProjectCell/IdeProjectCell'
|
import type { Project } from 'src/components/IdeProjectCell/IdeProjectCell'
|
||||||
import { IdeContext } from 'src/helpers/hooks/useIdeContext'
|
import { IdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||||
|
import type { CadPackageType } from 'src/components/CadPackage/CadPackage'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cadPackage: string
|
cadPackage: string
|
||||||
@@ -21,7 +22,9 @@ const DevIdePage = ({ cadPackage, project }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Toaster timeout={9000} />
|
<Toaster timeout={9000} />
|
||||||
<IdeContext.Provider value={{ state, thunkDispatch, project }}>
|
<IdeContext.Provider value={{ state, thunkDispatch, project }}>
|
||||||
<IdeWrapper cadPackage={cadPackage.toLocaleLowerCase()} />
|
<IdeWrapper
|
||||||
|
cadPackage={cadPackage.toLocaleLowerCase() as CadPackageType}
|
||||||
|
/>
|
||||||
</IdeContext.Provider>
|
</IdeContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import { useAuth } from '@redwoodjs/auth'
|
|||||||
|
|
||||||
import ProjectCell from 'src/components/ProjectCell'
|
import ProjectCell from 'src/components/ProjectCell'
|
||||||
import Seo from 'src/components/Seo/Seo'
|
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 { Toaster } from '@redwoodjs/web/toast'
|
||||||
import { makeSocialPublicId } from 'src/helpers/hooks/useUpdateProjectImages'
|
import { makeSocialPublicId } from 'src/helpers/hooks/useUpdateProjectImages'
|
||||||
|
|
||||||
const ProjectPage = ({ userName, projectTitle }) => {
|
const ProjectPage = ({ userName, projectTitle }) => {
|
||||||
const { currentUser } = useAuth()
|
const { currentUser } = useAuth()
|
||||||
const [state, thunkDispatch] = useIdeState()
|
|
||||||
const socialImageUrl = `http://res.cloudinary.com/irevdev/image/upload/c_scale,w_1200/v1/CadHub/${makeSocialPublicId(
|
const socialImageUrl = `http://res.cloudinary.com/irevdev/image/upload/c_scale,w_1200/v1/CadHub/${makeSocialPublicId(
|
||||||
userName,
|
userName,
|
||||||
projectTitle
|
projectTitle
|
||||||
@@ -23,13 +20,11 @@ const ProjectPage = ({ userName, projectTitle }) => {
|
|||||||
lang="en-US"
|
lang="en-US"
|
||||||
/>
|
/>
|
||||||
<Toaster timeout={1500} />
|
<Toaster timeout={1500} />
|
||||||
<IdeContext.Provider value={{ state, thunkDispatch, project: null }}>
|
<ProjectCell
|
||||||
<ProjectCell
|
userName={userName}
|
||||||
userName={userName}
|
projectTitle={projectTitle}
|
||||||
projectTitle={projectTitle}
|
currentUserId={currentUser?.sub}
|
||||||
currentUserId={currentUser?.sub}
|
/>
|
||||||
/>
|
|
||||||
</IdeContext.Provider>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
Navigate to either the IDE or the details views to match wherever you are now.
TODO regularize these params when the Header component gets regularized.
This seems a little brittle to me, as I would hope that the component would be able be able to know what page it's on without having to look at the route. What if someone names their project "idea" than it would match on "user/idea" I had a look at seeing what it would take to pass this information to the component instead and realised that it could be done but it would be adding more mess to the messy situation that is this component so I did a big refactor aimed at this branch.
I've put you as a review, but the diff is not super meaningful as it's really just huge chunks of code moved around 🤷
https://github.com/Irev-Dev/cadhub/pull/534