Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remix projects context menu #4893

Merged
merged 8 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions utopia-remix/app/components/projectActionContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'

import { useFetcher } from '@remix-run/react'
import React from 'react'
import { Category } from '../routes/projects'
import { ProjectWithoutContent } from '../types'
import { assertNever } from '../util/assertNever'
import { projectEditorLink } from '../util/links'
import { contextMenuItem } from '../styles/contextMenuItem.css'
import { colors } from '../styles/sprinkles.css'

type ContextMenuEntry =
| {
text: string
onClick: (project: ProjectWithoutContent) => void
}
| 'separator'

export const ProjectContextMenu = React.memo(
({
selectedCategory,
project,
}: {
selectedCategory: Category
project: ProjectWithoutContent
}) => {
const fetcher = useFetcher()

const deleteProject = React.useCallback(
(projectId: string) => {
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/delete` })
},
[fetcher],
)

const destroyProject = React.useCallback(
(projectId: string) => {
const ok = window.confirm('Are you sure? The project contents will be deleted permanently.')
if (ok) {
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/destroy` })
}
},
[fetcher],
)

const restoreProject = React.useCallback(
(projectId: string) => {
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/restore` })
},
[fetcher],
)

const renameProject = React.useCallback(
(projectId: string, newTitle: string) => {
fetcher.submit(
{ title: newTitle },
{ method: 'POST', action: `/internal/projects/${projectId}/rename` },
)
},
[fetcher],
)

const menuEntries = React.useMemo((): ContextMenuEntry[] => {
switch (selectedCategory) {
case 'allProjects':
return [
{
text: 'Open',
onClick: (project) => {
window.open(projectEditorLink(project.proj_id), '_blank')
},
},
'separator',
{
text: 'Copy Link',
onClick: (project) => {
navigator.clipboard.writeText(projectEditorLink(project.proj_id))
// TODO notification toast
},
},
'separator',
{
text: 'Rename',
onClick: (project) => {
const newTitle = window.prompt('New title:', project.title)
if (newTitle != null) {
renameProject(project.proj_id, newTitle)
}
},
},
{
text: 'Delete',
onClick: (project) => {
deleteProject(project.proj_id)
},
},
]
case 'trash':
return [
{
text: 'Restore',
onClick: (project) => {
restoreProject(project.proj_id)
},
},
'separator',
{
text: 'Delete permanently',
onClick: (project) => {
destroyProject(project.proj_id)
},
},
]
default:
assertNever(selectedCategory)
}
}, [selectedCategory])

return (
<>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{
background: 'white',
padding: 4,
boxShadow: '2px 3px 4px #dddddd',
border: '1px solid #ccc',
borderRadius: 4,
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 100,
}}
sideOffset={5}
>
{menuEntries.map((entry, index) => {
if (entry === 'separator') {
return (
<DropdownMenu.Separator
key={`separator-${index}`}
style={{ backgroundColor: colors.separator, height: 1 }}
/>
)
}
return (
<DropdownMenu.Item
key={`entry-${index}`}
onClick={() => entry.onClick(project)}
className={contextMenuItem()}
>
{entry.text}
</DropdownMenu.Item>
)
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>

<fetcher.Form />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this self closing tag do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's the form component for the fetcher used to send the requests, as per the remix docs https://remix.run/docs/en/main/hooks/use-fetcher#fetcherform

</>
)
},
)
ProjectContextMenu.displayName = 'ProjectContextMenu'
4 changes: 4 additions & 0 deletions utopia-remix/app/radix-fix.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
body {
border: 1px solid transparent; /* Fix jumpy content when opening dropdowns */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks truly horrible!

}

2 changes: 2 additions & 0 deletions utopia-remix/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { BrowserEnvironment } from './env.server'
import { styles } from './styles/styles.css'

import './radix-fix.css'

declare global {
interface Window {
ENV: BrowserEnvironment
Expand Down
91 changes: 44 additions & 47 deletions utopia-remix/app/routes/projects.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { LoaderFunctionArgs, json } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useLoaderData } from '@remix-run/react'
import moment from 'moment'
import { UserDetails } from 'prisma-client'
import React, { useEffect, useState } from 'react'
import { ProjectContextMenu } from '../components/projectActionContextMenu'
import { listDeletedProjects, listProjects } from '../models/project.server'
import { newProjectButton } from '../styles/newProjectButton.css'
import { projectCategoryButton, userName } from '../styles/sidebarComponents.css'
import { sprinkles } from '../styles/sprinkles.css'
import { requireUser } from '../util/api.server'
import { ProjectWithoutContent } from '../types'
import { requireUser } from '../util/api.server'
import { assertNever } from '../util/assertNever'
import { projectEditorLink } from '../util/links'

import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { button } from '../styles/button.css'

export async function loader(args: LoaderFunctionArgs) {
Expand Down Expand Up @@ -38,6 +42,7 @@ const categories: { [key in Category]: { name: string } } = {
allProjects: { name: 'All My Projects' },
trash: { name: 'Trash' },
}

const ProjectsPage = React.memo(() => {
const marginSize = 30
const rowHeight = 30
Expand Down Expand Up @@ -104,7 +109,7 @@ const ProjectsPage = React.memo(() => {
}, [searchValue, projects])

const createNewProject = () => {
window.open(`${window.ENV.EDITOR_URL}/project/`, '_blank')
window.open(projectEditorLink(null), '_blank')
}

const newProjectButtons = [
Expand Down Expand Up @@ -154,7 +159,7 @@ const ProjectsPage = React.memo(() => {
}
}, [])

const logoPic = isDarkMode ? 'url(assets/pyramid_dark.png)' : 'url(assets/pyramid_light.png)'
const logoPic = isDarkMode ? 'url(/assets/pyramid_dark.png)' : 'url(/assets/pyramid_light.png)'

return (
<div
Expand Down Expand Up @@ -335,6 +340,7 @@ const ProjectsPage = React.memo(() => {
project={project}
selected={project.proj_id === selectedProject.selectedProjectId}
onSelect={() => handleProjectSelect(project.proj_id)}
selectedCategory={selectedCategory}
/>
))}
</div>
Expand All @@ -350,11 +356,17 @@ type ProjectCardProps = {
project: ProjectWithoutContent
selected: boolean
onSelect: () => void
selectedCategory: Category
}

const ProjectCard: React.FC<ProjectCardProps> = ({ project, selected, onSelect }) => {
const ProjectCard: React.FC<ProjectCardProps> = ({
project,
selected,
onSelect,
selectedCategory,
}) => {
const openProject = React.useCallback(() => {
window.open(`${window.ENV.EDITOR_URL}/p/${project.proj_id}`, '_blank')
window.open(projectEditorLink(project.proj_id), '_blank')
}, [project.proj_id])

return (
Expand All @@ -381,50 +393,35 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, selected, onSelect }
onMouseDown={onSelect}
onDoubleClick={openProject}
/>
<ProjectActions project={project} />
<ProjectActions project={project} selectedCategory={selectedCategory} />
</div>
)
}

const ProjectActions = React.memo(({ project }: { project: ProjectWithoutContent }) => {
const fetcher = useFetcher()

const deleteProject = React.useCallback(() => {
if (project.deleted === true) {
const ok = window.confirm('Are you sure? The project contents will be deleted permanently.')
if (ok) {
fetcher.submit(
{},
{ method: 'POST', action: `/internal/projects/${project.proj_id}/destroy` },
)
}
} else {
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${project.proj_id}/delete` })
}
}, [fetcher])

