diff --git a/packages/app/src/app/components/Create/CreateBox.tsx b/packages/app/src/app/components/Create/CreateBox.tsx new file mode 100644 index 00000000000..2f5ccd5c514 --- /dev/null +++ b/packages/app/src/app/components/Create/CreateBox.tsx @@ -0,0 +1,522 @@ +import { + Text, + Stack, + Element, + IconButton, + ThemeProvider, +} from '@codesandbox/components'; +import { useActions, useAppState } from 'app/overmind'; +import React, { useState, useEffect } from 'react'; +import { useTabState } from 'reakit/Tab'; +import slugify from '@codesandbox/common/lib/utils/slugify'; +import { TemplateFragment } from 'app/graphql/types'; +import track from '@codesandbox/common/lib/utils/analytics'; +import { sandboxUrl } from '@codesandbox/common/lib/utils/url-generator'; + +import { useBetaSandboxEditor } from 'app/hooks/useBetaSandboxEditor'; +import { pluralize } from 'app/utils/pluralize'; +import { ModalContentProps } from 'app/pages/common/Modals'; +import { + Container, + Tab, + Tabs, + Panel, + HeaderInformation, + ModalContent, + ModalSidebar, + ModalBody, + DevboxAlternative, + SandboxAlternative, +} from './elements'; +import { TemplateList } from './TemplateList'; +import { useTemplateCollections } from './hooks/useTemplateCollections'; +import { useOfficialTemplates } from './hooks/useOfficialTemplates'; +import { useTeamTemplates } from './hooks/useTeamTemplates'; +import { CreateParams } from './utils/types'; +import { SearchBox } from './SearchBox'; +import { ImportTemplate } from './ImportTemplate'; +import { CreateBoxForm } from './CreateBox/CreateBoxForm'; +import { TemplateInfo } from './CreateBox/TemplateInfo'; +import { useFeaturedTemplates } from './hooks/useFeaturedTemplates'; +import { useAllTemplates } from './hooks/useAllTemplates'; + +export const COLUMN_MEDIA_THRESHOLD = 1600; + +type CreateBoxProps = ModalContentProps & { + collectionId?: string; + type?: 'devbox' | 'sandbox'; + hasSecondStep?: boolean; +}; + +export const CreateBox: React.FC = ({ + collectionId, + type = 'devbox', + hasSecondStep = false, + closeModal, + isModal, +}) => { + const { hasLogIn, activeTeam } = useAppState(); + const actions = useActions(); + + const mediaQuery = window.matchMedia('screen and (max-width: 950px)'); + const mobileScreenSize = mediaQuery.matches; + + const showFeaturedTemplates = type === 'devbox'; + const showCollections = type === 'devbox'; + + const tabState = useTabState({ + orientation: mobileScreenSize ? 'horizontal' : 'vertical', + selectedId: type === 'devbox' ? 'featured' : 'all', + }); + + const [viewState, setViewState] = useState<'initial' | 'fromTemplate'>( + 'initial' + ); + const [selectedTemplate, setSelectedTemplate] = useState(); + const [searchQuery, setSearchQuery] = useState(''); + + const { collections } = useTemplateCollections({ type }); + const { templates: officialTemplates } = useOfficialTemplates({ type }); + const { teamTemplates, recentTemplates } = useTeamTemplates({ + teamId: activeTeam, + hasLogIn, + type, + }); + + const recentlyUsedTemplates = recentTemplates.slice(0, 3); + const featuredTemplates = useFeaturedTemplates({ + officialTemplates, + recentTemplates, + }); + + const allTemplates = useAllTemplates({ + featuredTemplates, + officialTemplates, + teamTemplates, + collections, + searchQuery, + }); + + /** + * Only show the team templates if the list is populated. + */ + const hasRecentlyUsedTemplates = recentlyUsedTemplates.length > 0; + const showTeamTemplates = teamTemplates.length > 0; + const showImportTemplates = hasLogIn && activeTeam && type === 'devbox'; + + useEffect(() => { + if (searchQuery) { + track(`Create ${type} - Search Templates`, { + query: searchQuery, + codesandbox: 'V1', + event_source: 'UI', + }); + } + }, [searchQuery]); + + useEffect(() => { + if (searchQuery && tabState.selectedId !== 'all') { + setSearchQuery(''); + } + }, [searchQuery, tabState.selectedId]); + + const [hasBetaEditorExperiment] = useBetaSandboxEditor(); + + const createFromTemplate = ( + template: TemplateFragment, + { name, createAs }: CreateParams + ) => { + const { sandbox } = template; + + track(`Create ${type} - Create`, { + codesandbox: 'V1', + event_source: 'UI', + type: 'fork', + template_name: + template.sandbox.title || template.sandbox.alias || template.sandbox.id, + }); + + actions.editor.forkExternalSandbox({ + sandboxId: sandbox.id, + openInNewWindow: false, + hasBetaEditorExperiment, + body: { + alias: name, + collectionId, + v2: createAs === 'devbox', + }, + }); + + closeModal(); + }; + + const selectTemplate = ( + template: TemplateFragment, + trackingSource: string + ) => { + if (hasSecondStep) { + setSelectedTemplate(template); + setViewState('fromTemplate'); + + track(`Create ${type} - Select template`, { + codesandbox: 'V1', + event_source: 'UI', + type: 'fork', + tab_name: trackingSource, + template_name: + template.sandbox.title || + template.sandbox.alias || + template.sandbox.id, + }); + } else { + createFromTemplate(template, { + createAs: type, + permission: 0, + editor: 'web', + }); + } + }; + + const openTemplate = (template: TemplateFragment, trackingSource: string) => { + const { sandbox } = template; + const url = sandboxUrl(sandbox, hasBetaEditorExperiment); + window.open(url, '_blank'); + + track(`Create ${type} - Open template`, { + codesandbox: 'V1', + event_source: 'UI', + type: 'open', + tab_name: trackingSource, + template_name: + template.sandbox.title || template.sandbox.alias || template.sandbox.id, + }); + }; + + const trackTabClick = (tab: string) => { + track(`Create ${type} - Click Tab`, { + codesandbox: 'V1', + event_source: 'UI', + tab_name: tab, + }); + }; + + return ( + + + + + {viewState === 'initial' ? ( + + Create {type === 'devbox' ? 'Devbox' : 'Sandbox'} + + ) : ( + { + setViewState('initial'); + }} + /> + )} + + + {mobileScreenSize && viewState === 'initial' ? ( + { + const query = e.target.value; + tabState.select('all'); + setSearchQuery(query); + }} + /> + ) : null} + + {/* isModal is undefined on /s/ page */} + {isModal && closeModal ? ( + closeModal()} + /> + ) : null} + + + + + {viewState === 'initial' ? ( + + + {!mobileScreenSize && ( + <> + { + const query = e.target.value; + tabState.select('all'); + setSearchQuery(query); + }} + /> + + + + )} + + + {showFeaturedTemplates && ( + trackTabClick('featured')} + stopId="featured" + > + Featured templates + + )} + + trackTabClick('all')} + stopId="all" + > + All templates + + + {type === 'devbox' && } + + {showTeamTemplates ? ( + trackTabClick('workspace')} + stopId="workspace" + > + Workspace templates + + ) : null} + + {showImportTemplates ? ( + trackTabClick('import-template')} + stopId="import-template" + > + Import template + + ) : null} + + trackTabClick('official')} + stopId="official" + > + Official templates + + + + + {showCollections + ? collections.map(collection => ( + trackTabClick(collection.title)} + > + {collection.title} + + )) + : null} + + + {!mobileScreenSize && ( + + + {type === 'devbox' + ? "There's even more" + : 'Do more with Devboxes'} + + + {type === 'devbox' ? ( + { + track(`Create ${type} - Open Community Search`, { + codesandbox: 'V1', + event_source: 'UI - Sidebar', + }); + }} + /> + ) : ( + { + track(`Create ${type} - Open Devboxes`, { + codesandbox: 'V1', + event_source: 'UI - Sidebar', + }); + actions.modalOpened({ + modal: 'createDevbox', + }); + }} + /> + )} + + + )} + + ) : null} + + {viewState === 'fromTemplate' ? ( + + ) : null} + + + + {viewState === 'initial' && ( + + + {hasRecentlyUsedTemplates && ( + { + selectTemplate(template, 'featured'); + }} + onOpenTemplate={template => { + openTemplate(template, 'featured'); + }} + /> + )} + { + selectTemplate(template, 'featured'); + }} + onOpenTemplate={template => { + openTemplate(template, 'featured'); + }} + /> + + + + { + selectTemplate(template, 'all'); + }} + onOpenTemplate={template => { + openTemplate(template, 'all'); + }} + /> + + + {showTeamTemplates ? ( + + { + selectTemplate(template, 'workspace'); + }} + onOpenTemplate={template => { + openTemplate(template, 'workspace'); + }} + /> + + ) : null} + + {showImportTemplates ? ( + + + + ) : null} + + + { + selectTemplate(template, 'official'); + }} + onOpenTemplate={template => { + openTemplate(template, 'official'); + }} + /> + + + {collections.map(collection => ( + + { + selectTemplate(template, collection.title); + }} + onOpenTemplate={template => { + openTemplate(template, collection.title); + }} + /> + + ))} + + )} + + {viewState === 'fromTemplate' ? ( + { + setViewState('initial'); + }} + onSubmit={params => { + createFromTemplate(selectedTemplate, params); + }} + /> + ) : null} + + + + + ); +}; diff --git a/packages/app/src/app/components/Create/CreateBox/CreateBoxForm.tsx b/packages/app/src/app/components/Create/CreateBox/CreateBoxForm.tsx new file mode 100644 index 00000000000..dde86444956 --- /dev/null +++ b/packages/app/src/app/components/Create/CreateBox/CreateBoxForm.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { + Stack, + Element, + Button, + Text, + Input, + Radio, + Icon, +} from '@codesandbox/components'; + +import { CreateParams } from '../utils/types'; + +interface CreateBoxFormProps { + type: 'sandbox' | 'devbox'; + onCancel: () => void; + onSubmit: (params: CreateParams) => void; +} + +export const CreateBoxForm: React.FC = ({ + type, + onCancel, + onSubmit, +}) => { + const label = type === 'sandbox' ? 'Sandbox' : 'Devbox'; + + const [name, setName] = useState(); + // TODO: default privacy in workspace + const [permission, setPermission] = useState<0 | 1 | 2>(0); + const [editor, setEditor] = useState<'web' | 'vscode'>('web'); + const showVMSpecs = type === 'devbox'; + const disableEditorChange = type === 'sandbox'; + + const defaultSpecs = '4 vCPUs - 8GiB RAM - 16GB disk'; + + return ( + + { + e.preventDefault(); + onSubmit({ + name, + createAs: type, + permission, + editor, + }); + }} + > + + + Create {label} + + + + Name + + + Leaving this field empty will generate a random name. + + setName(e.target.value)} + aria-describedby="name-desc" + /> + + + + + Visibility + + + setPermission(0)} + label="Public" + /> + setPermission(1)} + label="Unlisted" + /> + setPermission(2)} + label="Private" + /> + + + + + + Open in + + + setEditor('web')} + label="Web editor" + /> + setEditor('vscode')} + label="VSCode" + /> + + {disableEditorChange && ( + + + + Sandboxes can only be open in the web editor. + + + )} + + + {showVMSpecs && ( + + + Virtual machine specifications + + + + + + VM specs are currently tied to your subscription. + + + + )} + + + + + + + + + + + ); +}; diff --git a/packages/app/src/app/components/Create/CreateBox/TemplateInfo.tsx b/packages/app/src/app/components/Create/CreateBox/TemplateInfo.tsx new file mode 100644 index 00000000000..cff49369424 --- /dev/null +++ b/packages/app/src/app/components/Create/CreateBox/TemplateInfo.tsx @@ -0,0 +1,41 @@ +import { getTemplateIcon } from '@codesandbox/common/lib/utils/getTemplateIcon'; +import { Stack, Text } from '@codesandbox/components'; +import { TemplateFragment } from 'app/graphql/types'; +import React from 'react'; + +interface TemplateInfoProps { + template: TemplateFragment; +} + +export const TemplateInfo = ({ template }: TemplateInfoProps) => { + const { UserIcon } = getTemplateIcon( + template.sandbox.title, + template.iconUrl, + template.sandbox?.source?.template + ); + + const title = template.sandbox.title || template.sandbox.alias; + const author = + template.sandbox?.team?.name || + template.sandbox?.author?.username || + 'CodeSandbox'; + + return ( + + + + + {title} + + {author && ( + + {author} + + )} + + + {template.sandbox.description} + + + ); +}; diff --git a/packages/app/src/app/components/Create/GenericCreate.tsx b/packages/app/src/app/components/Create/GenericCreate.tsx new file mode 100644 index 00000000000..7567dfeafe3 --- /dev/null +++ b/packages/app/src/app/components/Create/GenericCreate.tsx @@ -0,0 +1,133 @@ +import React, { useEffect } from 'react'; + +import { Stack, Text, IconButton, Element } from '@codesandbox/components'; +import track from '@codesandbox/common/lib/utils/analytics'; +import { useActions } from 'app/overmind'; +import { Container, HeaderInformation } from './elements'; +import { LargeCTAButton } from '../dashboard/LargeCTAButton'; +import { + DEVBOX_BUTTON_DESCRIPTION, + IMPORT_BUTTON_DESCRIPTION, + SANDBOX_BUTTON_DESCRIPTION, +} from './utils/constants'; + +export const GenericCreate: React.FC<{ + closeModal?: () => void; + isModal?: boolean; +}> = ({ closeModal, isModal }) => { + const actions = useActions(); + + useEffect(() => { + track('Generic Create - Show', { + codesandbox: 'V1', + event_source: 'UI', + }); + }, []); + + return ( + + + + + Create + + + + {/* isModal is undefined on /s/ page */} + {isModal ? ( + closeModal()} + /> + ) : null} + + + + { + track('Generic Create - Import Repository', { + codesandbox: 'V1', + event_source: 'UI', + }); + if (closeModal) { + closeModal(); + } + actions.modalOpened({ modal: 'importRepository' }); + }} + variant="primary" + alignment="vertical" + /> + + { + track('Generic Create - Create Devbox', { + codesandbox: 'V1', + event_source: 'UI', + }); + if (closeModal) { + closeModal(); + } + actions.modalOpened({ modal: 'createDevbox' }); + }} + variant="primary" + alignment="vertical" + /> + + { + track('Generic Create - Create Sandbox', { + codesandbox: 'V1', + event_source: 'UI', + }); + if (closeModal) { + closeModal(); + } + actions.modalOpened({ modal: 'createSandbox' }); + }} + variant="secondary" + alignment="vertical" + /> + + + ); +}; diff --git a/packages/app/src/app/components/Create/ImportRepository.tsx b/packages/app/src/app/components/Create/ImportRepository.tsx new file mode 100644 index 00000000000..fe32f0d1785 --- /dev/null +++ b/packages/app/src/app/components/Create/ImportRepository.tsx @@ -0,0 +1,115 @@ +import { + Text, + Stack, + IconButton, + ThemeProvider, +} from '@codesandbox/components'; +import React, { useState } from 'react'; + +import { ModalContentProps } from 'app/pages/common/Modals'; +import { + Container, + HeaderInformation, + ModalContent, + ModalSidebar, + ModalBody, +} from './elements'; +import { Import } from './ImportRepository/Import'; +import { GithubRepoToImport } from './ImportRepository/types'; +import { ImportInfo } from './ImportRepository/ImportInfo'; +import { FromRepo } from './ImportRepository/FromRepo'; + +export const COLUMN_MEDIA_THRESHOLD = 1600; + +export const ImportRepository: React.FC = ({ + isModal, + closeModal, +}) => { + const mediaQuery = window.matchMedia('screen and (max-width: 950px)'); + const mobileScreenSize = mediaQuery.matches; + + const [viewState, setViewState] = useState<'initial' | 'fork'>('initial'); + + const [selectedRepo, setSelectedRepo] = useState(); + + const selectGithubRepo = (repo: GithubRepoToImport) => { + setSelectedRepo(repo); + setViewState('fork'); + }; + + return ( + + + + + {viewState === 'initial' ? ( + + Import repository + + ) : ( + // TODO: add aria-label based on title to IconButton? + { + setViewState('initial'); + }} + /> + )} + + + {/* isModal is undefined on /s/ page */} + {isModal ? ( + // TODO: IconButton doesn't have aria label or visuallyhidden text (reads floating label too late) + closeModal()} + /> + ) : null} + + + + {viewState === 'initial' ? ( + + + + ) : null} + {viewState === 'fork' ? ( + <> + + + + + + { + setViewState('initial'); + }} + /> + + + ) : null} + + + + ); +}; diff --git a/packages/app/src/app/components/CreateSandbox/Import/AccountSelect.tsx b/packages/app/src/app/components/Create/ImportRepository/AccountSelect.tsx similarity index 100% rename from packages/app/src/app/components/CreateSandbox/Import/AccountSelect.tsx rename to packages/app/src/app/components/Create/ImportRepository/AccountSelect.tsx diff --git a/packages/app/src/app/components/CreateSandbox/Import/AuthorizeForSuggested.tsx b/packages/app/src/app/components/Create/ImportRepository/AuthorizeForSuggested.tsx similarity index 100% rename from packages/app/src/app/components/CreateSandbox/Import/AuthorizeForSuggested.tsx rename to packages/app/src/app/components/Create/ImportRepository/AuthorizeForSuggested.tsx diff --git a/packages/app/src/app/components/CreateSandbox/Import/FromRepo.tsx b/packages/app/src/app/components/Create/ImportRepository/FromRepo.tsx similarity index 98% rename from packages/app/src/app/components/CreateSandbox/Import/FromRepo.tsx rename to packages/app/src/app/components/Create/ImportRepository/FromRepo.tsx index 219acf0144e..165bb808fba 100644 --- a/packages/app/src/app/components/CreateSandbox/Import/FromRepo.tsx +++ b/packages/app/src/app/components/Create/ImportRepository/FromRepo.tsx @@ -1,7 +1,6 @@ import { useActions, useAppState } from 'app/overmind'; import React, { useEffect } from 'react'; import { - Badge, Button, Element, Icon, @@ -64,7 +63,7 @@ export const FromRepo: React.FC = ({ repository, onCancel }) => { return; } - track('Create New - Create fork', { + track('Import repository - Create fork', { codesandbox: 'V1', event_source: 'UI', }); @@ -86,7 +85,7 @@ export const FromRepo: React.FC = ({ repository, onCancel }) => { }; useEffect(() => { - track('Create New - View create fork', { + track('Import repository - View create fork', { codesandbox: 'V1', event_source: 'UI', }); @@ -122,7 +121,6 @@ export const FromRepo: React.FC = ({ repository, onCancel }) => { > Create new fork - Cloud { const actions = useActions(); @@ -206,7 +206,7 @@ export const Import: React.FC = ({ onRepoSelect }) => { return; } - track('Create New - Import Repo', { + track('Import repository - Import', { codesandbox: 'V1', event_source: 'UI', }); @@ -220,44 +220,29 @@ export const Import: React.FC = ({ onRepoSelect }) => { return ( - - - Import repository - - - + Directly work on your GitHub repository in CodeSandbox. +
+ Learn more about Repositories{' '} + - Directly work on your GitHub repository in CodeSandbox. -
- Learn more about Repositories{' '} -
- here - - . -
-
+ here + + . + {restrictsPublicRepos ? : null} diff --git a/packages/app/src/app/components/CreateSandbox/Import/ImportInfo.tsx b/packages/app/src/app/components/Create/ImportRepository/ImportInfo.tsx similarity index 100% rename from packages/app/src/app/components/CreateSandbox/Import/ImportInfo.tsx rename to packages/app/src/app/components/Create/ImportRepository/ImportInfo.tsx diff --git a/packages/app/src/app/components/CreateSandbox/Import/PrivateRepoFreeTeam.tsx b/packages/app/src/app/components/Create/ImportRepository/PrivateRepoFreeTeam.tsx similarity index 95% rename from packages/app/src/app/components/CreateSandbox/Import/PrivateRepoFreeTeam.tsx rename to packages/app/src/app/components/Create/ImportRepository/PrivateRepoFreeTeam.tsx index c2d2361c8dd..5cdb6fd6fd7 100644 --- a/packages/app/src/app/components/CreateSandbox/Import/PrivateRepoFreeTeam.tsx +++ b/packages/app/src/app/components/Create/ImportRepository/PrivateRepoFreeTeam.tsx @@ -10,7 +10,7 @@ import { getEventName } from './utils'; export const PrivateRepoFreeTeam: React.FC = () => { const { isEligibleForTrial } = useWorkspaceSubscription(); const { isBillingManager } = useWorkspaceAuthorization(); - const { modals } = useActions(); + const { modalClosed } = useActions(); const [, createCheckout, canCheckout] = useCreateCheckout(); @@ -38,7 +38,7 @@ export const PrivateRepoFreeTeam: React.FC = () => { color="#FFFFFF" onClick={() => { if (ctaURL) { - modals.newSandboxModal.close(); + modalClosed(); } else { createCheckout({ trackingLocation: 'dashboard_import_limits', diff --git a/packages/app/src/app/components/CreateSandbox/Import/RestrictedPrivateRepositoriesImport.tsx b/packages/app/src/app/components/Create/ImportRepository/RestrictedPrivateRepositoriesImport.tsx similarity index 100% rename from packages/app/src/app/components/CreateSandbox/Import/RestrictedPrivateRepositoriesImport.tsx rename to packages/app/src/app/components/Create/ImportRepository/RestrictedPrivateRepositoriesImport.tsx diff --git a/packages/app/src/app/components/CreateSandbox/Import/SuggestedRepositories.tsx b/packages/app/src/app/components/Create/ImportRepository/SuggestedRepositories.tsx similarity index 99% rename from packages/app/src/app/components/CreateSandbox/Import/SuggestedRepositories.tsx rename to packages/app/src/app/components/Create/ImportRepository/SuggestedRepositories.tsx index 220734aca5c..de85587affa 100644 --- a/packages/app/src/app/components/CreateSandbox/Import/SuggestedRepositories.tsx +++ b/packages/app/src/app/components/Create/ImportRepository/SuggestedRepositories.tsx @@ -36,7 +36,7 @@ export const SuggestedRepositories = ({ activeTeamInfo, dashboard: { repositoriesByTeamId }, } = useAppState(); - const { modals, dashboard: dashboardActions } = useActions(); + const { modalClosed, dashboard: dashboardActions } = useActions(); const { restrictsPrivateRepos } = useGitHubPermissions(); const { isFree, isEligibleForTrial } = useWorkspaceSubscription(); const [importsInProgress, setImportsInProgress] = useState< @@ -277,7 +277,7 @@ export const SuggestedRepositories = ({ event_source: 'UI', } ); - modals.newSandboxModal.close(); + modalClosed(); }} > { +export const ImportTemplate = () => { const [repoUrl, setRepoUrl] = React.useState(''); const info = getRepoInfoFromURL(repoUrl); @@ -97,7 +97,7 @@ export const ImportSandbox = () => { lineHeight: 1.5, }} > - Create a read-only sandbox template that stays in sync with a GitHub + Create a read-only devbox template that stays in sync with a GitHub repository or folder.
Learn more about synced templates{' '} diff --git a/packages/app/src/app/components/Create/ImportTemplate/index.ts b/packages/app/src/app/components/Create/ImportTemplate/index.ts new file mode 100644 index 00000000000..42938e2b38b --- /dev/null +++ b/packages/app/src/app/components/Create/ImportTemplate/index.ts @@ -0,0 +1 @@ +export { ImportTemplate } from './ImportTemplate'; diff --git a/packages/app/src/app/components/CreateSandbox/SearchBox/SearchBox.tsx b/packages/app/src/app/components/Create/SearchBox/SearchBox.tsx similarity index 100% rename from packages/app/src/app/components/CreateSandbox/SearchBox/SearchBox.tsx rename to packages/app/src/app/components/Create/SearchBox/SearchBox.tsx diff --git a/packages/app/src/app/components/CreateSandbox/SearchBox/elements.ts b/packages/app/src/app/components/Create/SearchBox/elements.ts similarity index 97% rename from packages/app/src/app/components/CreateSandbox/SearchBox/elements.ts rename to packages/app/src/app/components/Create/SearchBox/elements.ts index 063fb5ecf4d..fefd858a42f 100644 --- a/packages/app/src/app/components/CreateSandbox/SearchBox/elements.ts +++ b/packages/app/src/app/components/Create/SearchBox/elements.ts @@ -15,9 +15,8 @@ export const SearchElement = styled.input` border-radius: 2px; padding-top: 4px; padding-bottom: 4px; - padding-left: 30px; - padding-right: 30px; - width: 176px; + padding-left: 25px; + padding-right: 25px; font-style: normal; font-weight: 400; font-size: 12px; diff --git a/packages/app/src/app/components/CreateSandbox/SearchBox/index.ts b/packages/app/src/app/components/Create/SearchBox/index.ts similarity index 100% rename from packages/app/src/app/components/CreateSandbox/SearchBox/index.ts rename to packages/app/src/app/components/Create/SearchBox/index.ts diff --git a/packages/app/src/app/components/CreateSandbox/TemplateCard.tsx b/packages/app/src/app/components/Create/TemplateCard.tsx similarity index 71% rename from packages/app/src/app/components/CreateSandbox/TemplateCard.tsx rename to packages/app/src/app/components/Create/TemplateCard.tsx index d8c5293709f..24124002045 100644 --- a/packages/app/src/app/components/CreateSandbox/TemplateCard.tsx +++ b/packages/app/src/app/components/Create/TemplateCard.tsx @@ -1,14 +1,6 @@ import React from 'react'; -import { - Badge, - formatNumber, - Icon, - Stack, - Text, -} from '@codesandbox/components'; +import { formatNumber, Icon, Stack, Text } from '@codesandbox/components'; import { getTemplateIcon } from '@codesandbox/common/lib/utils/getTemplateIcon'; -import { docsUrl } from '@codesandbox/common/lib/utils/url-generator'; -import Tooltip from '@codesandbox/common/lib/components/Tooltip'; import { TemplateFragment } from 'app/graphql/types'; import { VisuallyHidden } from 'reakit/VisuallyHidden'; import { useAppState } from 'app/overmind'; @@ -21,7 +13,6 @@ interface TemplateCardProps { onOpenTemplate: (template: TemplateFragment) => void; padding?: number | string; forks?: number; - isOfficial?: boolean; } export const TemplateCard = ({ @@ -31,7 +22,6 @@ export const TemplateCard = ({ onOpenTemplate, padding, forks, - isOfficial, }: TemplateCardProps) => { const { isLoggedIn } = useAppState(); const { UserIcon } = getTemplateIcon( @@ -41,12 +31,11 @@ export const TemplateCard = ({ ); const sandboxTitle = template.sandbox?.title || template.sandbox?.alias; - const isV2 = template.sandbox?.isV2; const teamName = template.sandbox?.team?.name || template.sandbox?.author?.username || - 'GitHub'; + 'CodeSandbox'; return ( - {isOfficial && ( - Official - )} - {!isOfficial && isV2 && ( - - This is a cloud sandbox that runs in a microVM, learn more{' '} - - here - - . - - } - interactive - > -
- Cloud -
-
- )}
by - {isOfficial ? 'CodeSandbox' : teamName} + {teamName} {forks ? ( diff --git a/packages/app/src/app/components/Create/TemplateList.tsx b/packages/app/src/app/components/Create/TemplateList.tsx new file mode 100644 index 00000000000..b437a6df67d --- /dev/null +++ b/packages/app/src/app/components/Create/TemplateList.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Button, Text, Stack } from '@codesandbox/components'; +import { useAppState, useActions } from 'app/overmind'; +import { TemplateFragment } from 'app/graphql/types'; +import track from '@codesandbox/common/lib/utils/analytics'; +import { TemplateCard } from './TemplateCard'; +import { + DevboxAlternative, + SandboxAlternative, + TemplateGrid, +} from './elements'; + +interface TemplateListProps { + title: string; + showEmptyState?: boolean; + searchQuery?: string; + type: 'sandbox' | 'devbox'; + templates: TemplateFragment[]; + onSelectTemplate: (template: TemplateFragment) => void; + onOpenTemplate: (template: TemplateFragment) => void; +} + +export const TemplateList = ({ + title, + templates, + onSelectTemplate, + onOpenTemplate, + showEmptyState = false, + searchQuery, + type, +}: TemplateListProps) => { + const { hasLogIn } = useAppState(); + const actions = useActions(); + + const requireLogin = !hasLogIn && type === 'devbox'; + + return ( + + + + {showEmptyState && templates.length === 0 ? 'No results' : title} + + + + {requireLogin ? ( + + + You need to be signed in to fork a devbox template. + + + + ) : null} + + {templates.length > 0 && ( + + {templates.map(template => ( + + ))} + + )} + + {showEmptyState && searchQuery && templates.length === 0 && ( + + + Not finding what you need? + + + {type === 'devbox' ? ( + { + track(`Create ${type} - Open Community Search`, { + codesandbox: 'V1', + event_source: 'UI - Empty Template List', + }); + }} + /> + ) : ( + { + track(`Create ${type} - Open Devboxes`, { + codesandbox: 'V1', + event_source: 'UI - Empty Template List', + }); + actions.modalOpened({ + modal: 'createDevbox', + }); + }} + /> + )} + + + )} + + ); +}; diff --git a/packages/app/src/app/components/CreateSandbox/elements.ts b/packages/app/src/app/components/Create/elements.tsx similarity index 62% rename from packages/app/src/app/components/CreateSandbox/elements.ts rename to packages/app/src/app/components/Create/elements.tsx index 75c6507d9cd..fb1ba429f84 100644 --- a/packages/app/src/app/components/CreateSandbox/elements.ts +++ b/packages/app/src/app/components/Create/elements.tsx @@ -1,9 +1,10 @@ -import styled from 'styled-components'; -import { Tab as BaseTab, TabList, TabPanel } from 'reakit/Tab'; +import styled, { keyframes } from 'styled-components'; +import React, { ReactNode } from 'react'; +import { Tab as BaseTab, TabList, TabPanel, TabStateReturn } from 'reakit/Tab'; import { Select } from '@codesandbox/components'; export const Container = styled.div` - height: 530px; + height: 564px; overflow: hidden; border-radius: 4px; background-color: #151515; @@ -16,6 +17,16 @@ export const HeaderInformation = styled.div` flex-grow: 1; `; +const fadeIn = keyframes` + from { + opacity: 0; + } + + to { + opacity: 1; + } +`; + export const ModalBody = styled.div` display: flex; flex: 1; @@ -41,7 +52,8 @@ export const ModalSidebar = styled.div` export const ModalContent = styled.div` flex-grow: 1; - padding: 0 24px; + padding: 0 16px 0 24px; + scrollbar-gutter: stable; overflow: auto; @media screen and (max-width: 950px) { @@ -49,6 +61,26 @@ export const ModalContent = styled.div` } `; +interface PanelProps { + tab: TabStateReturn; + id: string; + children: ReactNode; +} + +/** + * The Panel component handles the conditional rendering of the actual panel content. This is + * done with render props as per the Reakit docs. + */ +export const Panel = ({ tab, id, children }: PanelProps) => { + return ( + + {({ hidden, ...rest }) => + hidden ? null :
{children}
+ } +
+ ); +}; + export const Tabs = styled(TabList)` display: flex; flex-direction: column; @@ -104,9 +136,13 @@ export const TemplateButton = styled.button` font-family: inherit; border-radius: 2px; color: #e5e5e5; - transition: background ${props => props.theme.speeds[2]} ease-out; + animation: ${fadeIn} 0.15s ease-in; outline: none; + &:disabled { + animation: none; + } + &:hover:not(:disabled) { background: #252525; } @@ -163,10 +199,51 @@ export const UnstyledButtonLink = styled.button` appearance: none; padding: 0; background: transparent; - color: inherit; + color: #e4fc82; border: none; font-size: inherit; font-family: inherit; - text-decoration: underline; cursor: pointer; `; + +export const DevboxAlternative = ({ + searchQuery, + onClick, +}: { + searchQuery?: string; + onClick: () => void; +}) => { + return ( + <> + Browse more than 3 million community-made templates{' '} + + on our Discover page + + . + + ); +}; + +export const SandboxAlternative = ({ onClick }: { onClick: () => void }) => { + return ( + <> + Devboxes support many more technologies and frameworks, including + back-end. + + Browse Devbox templates + + + ); +}; diff --git a/packages/app/src/app/components/Create/hooks/useAllTemplates.ts b/packages/app/src/app/components/Create/hooks/useAllTemplates.ts new file mode 100644 index 00000000000..0e427524cda --- /dev/null +++ b/packages/app/src/app/components/Create/hooks/useAllTemplates.ts @@ -0,0 +1,47 @@ +import { TemplateFragment } from 'app/graphql/types'; +import { TemplateCollection } from '../utils/types'; + +interface UseAllTemplatesParams { + searchQuery?: string; + featuredTemplates: TemplateFragment[]; + officialTemplates: TemplateFragment[]; + teamTemplates: TemplateFragment[]; + collections: TemplateCollection[]; +} + +export const useAllTemplates = ({ + featuredTemplates, + officialTemplates, + teamTemplates, + collections, + searchQuery, +}: UseAllTemplatesParams) => { + // Using a map to ensure unique entries for templates + const allTemplatesMap: Map = new Map(); + + featuredTemplates.forEach(t => { + allTemplatesMap.set(t.id, t); + }); + + officialTemplates.forEach(t => { + allTemplatesMap.set(t.id, t); + }); + + teamTemplates.forEach(t => { + allTemplatesMap.set(t.id, t); + }); + + collections.forEach(c => { + c.templates.forEach(t => { + allTemplatesMap.set(t.id, t); + }); + }); + + return Array.from(allTemplatesMap.values()).filter(t => + searchQuery + ? (t.sandbox.title || t.sandbox.alias || '') + .toLowerCase() + .includes(searchQuery.trim().toLowerCase()) + : true + ); +}; diff --git a/packages/app/src/app/components/Create/hooks/useFeaturedTemplates.ts b/packages/app/src/app/components/Create/hooks/useFeaturedTemplates.ts new file mode 100644 index 00000000000..663c3b26d7b --- /dev/null +++ b/packages/app/src/app/components/Create/hooks/useFeaturedTemplates.ts @@ -0,0 +1,36 @@ +import { TemplateFragment } from 'app/graphql/types'; + +interface UseFeaturedTemplatesParams { + recentTemplates: TemplateFragment[]; + officialTemplates: TemplateFragment[]; +} + +const FEATURED_IDS = [ + 'lhclt2', // react-new-devbox + 'wmhfhw', // javascript-devbox + 'kcd5jq', // html-css-devbox + '9qputt', // react (vite + ts) + 'fxis37', // next + 'prp60l', // remix + 'pb6sit', // vue + 'angular', // angular + 'hsd8ke', // docker +]; + +export const useFeaturedTemplates = ({ + recentTemplates, + officialTemplates, +}: UseFeaturedTemplatesParams) => { + const recentlyUsedTemplates = recentTemplates.slice(0, 3); + + const hasRecentlyUsedTemplates = recentlyUsedTemplates.length > 0; + + const featuredOfficialTemplates = FEATURED_IDS.map( + id => + // If the template is already in recently used, don't add it twice + !recentlyUsedTemplates.find(t => t.sandbox.id === id) && + officialTemplates.find(t => t.sandbox.id === id) + ).filter(Boolean); + + return featuredOfficialTemplates.slice(0, hasRecentlyUsedTemplates ? 6 : 9); +}; diff --git a/packages/app/src/app/components/Create/hooks/useOfficialTemplates.ts b/packages/app/src/app/components/Create/hooks/useOfficialTemplates.ts new file mode 100644 index 00000000000..8467481dfc7 --- /dev/null +++ b/packages/app/src/app/components/Create/hooks/useOfficialTemplates.ts @@ -0,0 +1,62 @@ +import { TemplateFragment } from 'app/graphql/types'; +import { useEffect, useState } from 'react'; +import { useAppState } from 'app/overmind'; +import { getTemplateInfosFromAPI } from '../utils/api'; + +type State = + | { + state: 'loading'; + templates: TemplateFragment[]; + } + | { + state: 'ready'; + templates: TemplateFragment[]; + } + | { + state: 'error'; + templates: TemplateFragment[]; + }; + +export const useOfficialTemplates = ({ + type, +}: { + type: 'devbox' | 'sandbox'; +}): State => { + const { officialTemplates } = useAppState(); + + const [officialTemplatesData, setOfficialTemplatesData] = useState({ + state: officialTemplates.length > 0 ? 'ready' : 'loading', + templates: officialTemplates, + }); + + useEffect(() => { + async function fetchTemplates() { + try { + const response = await getTemplateInfosFromAPI( + '/api/v1/sandboxes/templates/official' + ); + + setOfficialTemplatesData({ + state: 'ready', + templates: response[0].templates, + }); + } catch { + setOfficialTemplatesData({ + state: 'error', + templates: [], + }); + } + } + + if (officialTemplatesData.state === 'loading') { + fetchTemplates(); + } + }, [officialTemplatesData.state]); + + return { + state: officialTemplatesData.state, + templates: officialTemplatesData.templates.filter(t => + type === 'sandbox' && !t.sandbox.isV2 || type === 'devbox' && t.sandbox.isV2 + ), + }; +}; diff --git a/packages/app/src/app/components/Create/hooks/useTeamTemplates.ts b/packages/app/src/app/components/Create/hooks/useTeamTemplates.ts new file mode 100644 index 00000000000..7d6d3571c24 --- /dev/null +++ b/packages/app/src/app/components/Create/hooks/useTeamTemplates.ts @@ -0,0 +1,91 @@ +import { useQuery } from '@apollo/react-hooks'; +import { + RecentAndWorkspaceTemplatesQuery, + RecentAndWorkspaceTemplatesQueryVariables, + TemplateFragment, +} from 'app/graphql/types'; +import { FETCH_TEAM_TEMPLATES } from '../utils/queries'; + +type BaseState = { + recentTemplates: TemplateFragment[]; + teamTemplates: TemplateFragment[]; +}; + +type State = BaseState & + ( + | { state: 'idle' } + | { + state: 'loading'; + } + | { + state: 'ready'; + } + | { + state: 'error'; + error: string; + } + ); + +type UseTeamTemplatesParams = { + teamId: string; + hasLogIn: boolean; + type: 'devbox' | 'sandbox'; +}; + +export const useTeamTemplates = ({ + teamId, + hasLogIn, + type, +}: UseTeamTemplatesParams): State => { + const skip = !hasLogIn; + + const noDevboxesWhenListingSandboxes = (t: TemplateFragment) => + type === 'sandbox' ? !t.sandbox.isV2 : true; + + const { data, error } = useQuery< + RecentAndWorkspaceTemplatesQuery, + RecentAndWorkspaceTemplatesQueryVariables + >(FETCH_TEAM_TEMPLATES, { + variables: { teamId }, + fetchPolicy: 'cache-and-network', + skip, + }); + + if (skip) { + return { + state: 'idle', + recentTemplates: [], + teamTemplates: [], + }; + } + + if (error) { + return { + state: 'error', + recentTemplates: [], + teamTemplates: [], + error: error.message, + }; + } + + // Instead of checking the loading var we check this. Apollo sets the loading + // var to true even if we still have cached data that we can use. We also need to + // check if `data.me` isnt undefined before getting templates. + if (typeof data?.me === 'undefined') { + return { + state: 'loading', + recentTemplates: [], + teamTemplates: [], + }; + } + + return { + state: 'ready', + recentTemplates: data.me.recentlyUsedTemplates.filter( + noDevboxesWhenListingSandboxes + ), + teamTemplates: data.me.team.templates.filter( + noDevboxesWhenListingSandboxes + ), + }; +}; diff --git a/packages/app/src/app/components/Create/hooks/useTemplateCollections.ts b/packages/app/src/app/components/Create/hooks/useTemplateCollections.ts new file mode 100644 index 00000000000..f2db822d740 --- /dev/null +++ b/packages/app/src/app/components/Create/hooks/useTemplateCollections.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { TemplateCollection } from '../utils/types'; +import { getTemplateInfosFromAPI } from '../utils/api'; + +type TemplateCollectionsState = + | { + state: 'idle'; + collections: TemplateCollection[]; + } + | { + state: 'loading'; + collections: TemplateCollection[]; + } + | { + state: 'success'; + collections: TemplateCollection[]; + } + | { + state: 'error'; + error: string; + collections: TemplateCollection[]; + }; + +export const useTemplateCollections = ({ + type, +}: { + type: 'devbox' | 'sandbox'; +}) => { + const [collectionsState, setCollectionsState] = useState< + TemplateCollectionsState + >({ + state: type === 'devbox' ? 'loading' : 'idle', + collections: [], + }); + + useEffect(() => { + if (collectionsState.state === 'idle' && type === 'devbox') { + setCollectionsState({ state: 'loading', collections: [] }); + } + }, [collectionsState.state, type]); + + useEffect(() => { + async function fetchData() { + try { + const result = await getTemplateInfosFromAPI( + '/api/v1/sandboxes/templates/explore' + ); + + setCollectionsState({ + state: 'success', + collections: result, + }); + } catch { + setCollectionsState({ + state: 'error', + error: 'Something went wrong when fetching more templates, sorry!', + collections: [], + }); + } + } + + if (collectionsState.state === 'loading') { + fetchData(); + } + }, [collectionsState.state]); + + return collectionsState; +}; diff --git a/packages/app/src/app/components/CreateSandbox/utils/api.ts b/packages/app/src/app/components/Create/utils/api.ts similarity index 87% rename from packages/app/src/app/components/CreateSandbox/utils/api.ts rename to packages/app/src/app/components/Create/utils/api.ts index a0c1587e05e..7f1cd2b301a 100644 --- a/packages/app/src/app/components/CreateSandbox/utils/api.ts +++ b/packages/app/src/app/components/Create/utils/api.ts @@ -1,8 +1,8 @@ import { TemplateType } from '@codesandbox/common/lib/templates'; import { isServer } from '@codesandbox/common/lib/templates/helpers/is-server'; -import { TemplateInfo } from '../types'; +import { TemplateCollection } from './types'; -interface IExploreTemplate { +interface ExploreTemplateAPIResponse { title: string; sandboxes: { id: string; @@ -36,8 +36,8 @@ interface IExploreTemplate { } const mapAPIResponseToTemplateInfo = ( - exploreTemplate: IExploreTemplate -): TemplateInfo => ({ + exploreTemplate: ExploreTemplateAPIResponse +): TemplateCollection => ({ key: exploreTemplate.title, title: exploreTemplate.title, templates: exploreTemplate.sandboxes.map(sandbox => ({ @@ -61,7 +61,7 @@ const mapAPIResponseToTemplateInfo = ( team: { name: 'CodeSandbox', }, - isV2: sandbox.v2, + isV2: sandbox.v2 || false, isSse: isServer(sandbox.environment), git: sandbox.git && { id: sandbox.git.id, @@ -75,10 +75,14 @@ const mapAPIResponseToTemplateInfo = ( })), }); -export const getTemplateInfosFromAPI = (url: string): Promise => +export const getTemplateInfosFromAPI = ( + url: string +): Promise => fetch(url) .then(res => res.json()) - .then((body: IExploreTemplate[]) => body.map(mapAPIResponseToTemplateInfo)); + .then((body: ExploreTemplateAPIResponse[]) => + body.map(mapAPIResponseToTemplateInfo) + ); type ValidateRepositoryDestinationFn = ( destination: string diff --git a/packages/app/src/app/components/Create/utils/constants.ts b/packages/app/src/app/components/Create/utils/constants.ts new file mode 100644 index 00000000000..8b6094a1409 --- /dev/null +++ b/packages/app/src/app/components/Create/utils/constants.ts @@ -0,0 +1,6 @@ +export const SANDBOX_BUTTON_DESCRIPTION = + 'Create simple front-end prototypes where all the code runs in your browser'; +export const DEVBOX_BUTTON_DESCRIPTION = + 'Build and share standalone projects of any size in our Cloud Development Environment.'; +export const IMPORT_BUTTON_DESCRIPTION = + 'Run any branch instantly, create and review PRs in our Cloud Development Environment.'; diff --git a/packages/app/src/app/components/CreateSandbox/queries.ts b/packages/app/src/app/components/Create/utils/queries.ts similarity index 83% rename from packages/app/src/app/components/CreateSandbox/queries.ts rename to packages/app/src/app/components/Create/utils/queries.ts index a77e23581f9..81e2a0a203d 100644 --- a/packages/app/src/app/components/CreateSandbox/queries.ts +++ b/packages/app/src/app/components/Create/utils/queries.ts @@ -31,38 +31,14 @@ const TEMPLATE_FRAGMENT = gql` } `; -export const LIST_PERSONAL_TEMPLATES = gql` - query ListPersonalTemplates { +export const FETCH_TEAM_TEMPLATES = gql` + query RecentAndWorkspaceTemplates($teamId: UUID4) { me { - templates { + recentlyUsedTemplates(teamId: $teamId) { ...Template } - recentlyUsedTemplates { - ...Template - - sandbox { - git { - id - username - commitSha - path - repo - branch - } - } - } - - bookmarkedTemplates { - ...Template - } - - teams { - id - name - bookmarkedTemplates { - ...Template - } + team(id: $teamId) { templates { ...Template } diff --git a/packages/app/src/app/components/CreateSandbox/types.ts b/packages/app/src/app/components/Create/utils/types.ts similarity index 51% rename from packages/app/src/app/components/CreateSandbox/types.ts rename to packages/app/src/app/components/Create/utils/types.ts index 81923ff19aa..898af5b37e5 100644 --- a/packages/app/src/app/components/CreateSandbox/types.ts +++ b/packages/app/src/app/components/Create/utils/types.ts @@ -1,12 +1,13 @@ import { TemplateFragment } from 'app/graphql/types'; -export type CreateSandboxParams = { +export type CreateParams = { name?: string; - githubOwner?: string; - createRepo?: boolean; + createAs: 'devbox' | 'sandbox'; + permission: 0 | 1 | 2; + editor: 'web' | 'vscode'; }; -export interface TemplateInfo { +export interface TemplateCollection { title?: string; key: string; templates: TemplateFragment[]; diff --git a/packages/app/src/app/components/CreateSandbox/CreateSandbox.tsx b/packages/app/src/app/components/CreateSandbox/CreateSandbox.tsx deleted file mode 100644 index c6142a92e0e..00000000000 --- a/packages/app/src/app/components/CreateSandbox/CreateSandbox.tsx +++ /dev/null @@ -1,737 +0,0 @@ -import { - Text, - Stack, - Element, - IconButton, - SkeletonText, - ThemeProvider, -} from '@codesandbox/components'; -import { useActions, useAppState } from 'app/overmind'; -import React, { ReactNode, useState, useEffect } from 'react'; -import { TabStateReturn, useTabState } from 'reakit/Tab'; -import slugify from '@codesandbox/common/lib/utils/slugify'; -import { getTemplateIcon } from '@codesandbox/common/lib/utils/getTemplateIcon'; -import { TemplateFragment } from 'app/graphql/types'; -import track from '@codesandbox/common/lib/utils/analytics'; -import { sandboxUrl } from '@codesandbox/common/lib/utils/url-generator'; - -import { useCreateCheckout } from 'app/hooks'; -import { useGlobalPersistedState } from 'app/hooks/usePersistedState'; -import { - Container, - Tab, - TabContent, - Tabs, - HeaderInformation, - ModalContent, - ModalSidebar, - ModalBody, -} from './elements'; -import { Import } from './Import'; -import { TemplateCategoryList } from './TemplateCategoryList'; -import { useEssentialTemplates } from './useEssentialTemplates'; -import { FromTemplate } from './FromTemplate'; -import { useOfficialTemplates } from './useOfficialTemplates'; -import { useTeamTemplates } from './useTeamTemplates'; -import { CreateSandboxParams } from './types'; -import { SearchBox } from './SearchBox'; -import { SearchResults } from './SearchResults'; -import { GithubRepoToImport } from './Import/types'; -import { ImportInfo } from './Import/ImportInfo'; -import { FromRepo } from './Import/FromRepo'; -import { ImportSandbox } from './ImportSandbox'; -import { ExperimentalBetaEditor } from './ExperimentalBetaEditor'; - -export const COLUMN_MEDIA_THRESHOLD = 1600; - -const QUICK_START_IDS = [ - 'new', - 'vanilla', - 'vue', - 'hsd8ke', // docker starter v2 - 'fxis37', // next v2 - '9qputt', // vite + react v2 - 'prp60l', // remix v2 - 'angular', - 'react-ts', - 'rjk9n4zj7m', // static v1 -]; - -interface PanelProps { - tab: TabStateReturn; - id: string; - children: ReactNode; -} - -/** - * The Panel component handles the conditional rendering of the actual panel content. This is - * done with render props as per the Reakit docs. - */ -const Panel = ({ tab, id, children }: PanelProps) => { - return ( - - {({ hidden, ...rest }) => - hidden ? null :
{children}
- } -
- ); -}; - -interface CreateSandboxProps { - collectionId?: string; - initialTab?: 'import'; - isModal?: boolean; -} - -export const CreateSandbox: React.FC = ({ - collectionId, - initialTab, - isModal, -}) => { - const { hasLogIn, activeTeamInfo, user, environment } = useAppState(); - const actions = useActions(); - const isUnderRepositoriesSection = - location.pathname.includes('/my-contributions') || - location.pathname.includes('/repositories'); - const defaultSelectedTab = - initialTab || isUnderRepositoriesSection ? 'import' : 'quickstart'; - const isUser = user?.username === activeTeamInfo?.name; - const mediaQuery = window.matchMedia('screen and (max-width: 950px)'); - const mobileScreenSize = mediaQuery.matches; - - const essentialState = useEssentialTemplates(); - const officialTemplatesData = useOfficialTemplates(); - const teamTemplatesData = useTeamTemplates({ - isUser, - teamId: activeTeamInfo?.id, - hasLogIn, - }); - - const officialTemplates = - officialTemplatesData.state === 'ready' - ? officialTemplatesData.templates - : []; - - /** - * Checking for user because user is undefined when landing on /s/, even though - * hasLogIn is true. Only show the team/my templates if the list is populated. - */ - const showTeamTemplates = - hasLogIn && - user && - teamTemplatesData.state === 'ready' && - teamTemplatesData.teamTemplates.length > 0; - - /** - * For the quick start we show: - * - 3 most recently used templates (if they exist) - * - 6 to 9 templates selected from the official list, ensuring the total number - * of unique templates is 9 (recent + official) - */ - const recentlyUsedTemplates = - teamTemplatesData.state === 'ready' - ? teamTemplatesData.recentTemplates.slice(0, 3) - : []; - - const quickStartOfficialTemplates = - officialTemplatesData.state === 'ready' - ? QUICK_START_IDS.map( - id => - // If the template is already in recently used, don't add it twice - !recentlyUsedTemplates.find(t => t.sandbox.id === id) && - officialTemplates.find(t => t.sandbox.id === id) - ).filter(Boolean) - : []; - - const quickStartTemplates = [ - ...recentlyUsedTemplates, - ...quickStartOfficialTemplates, - ].slice(0, 9); - - const teamTemplates = - teamTemplatesData.state === 'ready' ? teamTemplatesData.teamTemplates : []; - - const tabState = useTabState({ - orientation: mobileScreenSize ? 'horizontal' : 'vertical', - selectedId: defaultSelectedTab, - }); - - const [viewState, setViewState] = useState< - 'initial' | 'fromTemplate' | 'fork' - >('initial'); - // ❗️ We could combine viewState with selectedTemplate - // and selectedRepo to limit the amount of states. - const [selectedTemplate] = useState(); - const [selectedRepo, setSelectedRepo] = useState(); - const [searchQuery, setSearchQuery] = useState(''); - - useEffect(() => { - if (searchQuery) { - track('Create New - Search Templates', { - query: searchQuery, - codesandbox: 'V1', - event_source: 'UI', - }); - } - }, [searchQuery]); - - useEffect(() => { - if (searchQuery && tabState.selectedId) { - setSearchQuery(''); - } - }, [tabState.selectedId]); - - const [, createCheckout, canCheckout] = useCreateCheckout(); - - const onCreateCheckout = () => { - createCheckout({ - trackingLocation: 'dashboard_upgrade_banner', - }); - }; - - const [hasBetaEditorExperiment] = useGlobalPersistedState( - 'BETA_SANDBOX_EDITOR', - false - ); - - const createFromTemplate = ( - template: TemplateFragment, - { name }: CreateSandboxParams - ) => { - const { sandbox } = template; - - actions.editor.forkExternalSandbox({ - sandboxId: sandbox.id, - openInNewWindow: false, - hasBetaEditorExperiment, - body: { - alias: name, - collectionId, - }, - }); - - actions.modals.newSandboxModal.close(); - }; - - const selectTemplate = (template: TemplateFragment) => { - createFromTemplate(template, {}); - - // Temporarily disable the second screen until we have more functionality on it - // setSelectedTemplate(template); - // setViewState('fromTemplate'); - }; - - const openTemplate = (template: TemplateFragment) => { - const { sandbox } = template; - const url = sandboxUrl(sandbox, hasBetaEditorExperiment); - window.open(url, '_blank'); - }; - - const selectGithubRepo = (repo: GithubRepoToImport) => { - setSelectedRepo(repo); - setViewState('fork'); - }; - - const showSearch = !environment.isOnPrem; - const showCloudTemplates = !environment.isOnPrem; - const showImportRepository = !environment.isOnPrem; - - return ( - - - - - {viewState === 'initial' ? ( - - New - - ) : ( - // TODO: add aria-label based on title to IconButton? - { - setViewState('initial'); - }} - /> - )} - - - {showSearch && viewState === 'initial' ? ( - { - const query = e.target.value; - if (query) { - // Reset tab panel when typing in the search query box - tabState.select(null); - } else { - // Restore the default tab when search query is removed - tabState.select(defaultSelectedTab); - } - - setSearchQuery(query); - }} - /> - ) : null} - - {/* isModal is undefined on /s/ page */} - {isModal ? ( - // TODO: IconButton doesn't have aria label or visuallyhidden text (reads floating label too late) - actions.modals.newSandboxModal.close()} - /> - ) : null} - - - - - {viewState === 'initial' ? ( - - - { - track('Create New - Click Tab', { - codesandbox: 'V1', - event_source: 'UI', - tab_name: 'Quick Start', - }); - }} - stopId="quickstart" - > - Quick start - - - {showImportRepository && ( - { - track('Create New - Click Tab', { - codesandbox: 'V1', - event_source: 'UI', - tab_name: 'Import from Github', - }); - }} - stopId="import" - > - Import repository - - )} - - { - track('Create New - Click Tab', { - codesandbox: 'V1', - event_source: 'UI', - tab_name: 'Import template', - }); - }} - stopId="import-template" - > - Import template - - - - - {showTeamTemplates ? ( - { - track(`Create New - Click Tab`, { - codesandbox: 'V1', - event_source: 'UI', - tab_name: `${isUser ? 'My' : 'Team'} Templates`, - }); - }} - stopId="team-templates" - > - {`${isUser ? 'My' : 'Team'} templates`} - - ) : null} - - {showCloudTemplates && ( - { - track('Create New - Click Tab', { - codesandbox: 'V1', - event_source: 'UI', - tab_name: `Cloud templates`, - }); - }} - stopId="cloud-templates" - > - Cloud templates - - )} - - { - track('Create New - Click Tab', { - codesandbox: 'V1', - event_source: 'UI', - tab_name: `Official Templates`, - }); - }} - stopId="official-templates" - > - Official templates - - - {essentialState.state === 'success' - ? essentialState.essentials.map(essential => ( - { - track(`Create New - Click Tab`, { - codesandbox: 'V1', - event_source: 'UI', - tab_name: essential.title, - }); - }} - > - {essential.title} - - )) - : null} - - {!mobileScreenSize && essentialState.state === 'loading' ? ( - - - - - - - - - ) : null} - - {essentialState.state === 'error' ? ( -
{essentialState.error}
- ) : null} -
-
- ) : null} - - {viewState === 'fromTemplate' ? ( - - ) : null} - {viewState === 'fork' ? ( - - ) : null} -
- - - {viewState === 'initial' && - (searchQuery ? ( - - ) : ( - - - { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'fork', - tab_name: 'Quick Start', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - }); - - selectTemplate(template); - }} - onOpenTemplate={template => { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'open', - tab_name: 'Quick Start', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - }); - - openTemplate(template); - }} - /> - - - - - - - - - - - {showTeamTemplates ? ( - - { - track(`Create New - Select template`, { - codesandbox: 'V1', - event_source: 'UI', - type: 'fork', - tab_name: `${ - isUser ? 'My' : activeTeamInfo?.name || 'Team' - } templates`, - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - }); - - selectTemplate(template); - }} - onOpenTemplate={template => { - track(`Create New - Select template`, { - codesandbox: 'V1', - event_source: 'UI', - type: 'open', - tab_name: `${ - isUser ? 'My' : activeTeamInfo?.name || 'Team' - } templates`, - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - }); - - openTemplate(template); - }} - /> - - ) : null} - - - template.sandbox.isV2 - )} - onSelectTemplate={template => { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'fork', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: 'Cloud templates', - }); - - selectTemplate(template); - }} - onOpenTemplate={template => { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'open', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: 'Cloud templates', - }); - - openTemplate(template); - }} - isCloudTemplateList - /> - - - - { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'fork', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: 'Official Templates', - }); - - selectTemplate(template); - }} - onOpenTemplate={template => { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'open', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: 'Official Templates', - }); - - openTemplate(template); - }} - /> - - - {essentialState.state === 'success' - ? essentialState.essentials.map(essential => ( - - { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'fork', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: essential.title, - }); - - selectTemplate(template); - }} - onOpenTemplate={template => { - track('Create New - Select template', { - codesandbox: 'V1', - event_source: 'UI', - type: 'open', - template_name: - template.sandbox.title || - template.sandbox.alias || - template.sandbox.id, - tab_name: essential.title, - }); - - openTemplate(template); - }} - /> - - )) - : null} - {tabState.selectedId === 'quickstart' && ( - - )} - - ))} - - {viewState === 'fromTemplate' ? ( - { - setViewState('initial'); - }} - onSubmit={params => { - createFromTemplate(selectedTemplate, params); - }} - /> - ) : null} - - {viewState === 'fork' ? ( - { - setViewState('initial'); - }} - /> - ) : null} - -
-
-
- ); -}; - -interface TemplateInfoProps { - template: TemplateFragment; -} - -const TemplateInfo = ({ template }: TemplateInfoProps) => { - const { UserIcon } = getTemplateIcon( - template.sandbox.title, - template.iconUrl, - template.sandbox?.source?.template - ); - - return ( - - - - - {template.sandbox.title} - - - {template.sandbox?.team?.name} - - - - {template.sandbox.description} - - - ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/CreateSandboxModal.tsx b/packages/app/src/app/components/CreateSandbox/CreateSandboxModal.tsx deleted file mode 100644 index c945437a913..00000000000 --- a/packages/app/src/app/components/CreateSandbox/CreateSandboxModal.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ThemeProvider } from '@codesandbox/components'; -import Modal from 'app/components/Modal'; -import { useAppState, useActions } from 'app/overmind'; -import { DELETE_ME_COLLECTION } from 'app/overmind/namespaces/dashboard/types'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; - -import { COLUMN_MEDIA_THRESHOLD, CreateSandbox } from './CreateSandbox'; - -/** - * If you have the dashboard open, in a collection path, we want to create new sandboxes - * in that folder. That's why we get that path from the url. - */ -function getImplicitCollectionIdFromFolder( - pathname: string, - collections: DELETE_ME_COLLECTION[] | null -): DELETE_ME_COLLECTION | null { - if (!collectionPathRegex.test(location.pathname)) { - return null; - } - - if (!collections) { - return null; - } - - const collectionPath = decodeURIComponent( - pathname.replace(collectionPathRegex, '') - ); - - const collection = collections.find(c => c.path === collectionPath); - - return collection; -} - -const collectionPathRegex = /^.*dashboard\/sandboxes/; - -export const CreateSandboxModal = () => { - const { modals, dashboard } = useAppState(); - const { modals: modalsActions } = useActions(); - const location = useLocation(); - - const implicitCollection = getImplicitCollectionIdFromFolder( - location.pathname, - dashboard.allCollections - ); - - return ( - - modalsActions.newSandboxModal.close()} - width={window.outerWidth > COLUMN_MEDIA_THRESHOLD ? 1200 : 950} - fullWidth={window.screen.availWidth < 800} - > - - - - ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/ExperimentalBetaEditor.tsx b/packages/app/src/app/components/CreateSandbox/ExperimentalBetaEditor.tsx deleted file mode 100644 index 96e28ff83c7..00000000000 --- a/packages/app/src/app/components/CreateSandbox/ExperimentalBetaEditor.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { - Badge, - MessageStripe, - Stack, - Text, - Switch, -} from '@codesandbox/components'; -import { useGlobalPersistedState } from 'app/hooks/usePersistedState'; -import { useActions } from 'app/overmind'; -import track from '@codesandbox/common/lib/utils/analytics'; -import { UnstyledButtonLink } from './elements'; - -export const ExperimentalBetaEditor = () => { - const actions = useActions(); - const [betaSandboxEditor, setBetaSandboxEditor] = useGlobalPersistedState( - 'BETA_SANDBOX_EDITOR', - false - ); - - if (betaSandboxEditor) { - return ( - - - If you wish to disable the new sandbox editor, open{' '} - { - actions.modals.newSandboxModal.close(); - actions.preferences.openPreferencesModal('experiments'); - }} - > - Preferences - - . - - - ); - } - - return ( - - - - - Beta - - Try the new sandbox editor. - Get a faster and more stable prototyping experience. - - { - setBetaSandboxEditor(true); - track('Enable new sandbox editor - Create modal'); - }} - /> - - - ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/FromTemplate.tsx b/packages/app/src/app/components/CreateSandbox/FromTemplate.tsx deleted file mode 100644 index 072dfd9d71a..00000000000 --- a/packages/app/src/app/components/CreateSandbox/FromTemplate.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useAppState } from 'app/overmind'; -import React, { useState } from 'react'; -import { - Badge, - Stack, - Element, - Checkbox, - Icon, - Button, - Text, -} from '@codesandbox/components'; - -import { StyledSelect } from './elements'; -import { CreateSandboxParams } from './types'; -import { InputText } from '../dashboard/InputText'; - -interface FromTemplateProps { - isV2: boolean; - onCancel: () => void; - onSubmit: (params: CreateSandboxParams) => void; -} - -export const FromTemplate: React.FC = ({ - isV2, - onCancel, - onSubmit, -}) => { - const { hasLogIn, user, dashboard, activeTeam } = useAppState(); - - // TODO: Set generated name as default value if we can / need - // otherwise tell the user if empty we generate a name - const [sandboxName, setSandboxName] = useState(); - - const createRepo = false; - // TODO: Enable when checkbox is active again - // const [createRepo, setCreateRepo] = useState(false); - const [selectedTeam, setSelectedTeam] = useState(activeTeam); - - return ( - - - - New from template - - {isV2 && Cloud} - - - { - e.preventDefault(); - onSubmit({ - name: sandboxName, - createRepo, - githubOwner: selectedTeam, - }); - }} - > - - - setSandboxName(e.target.value)} - aria-describedby="name-desc" - /> - - Leaving this field empty will generate a random name. - - - - - Create git repository (coming soon) -
- } - /> - - {createRepo ? ( - } - onChange={e => { - setSelectedTeam(e.target.value); - }} - value={selectedTeam} - disabled={!hasLogIn || !user || !dashboard.teams} - > - {dashboard.teams.map(team => ( - - ))} - - ) : null} -
- - - - - - - -
- - ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/Icons/GitHubIcon.tsx b/packages/app/src/app/components/CreateSandbox/Icons/GitHubIcon.tsx deleted file mode 100644 index 355f30696d5..00000000000 --- a/packages/app/src/app/components/CreateSandbox/Icons/GitHubIcon.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import IconBase from 'react-icons/IconBase'; - -export const GitHubIcon = props => ( - - - -); diff --git a/packages/app/src/app/components/CreateSandbox/Icons/index.ts b/packages/app/src/app/components/CreateSandbox/Icons/index.ts deleted file mode 100644 index f85c70d516e..00000000000 --- a/packages/app/src/app/components/CreateSandbox/Icons/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { GitHubIcon } from './GitHubIcon'; diff --git a/packages/app/src/app/components/CreateSandbox/Import/index.ts b/packages/app/src/app/components/CreateSandbox/Import/index.ts deleted file mode 100644 index 1c56553c21a..00000000000 --- a/packages/app/src/app/components/CreateSandbox/Import/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Import } from './Import'; diff --git a/packages/app/src/app/components/CreateSandbox/ImportSandbox/index.ts b/packages/app/src/app/components/CreateSandbox/ImportSandbox/index.ts deleted file mode 100644 index 4623b993c30..00000000000 --- a/packages/app/src/app/components/CreateSandbox/ImportSandbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ImportSandbox } from './ImportSandbox'; diff --git a/packages/app/src/app/components/CreateSandbox/Loader/elements.ts b/packages/app/src/app/components/CreateSandbox/Loader/elements.ts deleted file mode 100644 index ef287416a19..00000000000 --- a/packages/app/src/app/components/CreateSandbox/Loader/elements.ts +++ /dev/null @@ -1,11 +0,0 @@ -import styled from 'styled-components'; - -export const LoadingWrapper = styled.div` - overflow: hidden; - height: calc(100%); -`; - -export const Individual = styled.div` - margin: 1.5rem; - margin-bottom: 0; -`; diff --git a/packages/app/src/app/components/CreateSandbox/Loader/index.tsx b/packages/app/src/app/components/CreateSandbox/Loader/index.tsx deleted file mode 100644 index d697c4b54a9..00000000000 --- a/packages/app/src/app/components/CreateSandbox/Loader/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import ContentLoader from 'react-content-loader'; -import { LoadingWrapper, Individual } from './elements'; - -const Loading = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -export const Loader = () => ( - - - - - - - - -); diff --git a/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResultList.tsx b/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResultList.tsx deleted file mode 100644 index 37b1ccfc2cf..00000000000 --- a/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResultList.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect } from 'react'; -import { connectInfiniteHits } from 'react-instantsearch-dom'; -import { InfiniteHitsProvided } from 'react-instantsearch-core'; -import { AlgoliaSandboxHit } from '@codesandbox/common/lib/types/algolia'; -import { isServer } from '@codesandbox/common/lib/templates/helpers/is-server'; -import type { TemplateType } from '@codesandbox/common/lib/templates'; -import { TemplateFragment } from 'app/graphql/types'; -import { TemplateGrid } from '../elements'; -import { TemplateCard } from '../TemplateCard'; - -type ResultsProps = InfiniteHitsProvided & { - disableTemplates?: boolean; - onSelectTemplate: (template: TemplateFragment) => void; - onOpenTemplate: (template: TemplateFragment) => void; - officialTemplates: TemplateFragment[]; -}; - -const Results = (props: ResultsProps) => { - const { disableTemplates, hits } = props; - const bottomDetectionEl = React.useRef(); - - useEffect(() => { - let observer: IntersectionObserver; - const onSentinelIntersection = (entries: IntersectionObserverEntry[]) => { - const { hasMore, refineNext } = props; - - entries.forEach(entry => { - if (entry.isIntersecting && hasMore) { - refineNext(); - } - }); - }; - - if (bottomDetectionEl.current) { - observer = new IntersectionObserver(onSentinelIntersection); - observer.observe(bottomDetectionEl.current); - } - - return () => { - if (observer) { - observer.disconnect(); - } - }; - }, [props]); - - const templates = hits.map(hit => ({ - id: hit.custom_template.id, - color: hit.custom_template.color, - iconUrl: hit.custom_template.icon_url, - published: hit.custom_template.published, - forks: hit.fork_count, - sandbox: { - id: hit.objectID, - alias: hit.alias, - title: hit.title, - description: hit.description, - insertedAt: new Date(hit.inserted_at).toString(), - updatedAt: new Date(hit.updated_at).toString(), - author: hit.author, - isV2: hit.custom_template.v2, - source: { - template: hit.template, - }, - team: hit.team, - isSse: isServer(hit.template as TemplateType), - }, - })); - - return ( - - {templates.map(template => ( - t.id === template.id)} - /> - ))} -
- - ); -}; - -export const SearchResultList = connectInfiniteHits< - ResultsProps, - AlgoliaSandboxHit ->(Results); diff --git a/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResults.tsx b/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResults.tsx deleted file mode 100644 index 17d94e505c6..00000000000 --- a/packages/app/src/app/components/CreateSandbox/SearchResults/SearchResults.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { - ALGOLIA_API_KEY, - ALGOLIA_APPLICATION_ID, - ALGOLIA_DEFAULT_INDEX, // eslint-disable-line -} from '@codesandbox/common/lib/utils/config'; -import { InstantSearch, Configure, Stats } from 'react-instantsearch/dom'; -import { connectStateResults } from 'react-instantsearch-dom'; -import { createGlobalStyle } from 'styled-components'; -import { Text, Stack } from '@codesandbox/components'; -import { TemplateFragment } from 'app/graphql/types'; -import { SearchResultList } from './SearchResultList'; -import { Loader } from '../Loader'; - -const LoadingIndicator = connectStateResults(({ isSearchStalled }) => - isSearchStalled ? : null -); - -const GlobalSearchStyles = createGlobalStyle` -.ais-InstantSearch__root { - display: flex; - flex-direction: column; - height: 100%; -} -`; - -export const SearchResults = ({ - onCreateCheckout, - isInCollection, - search, - onSelectTemplate, - onOpenTemplate, - officialTemplates, -}: { - onCreateCheckout: () => void; - isInCollection: boolean; - search: string; - onSelectTemplate: (template: TemplateFragment) => void; - onOpenTemplate: (template: TemplateFragment) => void; - officialTemplates: TemplateFragment[]; -}) => { - return ( - <> - - - - - - - `${nbHits.toLocaleString()} results found`, - }} - /> - - - - - - - - ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/SearchResults/index.ts b/packages/app/src/app/components/CreateSandbox/SearchResults/index.ts deleted file mode 100644 index c185070d81a..00000000000 --- a/packages/app/src/app/components/CreateSandbox/SearchResults/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchResults } from './SearchResults'; diff --git a/packages/app/src/app/components/CreateSandbox/TemplateCategoryList.tsx b/packages/app/src/app/components/CreateSandbox/TemplateCategoryList.tsx deleted file mode 100644 index 9fdcf629af5..00000000000 --- a/packages/app/src/app/components/CreateSandbox/TemplateCategoryList.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect } from 'react'; -import track from '@codesandbox/common/lib/utils/analytics'; -import { Button, Text, Stack } from '@codesandbox/components'; -import { css } from '@styled-system/css'; -import { useAppState, useActions } from 'app/overmind'; -import { TemplateFragment } from 'app/graphql/types'; -import { TemplateCard } from './TemplateCard'; -import { TemplateGrid } from './elements'; - -interface TemplateCategoryListProps { - title: string; - onCreateCheckout: () => void; - isCloudTemplateList?: boolean; - isInCollection: boolean; - templates: TemplateFragment[]; - onSelectTemplate: (template: TemplateFragment) => void; - onOpenTemplate: (template: TemplateFragment) => void; - canCheckout: boolean; -} - -export const TemplateCategoryList = ({ - title, - onCreateCheckout, - isCloudTemplateList, - isInCollection, - templates, - onSelectTemplate, - onOpenTemplate, - canCheckout, -}: TemplateCategoryListProps) => { - const { hasLogIn } = useAppState(); - const actions = useActions(); - - useEffect(() => { - track('Create Sandbox Tab Open', { tab: title }); - }, [title]); - - return ( - - - - {title} - - - - {!hasLogIn && isCloudTemplateList ? ( - - - You need to be signed in to fork a cloud template. - - - - ) : null} - - {templates.length > 0 ? ( - templates.map(template => ( - - )) - ) : ( -
No templates for this category.
- )} -
-
- ); -}; diff --git a/packages/app/src/app/components/CreateSandbox/index.ts b/packages/app/src/app/components/CreateSandbox/index.ts deleted file mode 100644 index ab9c4b4e24c..00000000000 --- a/packages/app/src/app/components/CreateSandbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CreateSandbox, COLUMN_MEDIA_THRESHOLD } from './CreateSandbox'; diff --git a/packages/app/src/app/components/CreateSandbox/useEssentialTemplates.ts b/packages/app/src/app/components/CreateSandbox/useEssentialTemplates.ts deleted file mode 100644 index bff393d564d..00000000000 --- a/packages/app/src/app/components/CreateSandbox/useEssentialTemplates.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from 'react'; -import { TemplateInfo } from './types'; -import { getTemplateInfosFromAPI } from './utils/api'; - -type EssentialsState = - | { - state: 'loading'; - } - | { - state: 'success'; - essentials: TemplateInfo[]; - } - | { - state: 'error'; - error: string; - }; - -export const useEssentialTemplates = () => { - const [essentialState, setEssentialState] = useState({ - state: 'loading', - }); - - useEffect(() => { - async function getEssentials() { - try { - const result = await getTemplateInfosFromAPI( - '/api/v1/sandboxes/templates/explore' - ); - - setEssentialState({ - state: 'success', - essentials: result, - }); - } catch { - setEssentialState({ - state: 'error', - error: 'Something went wrong when fetching more templates, sorry!', - }); - } - } - - if (essentialState.state === 'loading') { - getEssentials(); - } - }, [essentialState.state]); - - return essentialState; -}; diff --git a/packages/app/src/app/components/CreateSandbox/useOfficialTemplates.ts b/packages/app/src/app/components/CreateSandbox/useOfficialTemplates.ts deleted file mode 100644 index d1fed1a1332..00000000000 --- a/packages/app/src/app/components/CreateSandbox/useOfficialTemplates.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { TemplateFragment } from 'app/graphql/types'; -import { useEffect, useState } from 'react'; -import { getTemplateInfosFromAPI } from './utils/api'; - -type State = - | { - state: 'loading'; - } - | { - state: 'ready'; - templates: TemplateFragment[]; - } - | { - state: 'error'; - error: string; - }; - -export const useOfficialTemplates = (): State => { - const [officialTemplates, setOfficialTemplates] = useState({ - state: 'loading', - }); - - useEffect(() => { - async function fetchTemplates() { - try { - const response = await getTemplateInfosFromAPI( - '/api/v1/sandboxes/templates/official' - ); - - setOfficialTemplates({ - state: 'ready', - templates: response[0].templates, - }); - } catch { - setOfficialTemplates({ - state: 'error', - error: 'Something went wrong when fetching more templates, sorry!', - }); - } - } - - if (officialTemplates.state === 'loading') { - fetchTemplates(); - } - }, [officialTemplates.state]); - - return officialTemplates; -}; diff --git a/packages/app/src/app/components/CreateSandbox/useTeamTemplates.ts b/packages/app/src/app/components/CreateSandbox/useTeamTemplates.ts deleted file mode 100644 index 7ea54f72910..00000000000 --- a/packages/app/src/app/components/CreateSandbox/useTeamTemplates.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useQuery } from '@apollo/react-hooks'; -import { - ListPersonalTemplatesQuery, - ListPersonalTemplatesQueryVariables, - TemplateFragment, -} from 'app/graphql/types'; -import { LIST_PERSONAL_TEMPLATES } from './queries'; - -type State = - | { state: 'loading' } - | { - state: 'ready'; - recentTemplates: TemplateFragment[]; - teamTemplates: TemplateFragment[]; - } - | { - state: 'error'; - error: string; - }; - -function getUserTemplates(data: ListPersonalTemplatesQuery) { - return data.me.templates; -} - -function getTeamTemplates(data: ListPersonalTemplatesQuery, teamId: string) { - return data.me.teams.find(team => team.id === teamId)?.templates || []; -} - -type UseTeamTemplatesParams = { - isUser: boolean; - teamId?: string; - hasLogIn: boolean; -}; - -export const useTeamTemplates = ({ - isUser, - teamId, - hasLogIn, -}: UseTeamTemplatesParams): State => { - const { data, error } = useQuery< - ListPersonalTemplatesQuery, - ListPersonalTemplatesQueryVariables - >(LIST_PERSONAL_TEMPLATES, { - /** - * With LIST_PERSONAL_TEMPLATES we're also fetching team templates. We're reusing - * this query here because it has already been preloaded and cached by overmind. We're - * filtering what we need later. - */ - variables: {}, - fetchPolicy: 'cache-and-network', - skip: !hasLogIn, - }); - - if (error) { - return { - state: 'error', - error: error.message, - }; - } - - // Instead of checking the loading var we check this. Apollo sets the loading - // var to true even if we still have cached data that we can use. We also need to - // check if `data.me` isnt undefined before getting templates. - if (typeof data?.me === 'undefined') { - return { - state: 'loading', - }; - } - - const teamTemplates = - isUser || !teamId ? getUserTemplates(data) : getTeamTemplates(data, teamId); - - return { - state: 'ready', - recentTemplates: data.me.recentlyUsedTemplates, - teamTemplates, - }; -}; diff --git a/packages/app/src/app/components/Preview/DevTools/TerminalUpgrade/index.tsx b/packages/app/src/app/components/Preview/DevTools/TerminalUpgrade/index.tsx index 9989de534ca..08d68602f3c 100644 --- a/packages/app/src/app/components/Preview/DevTools/TerminalUpgrade/index.tsx +++ b/packages/app/src/app/components/Preview/DevTools/TerminalUpgrade/index.tsx @@ -38,19 +38,18 @@ export const TerminalUpgradeComponent: React.FC = ({ gap={4} > - To use the terminal, you need to upgrade your Browser Sandbox into a - Cloud Sandbox. + To use the terminal, you need to upgrade your Sandbox into a Devbox. - Cloud Sandboxes are an improved coding experience that runs your code - in the cloud. They bring new languages, servers, databases, - a built-in AI assistant, and much more. See a preview below. + Devboxes are an improved coding experience that runs your code in + the cloud. They bring new languages, servers, databases, a built-in + AI assistant, and much more. See a preview below. {canConvert - ? `Do you want to convert it into a Cloud Sandbox?` - : `Do you want to fork into a Cloud Sandbox?`} + ? `Do you want to convert it into a Devbox?` + : `Do you want to fork into a Devbox?`} { return ( - This sandbox runs much faster in our new editor. Do you want to{' '} - {canConvert ? 'convert' : 'fork'} it to a Cloud Sandbox? + This sandbox runs much faster as a devbox. Do you want to{' '} + {canConvert ? 'convert' : 'fork'} it? {canConvert ? 'Yes, convert' : 'Yes, fork'} diff --git a/packages/app/src/app/components/dashboard/LargeCTAButton.tsx b/packages/app/src/app/components/dashboard/LargeCTAButton.tsx new file mode 100644 index 00000000000..710c6b3095e --- /dev/null +++ b/packages/app/src/app/components/dashboard/LargeCTAButton.tsx @@ -0,0 +1,83 @@ +import styled from 'styled-components'; +import React from 'react'; +import { Icon, IconNames, Stack, Text } from '@codesandbox/components'; + +type LargeCTAButtonProps = { + title: string; + subtitle?: string; + icon: IconNames; + variant?: 'primary' | 'secondary'; + alignment?: 'horizontal' | 'vertical'; +} & Pick, 'onClick'>; + +export const LargeCTAButton = ({ + title, + subtitle, + icon, + onClick, + variant = 'secondary', + alignment = 'horizontal', +}: LargeCTAButtonProps) => { + return ( + + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + ); +}; + +const StyledButton = styled.button` + all: unset; + display: flex; + align-items: stretch; + padding: 0; + border: 0; + border-radius: 4px; + height: 80px; + overflow: hidden; + background-color: #ffffff; + color: #0e0e0e; + font-family: inherit; + font-weight: 500; + line-height: 16px; + transition: background-color 75ms ease; + + &:hover { + background-color: #ededed; + cursor: pointer; + } + + &:focus-visible { + background-color: #ededed; + outline: 2px solid #9581ff; + } +`; + +const StyledStackContent = styled(Stack)` + padding: 12px 16px; + min-height: 80px; +`; diff --git a/packages/app/src/app/constants.ts b/packages/app/src/app/constants.ts index 42fd9b75d10..d91bfc6247b 100644 --- a/packages/app/src/app/constants.ts +++ b/packages/app/src/app/constants.ts @@ -17,54 +17,18 @@ export interface Feature { highlighted?: boolean; } -export const FREE_FEATURES: Feature[] = [ - { - key: 'editor', - label: 'Single editor', - }, - { - key: 'public_limit', - label: 'Public repositories & sandboxes', - }, - { - key: 'ai', - label: 'No access to AI tools', - }, - { key: 'npm', label: 'Public npm packages' }, - { key: 'permissions', label: 'Limited permissions' }, - { key: 'vm_mem', label: '2GiB RAM' }, - { key: 'vm_cpu', label: '2 vCPUs' }, - { key: 'vm_disk', label: '6GB Disk' }, -]; - -export const PERSONAL_PRO_FEATURES: Feature[] = [ - { - key: 'editor', - label: 'Single editor', - }, - { - key: 'limit_sandboxes', - label: 'Unlimited private repositories & sandboxes', - }, - { - key: 'ai', - label: '✨ Full access to AI tools', - }, - { key: 'npm', label: 'Public npm packages' }, - { key: 'live_sessions', label: 'Live sessions' }, - { key: 'vm_mem', label: '8GiB RAM' }, - { key: 'vm_cpu', label: '4 vCPUs' }, - { key: 'vm_disk', label: '12GB Disk' }, -]; - export const TEAM_PRO_FEATURES: Feature[] = [ { key: 'editors', label: 'Unlimited editors', }, { - key: 'private', - label: 'Unlimited private repositories & sandboxes', + key: 'repos', + label: 'Unlimited private repositories', + }, + { + key: 'boxes', + label: 'Unlimited private devboxes and sandboxes', }, { key: 'ai', @@ -84,8 +48,12 @@ export const TEAM_PRO_FEATURES_WITH_PILLS: Feature[] = [ label: 'Unlimited editors', }, { - key: 'private', - label: 'Unlimited private repositories & sandboxes', + key: 'repos', + label: 'Unlimited private repositories', + }, + { + key: 'boxes', + label: 'Unlimited private devboxes and sandboxes', }, { key: 'ai', diff --git a/packages/app/src/app/graphql/types.ts b/packages/app/src/app/graphql/types.ts index 974da1a106b..513f529c9da 100644 --- a/packages/app/src/app/graphql/types.ts +++ b/packages/app/src/app/graphql/types.ts @@ -72,6 +72,14 @@ export type RootQueryType = { */ branchByName: Branch; curatedAlbums: Array; + /** + * Check for an active live session + * + * Accessible to members of the content's workspace and non-editor guests that have already + * joined the session. Prospective guests should use the mutation `joinLiveSession` with the + * session's ID instead. + */ + getLiveSession: Maybe; /** Get git repo and related V1 sandboxes */ git: Maybe; /** @@ -173,6 +181,10 @@ export type RootQueryTypeBranchByNameArgs = { team: InputMaybe; }; +export type RootQueryTypeGetLiveSessionArgs = { + vmId: Scalars['ID']; +}; + export type RootQueryTypeGitArgs = { branch: Scalars['String']; path: Scalars['String']; @@ -1171,6 +1183,86 @@ export enum GitProvider { Github = 'GITHUB', } +/** + * Live session started by an editor of a cloud sandbox for the benefit of guest users + * + * In a live session, there are editors (people who naturally have write access to the content) + * and guests (a superset that includes editors and other individuals who join the live session). + * Editors always have write permission, while non-editor guests have permissions determined by + * the default level-of-access or an individual override for that particular user. See mutations + * `setLiveSessionDefaultPermission` and `setLiveSessionGuestPermission` for more information. + */ +export type LiveSession = { + __typename?: 'LiveSession'; + /** + * Default level of access for non-editor guests + * + * Editors of the cloud sandbox can change this using the `setLiveSessionDefaultPermission` + * mutation. + */ + defaultPermission: LiveSessionPermission; + /** + * Guests of a live session + * + * This list includes all non-editor guests, regardless of their level of access. Users who have + * natural write access to the content will not appear here. + */ + guests: Array; + /** User that started the live session */ + host: Maybe; + /** Session identifier, used for client-side caching */ + id: Scalars['ID']; + /** Timestamp of the time when the live session opened (ISO 8601) */ + startedAt: Scalars['String']; + /** + * Timestamp of the time when the live session ended (ISO 8601) + * + * In practice, this field will only be visible in response to a `stopLiveSession` mutation or + * as the final message of a `liveSessionEvents` subscription. + */ + stoppedAt: Maybe; + /** ID of the related sandbox VM */ + vmId: Scalars['ID']; +}; + +/** Level of access for a live session */ +export enum LiveSessionPermission { + Read = 'READ', + Write = 'WRITE', +} + +/** + * Guest of a live session + * + * This record represents a non-editor guest of a live session. + */ +export type LiveSessionGuest = { + __typename?: 'LiveSessionGuest'; + /** URL of the user's avatar image */ + avatarUrl: Scalars['String']; + /** Level of access for the guest */ + permission: LiveSessionPermission; + /** CodeSandbox user ID */ + userId: Scalars['ID']; + /** CodeSandbox username */ + username: Scalars['String']; +}; + +/** + * Hose of a live session + * + * This user initially started the live session. + */ +export type LiveSessionHost = { + __typename?: 'LiveSessionHost'; + /** URL of the user's avatar image */ + avatarUrl: Scalars['String']; + /** CodeSandbox user ID */ + userId: Scalars['ID']; + /** CodeSandbox username */ + username: Scalars['String']; +}; + /** Details about a repository as it appears on GitHub (Open API `repository`) */ export type GithubRepo = { __typename?: 'GithubRepo'; @@ -1705,6 +1797,13 @@ export type RootMutationType = { inviteToTeam: Team; /** Invite someone to a team via email */ inviteToTeamViaEmail: Scalars['String']; + /** + * Join an existing live session + * + * Accessible to non-editor guests for content that has an existing live session. For editors, + * this mutation is a no-op. Returns an error if no live session exists. + */ + joinLiveSession: LiveSession; /** Leave a team */ leaveTeam: Scalars['String']; /** Make templates from sandboxes */ @@ -1781,6 +1880,31 @@ export type RootMutationType = { setBranchProtection: Branch; /** Set the default authorization for any new members joining this workspace */ setDefaultTeamMemberAuthorization: WorkspaceSandboxSettings; + /** + * Set the default level of access for guests in a live session + * + * Accessible to editors of the underlying content. + * + * With a default permission of `READ`, guests join with the ability to read the code and watch + * changes taking place without making changes of their own, like a classroom mode. With `WRITE`, + * all new guests will be able to make changes immediately. + * + * Individual guest permissions can be overridden using the `setLiveSessionGuestPermission` + * mutation. Changing the default permission does not reset any individual guest permissions + * set using the `setLiveSessionGuestPermission` mutation. It also does not affect editors (those + * who naturally have access to the content). + */ + setLiveSessionDefaultPermission: LiveSession; + /** + * Set the level of access for a specific guest in a live session + * + * Accessible to editors of the underlying content. + * + * If an individual guest should have a level of access different than the default permission + * set using the `setLiveSessionDefaultPermission` mutation, this mutation allows targeted + * access. It has no effect on editors (those who naturally have access to the content). + */ + setLiveSessionGuestPermission: LiveSession; setPreventSandboxesExport: Array; setPreventSandboxesLeavingWorkspace: Array; /** set sandbox always on status */ @@ -1797,6 +1921,22 @@ export type RootMutationType = { setTeamName: Team; setWorkspaceSandboxSettings: WorkspaceSandboxSettings; softCancelSubscription: ProSubscription; + /** + * Begin a new live session for a running VM + * + * Accessible to editors of the underlying content as long as live sessions are allowed by the + * content and its workspace. + * + * The live session will be automatically stopped a few minutes after the VM session ends, or + * immediately after calling the `stopLiveSession` mutation. + */ + startLiveSession: LiveSession; + /** + * Immediately close a live session + * + * Accessible to editors of the underlying content. + */ + stopLiveSession: LiveSession; /** Unbookmark a template */ unbookmarkTemplate: Maybe