const restoreProject = React.useCallback(() => {
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${project.proj_id}/restore` })
}, [fetcher])

return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', padding: 10, gap: 5, flex: 1 }}>
<div style={{ fontWeight: 600 }}>{project.title}</div>
<div>{moment(project.modified_at).fromNow()}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
{project.deleted === true ? (
<button className={button({ size: 'small' })} onClick={restoreProject}>
Restore
</button>
) : null}
<button className={button({ color: 'danger', size: 'small' })} onClick={deleteProject}>
Delete
</button>
<fetcher.Form />
const ProjectActions = React.memo(
({
project,
selectedCategory,
}: {
project: ProjectWithoutContent
selectedCategory: Category
}) => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', padding: 10, gap: 5, flex: 1 }}>
<div style={{ fontWeight: 600 }}>{project.title}</div>
<div>{moment(project.modified_at).fromNow()}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className={button()}>…</button>
</DropdownMenu.Trigger>
<ProjectContextMenu selectedCategory={selectedCategory} project={project} />
</DropdownMenu.Root>
</div>
</div>
</div>
)
})
)
},
)
ProjectActions.displayName = 'ProjectActions'
21 changes: 21 additions & 0 deletions utopia-remix/app/styles/contextMenuItem.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { recipe } from '@vanilla-extract/recipes'
import { colors, sprinkles } from './sprinkles.css'

export const contextMenuItem = recipe({
base: [
sprinkles({
borderRadius: 'small',
color: 'lightModeBlack',
}),
{
outline: 'none',
padding: '6px 8px',
cursor: 'pointer',
border: 'none !important',
':hover': {
backgroundColor: colors.primary,
color: 'white',
},
},
],
})
3 changes: 2 additions & 1 deletion utopia-remix/app/styles/sprinkles.css.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles'

const colors = {
export const colors = {
black: '#000',
white: '#fff',
primary: '#0075F9',
aqua: '#00E3E3',
darkModeBlack: '#181C20',
lightModeBlack: '#2B2B2B',
separator: '#dddddd',
}

const colorProperties = defineProperties({
Expand Down
9 changes: 9 additions & 0 deletions utopia-remix/app/util/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import urlJoin from 'url-join'

export function projectEditorLink(projectId: string | null): string {
const editorURL = window.ENV.EDITOR_URL
if (editorURL == null) {
throw new Error('missing editor url')
}
return urlJoin(editorURL, 'project', projectId ?? '')
}
1 change: 1 addition & 0 deletions utopia-remix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@prisma/client": "5.9.0",
"@radix-ui/react-dropdown-menu": "2.0.6",
"@remix-run/css-bundle": "2.5.1",
"@remix-run/node": "2.5.1",
"@remix-run/react": "2.5.1",
Expand Down
Loading
Loading