mirror of
https://github.com/enso-org/enso.git
synced 2024-11-30 05:35:09 +03:00
Separated templates and directory list (#6995)
Closes https://github.com/enso-org/cloud-v2/issues/490: - Make templates collapsible - Add scroll-based shadow to templates - Move scrollbars from body to templates and directory listing # Important Notes None
This commit is contained in:
parent
66894bd79e
commit
8504295e5e
4
app/ide-desktop/lib/assets/rotating_arrow.svg
Normal file
4
app/ide-desktop/lib/assets/rotating_arrow.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 4.93L5 11.07A.5.5 0 0 0 5.77 11.5L10.4 8.4A.5.5 0 0 0 10.4 7.6L5.77 4.5A.5.5 0 0 0 5 4.93"
|
||||
fill="#3e515fe5" />
|
||||
</svg>
|
After Width: | Height: | Size: 225 B |
@ -1061,7 +1061,7 @@ function Dashboard(props: DashboardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col relative select-none text-primary text-xs min-h-screen p-2 ${
|
||||
className={`flex flex-col gap-2 relative select-none text-primary text-xs h-screen py-2 ${
|
||||
tab === Tab.dashboard ? '' : 'hidden'
|
||||
}`}
|
||||
onClick={event => {
|
||||
@ -1091,21 +1091,21 @@ function Dashboard(props: DashboardProps) {
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{isListingRemoteDirectoryWhileOffline ? (
|
||||
<div className="grow grid place-items-center">
|
||||
<div className="grow grid place-items-center mx-2">
|
||||
<div className="text-base text-center">
|
||||
You are offline. Please connect to the internet and refresh to access the
|
||||
cloud backend.
|
||||
</div>
|
||||
</div>
|
||||
) : isListingLocalDirectoryAndWillFail ? (
|
||||
<div className="grow grid place-items-center">
|
||||
<div className="grow grid place-items-center mx-2">
|
||||
<div className="text-base text-center">
|
||||
Could not connect to the Project Manager. Please try restarting{' '}
|
||||
{common.PRODUCT_NAME}, or manually launching the Project Manager.
|
||||
</div>
|
||||
</div>
|
||||
) : isListingRemoteDirectoryAndWillFail ? (
|
||||
<div className="grow grid place-items-center">
|
||||
<div className="grow grid place-items-center mx-2">
|
||||
<div className="text-base text-center">
|
||||
We will review your user details and enable the cloud experience for you
|
||||
shortly.
|
||||
@ -1114,7 +1114,7 @@ function Dashboard(props: DashboardProps) {
|
||||
) : (
|
||||
<>
|
||||
<Templates onTemplateClick={handleCreateProject} />
|
||||
<div className="flex flex-row flex-nowrap my-2">
|
||||
<div className="flex flex-row flex-nowrap mx-2">
|
||||
<h1 className="text-xl font-bold mx-4 self-center">Drive</h1>
|
||||
<div className="flex flex-row flex-nowrap mx-4">
|
||||
{backend.type === backendModule.BackendType.remote && (
|
||||
@ -1224,361 +1224,385 @@ function Dashboard(props: DashboardProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<table className="table-fixed items-center border-collapse mt-2 w-0">
|
||||
<tbody>
|
||||
<tr className="h-10">
|
||||
{columnsFor(columnDisplayMode, backend.type).map(column => (
|
||||
<td key={column} className={COLUMN_CSS_CLASS[column]} />
|
||||
))}
|
||||
</tr>
|
||||
<Rows<backendModule.Asset<backendModule.AssetType.project>>
|
||||
items={visibleProjectAssets}
|
||||
getKey={projectAsset => projectAsset.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
You have no project yet. Go ahead and create one using the
|
||||
form above.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(columnDisplayMode, backend.type).map(
|
||||
column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.project
|
||||
),
|
||||
render: renderer(column, backendModule.AssetType.project),
|
||||
})
|
||||
)}
|
||||
onClick={(projectAsset, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, projectAsset]
|
||||
: [projectAsset]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(projectAsset, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doOpenForEditing = () => {
|
||||
unsetModal()
|
||||
setProjectEvent({
|
||||
type: projectActionButton.ProjectEventType.open,
|
||||
projectId: projectAsset.id,
|
||||
{/* Padding. */}
|
||||
<div className="h-6 mx-2" />
|
||||
<div className="flex-1 overflow-auto mx-2">
|
||||
<table className="table-fixed items-center border-collapse mt-2">
|
||||
<tbody>
|
||||
<tr className="h-0">
|
||||
{columnsFor(columnDisplayMode, backend.type).map(column => (
|
||||
<td key={column} className={COLUMN_CSS_CLASS[column]} />
|
||||
))}
|
||||
</tr>
|
||||
<Rows<backendModule.Asset<backendModule.AssetType.project>>
|
||||
items={visibleProjectAssets}
|
||||
getKey={projectAsset => projectAsset.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
You have no projects yet. Go ahead and create one using
|
||||
the form above.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(columnDisplayMode, backend.type).map(
|
||||
column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.project
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.project
|
||||
),
|
||||
})
|
||||
}
|
||||
const doOpenAsFolder = () => {
|
||||
// FIXME[sb]: Uncomment once backend support
|
||||
// is in place.
|
||||
// The following code does not typecheck
|
||||
// since `ProjectId`s are not `DirectoryId`s.
|
||||
// enterDirectory(projectAsset)
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doRename = () => {
|
||||
setModal(() => (
|
||||
<RenameModal
|
||||
name={projectAsset.title}
|
||||
assetType={projectAsset.type}
|
||||
{...(backend.type ===
|
||||
backendModule.BackendType.local
|
||||
? {
|
||||
namePattern:
|
||||
'[A-Z][a-z]*(?:_\\d+|_[A-Z][a-z]*)*',
|
||||
title:
|
||||
'Names must be in Upper_Snake_Case. ' +
|
||||
'(Numbers (_0, _1) are also allowed.)',
|
||||
}
|
||||
: {})}
|
||||
doRename={async newName => {
|
||||
setProjectAssets(
|
||||
arrayWithAssetTitleChanged(
|
||||
projectAssets,
|
||||
projectAsset,
|
||||
newName
|
||||
)}
|
||||
onClick={(projectAsset, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, projectAsset]
|
||||
: [projectAsset]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(projectAsset, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doOpenForEditing = () => {
|
||||
unsetModal()
|
||||
setProjectEvent({
|
||||
type: projectActionButton.ProjectEventType.open,
|
||||
projectId: projectAsset.id,
|
||||
})
|
||||
}
|
||||
const doOpenAsFolder = () => {
|
||||
// TODO[sb]: Implement once backend support is in place.
|
||||
// https://github.com/enso-org/cloud-v2/issues/506
|
||||
// The following code does not typecheck
|
||||
// since `ProjectId`s are not `DirectoryId`s.
|
||||
// enterDirectory(projectAsset)
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doRename = () => {
|
||||
setModal(() => (
|
||||
<RenameModal
|
||||
name={projectAsset.title}
|
||||
assetType={projectAsset.type}
|
||||
{...(backend.type ===
|
||||
backendModule.BackendType.local
|
||||
? {
|
||||
namePattern:
|
||||
'[A-Z][a-z]*(?:_\\d+|_[A-Z][a-z]*)*',
|
||||
title:
|
||||
'Names must be in Upper_Snake_Case. ' +
|
||||
'(Numbers (_0, _1) are also allowed.)',
|
||||
}
|
||||
: {})}
|
||||
doRename={async newName => {
|
||||
setProjectAssets(
|
||||
arrayWithAssetTitleChanged(
|
||||
projectAssets,
|
||||
projectAsset,
|
||||
newName
|
||||
)
|
||||
)
|
||||
)
|
||||
await backend.projectUpdate(projectAsset.id, {
|
||||
ami: null,
|
||||
ideVersion: null,
|
||||
projectName: newName,
|
||||
})
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={projectAsset.title}
|
||||
assetType={projectAsset.type}
|
||||
doDelete={() => {
|
||||
setProjectAssets(
|
||||
arrayWithAssetOmitted(
|
||||
projectAssets,
|
||||
projectAsset
|
||||
await backend.projectUpdate(
|
||||
projectAsset.id,
|
||||
{
|
||||
ami: null,
|
||||
ideVersion: null,
|
||||
projectName: newName,
|
||||
}
|
||||
)
|
||||
)
|
||||
return backend.deleteProject(projectAsset.id)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
const isDisabled =
|
||||
backend.type === backendModule.BackendType.local &&
|
||||
(projectDatas[projectAsset.id]?.isRunning ?? false)
|
||||
setModal(() => (
|
||||
<ContextMenu key={projectAsset.id} event={event}>
|
||||
<ContextMenuEntry onClick={doOpenForEditing}>
|
||||
Open for editing
|
||||
</ContextMenuEntry>
|
||||
{backend.type !== backendModule.BackendType.local && (
|
||||
<ContextMenuEntry disabled onClick={doOpenAsFolder}>
|
||||
Open as folder
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={projectAsset.title}
|
||||
assetType={projectAsset.type}
|
||||
doDelete={() => {
|
||||
setProjectAssets(
|
||||
arrayWithAssetOmitted(
|
||||
projectAssets,
|
||||
projectAsset
|
||||
)
|
||||
)
|
||||
return backend.deleteProject(
|
||||
projectAsset.id
|
||||
)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
const isDeleteDisabled =
|
||||
backend.type === backendModule.BackendType.local &&
|
||||
(projectDatas[projectAsset.id]?.isRunning ?? false)
|
||||
setModal(() => (
|
||||
<ContextMenu key={projectAsset.id} event={event}>
|
||||
<ContextMenuEntry onClick={doOpenForEditing}>
|
||||
Open for editing
|
||||
</ContextMenuEntry>
|
||||
)}
|
||||
<ContextMenuEntry onClick={doRename}>
|
||||
Rename
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
disabled={isDisabled}
|
||||
{...(isDisabled
|
||||
? {
|
||||
title: 'A running local project cannot be removed.',
|
||||
}
|
||||
: {})}
|
||||
onClick={doDelete}
|
||||
{backend.type !==
|
||||
backendModule.BackendType.local && (
|
||||
<ContextMenuEntry
|
||||
disabled
|
||||
onClick={doOpenAsFolder}
|
||||
>
|
||||
Open as folder
|
||||
</ContextMenuEntry>
|
||||
)}
|
||||
<ContextMenuEntry onClick={doRename}>
|
||||
Rename
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
disabled={isDeleteDisabled}
|
||||
{...(isDeleteDisabled
|
||||
? {
|
||||
title: 'A running local project cannot be removed.',
|
||||
}
|
||||
: {})}
|
||||
onClick={doDelete}
|
||||
>
|
||||
<span className="text-red-700">Delete</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
{backend.type === backendModule.BackendType.remote &&
|
||||
(remoteBackend => (
|
||||
<>
|
||||
<tr className="h-10" />
|
||||
<Rows<
|
||||
backendModule.Asset<backendModule.AssetType.directory>
|
||||
>
|
||||
<span className="text-red-700">Delete</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
{backend.type === backendModule.BackendType.remote &&
|
||||
(remoteBackend => (
|
||||
<>
|
||||
<tr className="h-10" />
|
||||
<Rows<
|
||||
backendModule.Asset<backendModule.AssetType.directory>
|
||||
>
|
||||
items={visibleDirectoryAssets}
|
||||
getKey={directoryAsset => directoryAsset.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any
|
||||
subdirectories
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.directory
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.directory
|
||||
),
|
||||
}))}
|
||||
onClick={(directoryAsset, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, directoryAsset]
|
||||
: [directoryAsset]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(directoryAsset, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(() => (
|
||||
<ContextMenu
|
||||
key={directoryAsset.id}
|
||||
event={event}
|
||||
></ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
<tr className="h-10" />
|
||||
<Rows<backendModule.Asset<backendModule.AssetType.secret>>
|
||||
items={visibleSecretAssets}
|
||||
getKey={secret => secret.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any secrets
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.secret
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.secret
|
||||
),
|
||||
}))}
|
||||
onClick={(secret, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, secret]
|
||||
: [secret]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(secret, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
items={visibleDirectoryAssets}
|
||||
getKey={directoryAsset => directoryAsset.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any
|
||||
subdirectories
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.directory
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.directory
|
||||
),
|
||||
}))}
|
||||
onClick={(directoryAsset, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, directoryAsset]
|
||||
: [directoryAsset]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(directoryAsset, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={secret.title}
|
||||
assetType={secret.type}
|
||||
doDelete={() => {
|
||||
setSecretAssets(
|
||||
arrayWithAssetOmitted(
|
||||
secretAssets,
|
||||
secret
|
||||
)
|
||||
)
|
||||
return remoteBackend.deleteSecret(
|
||||
secret.id
|
||||
)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
<ContextMenu
|
||||
key={directoryAsset.id}
|
||||
event={event}
|
||||
></ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
<tr className="h-10" />
|
||||
<Rows<
|
||||
backendModule.Asset<backendModule.AssetType.secret>
|
||||
>
|
||||
items={visibleSecretAssets}
|
||||
getKey={secret => secret.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any secrets
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
setModal(() => (
|
||||
<ContextMenu key={secret.id} event={event}>
|
||||
<ContextMenuEntry onClick={doDelete}>
|
||||
<span className="text-red-700">
|
||||
Delete
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
<tr className="h-10" />
|
||||
<Rows<backendModule.Asset<backendModule.AssetType.file>>
|
||||
items={visibleFileAssets}
|
||||
getKey={file => file.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any files
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.file
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.file
|
||||
),
|
||||
}))}
|
||||
onClick={(file, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, file]
|
||||
: [file]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(file, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doCopy = () => {
|
||||
// TODO: Wait for backend endpoint.
|
||||
}
|
||||
const doCut = () => {
|
||||
// TODO: Wait for backend endpoint.
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.secret
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.secret
|
||||
),
|
||||
}))}
|
||||
onClick={(secret, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, secret]
|
||||
: [secret]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(secret, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={secret.title}
|
||||
assetType={secret.type}
|
||||
doDelete={() => {
|
||||
setSecretAssets(
|
||||
arrayWithAssetOmitted(
|
||||
secretAssets,
|
||||
secret
|
||||
)
|
||||
)
|
||||
return remoteBackend.deleteSecret(
|
||||
secret.id
|
||||
)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={file.title}
|
||||
assetType={file.type}
|
||||
doDelete={() => {
|
||||
setFileAssets(
|
||||
arrayWithAssetOmitted(
|
||||
fileAssets,
|
||||
file
|
||||
)
|
||||
)
|
||||
return remoteBackend.deleteFile(
|
||||
file.id
|
||||
)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
<ContextMenu key={secret.id} event={event}>
|
||||
<ContextMenuEntry onClick={doDelete}>
|
||||
<span className="text-red-700">
|
||||
Delete
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
<tr className="h-10" />
|
||||
<Rows<backendModule.Asset<backendModule.AssetType.file>>
|
||||
items={visibleFileAssets}
|
||||
getKey={file => file.id}
|
||||
isLoading={isLoadingAssets}
|
||||
placeholder={
|
||||
<span className="opacity-75">
|
||||
This directory does not contain any files
|
||||
{query ? ' matching your query' : ''}.
|
||||
</span>
|
||||
}
|
||||
const doDownload = () => {
|
||||
/** TODO: Wait for backend endpoint. */
|
||||
}
|
||||
setModal(() => (
|
||||
<ContextMenu key={file.id} event={event}>
|
||||
<ContextMenuEntry disabled onClick={doCopy}>
|
||||
Copy
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry disabled onClick={doCut}>
|
||||
Cut
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry onClick={doDelete}>
|
||||
<span className="text-red-700">
|
||||
Delete
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
disabled
|
||||
onClick={doDownload}
|
||||
>
|
||||
Download
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
))(backend)}
|
||||
</tbody>
|
||||
</table>
|
||||
columns={columnsFor(
|
||||
columnDisplayMode,
|
||||
backend.type
|
||||
).map(column => ({
|
||||
id: column,
|
||||
heading: ColumnHeading(
|
||||
column,
|
||||
backendModule.AssetType.file
|
||||
),
|
||||
render: renderer(
|
||||
column,
|
||||
backendModule.AssetType.file
|
||||
),
|
||||
}))}
|
||||
onClick={(file, event) => {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
setSelectedAssets(
|
||||
event.shiftKey
|
||||
? [...selectedAssets, file]
|
||||
: [file]
|
||||
)
|
||||
}}
|
||||
onContextMenu={(file, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doCopy = () => {
|
||||
// TODO: Wait for backend endpoint.
|
||||
}
|
||||
const doCut = () => {
|
||||
// TODO: Wait for backend endpoint.
|
||||
}
|
||||
// This is not a React component even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const doDelete = () => {
|
||||
setModal(() => (
|
||||
<ConfirmDeleteModal
|
||||
name={file.title}
|
||||
assetType={file.type}
|
||||
doDelete={() => {
|
||||
setFileAssets(
|
||||
arrayWithAssetOmitted(
|
||||
fileAssets,
|
||||
file
|
||||
)
|
||||
)
|
||||
return remoteBackend.deleteFile(
|
||||
file.id
|
||||
)
|
||||
}}
|
||||
onComplete={doRefresh}
|
||||
/>
|
||||
))
|
||||
}
|
||||
const doDownload = () => {
|
||||
/** TODO: Wait for backend endpoint. */
|
||||
}
|
||||
setModal(() => (
|
||||
<ContextMenu key={file.id} event={event}>
|
||||
<ContextMenuEntry
|
||||
disabled
|
||||
onClick={doCopy}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
disabled
|
||||
onClick={doCut}
|
||||
>
|
||||
Cut
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry onClick={doDelete}>
|
||||
<span className="text-red-700">
|
||||
Delete
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
disabled
|
||||
onClick={doDownload}
|
||||
>
|
||||
Download
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
))(backend)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isFileBeingDragged &&
|
||||
directoryId != null &&
|
||||
backend.type === backendModule.BackendType.remote ? (
|
||||
|
@ -1,5 +1,35 @@
|
||||
/** @file Renders the list of templates from which a project can be created. */
|
||||
import * as react from 'react'
|
||||
|
||||
import PlusCircledIcon from 'enso-assets/plus_circled.svg'
|
||||
import RotatingArrowIcon from 'enso-assets/rotating_arrow.svg'
|
||||
|
||||
import * as common from 'enso-common'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The `localStorage` key used to store whether the {@link Templates} element should be
|
||||
* expanded by default. */
|
||||
const IS_TEMPLATES_OPEN_KEY = `${common.PRODUCT_NAME.toLowerCase()}-is-templates-expanded`
|
||||
/** The max width at which the bottom shadow should be visible. */
|
||||
const MAX_WIDTH_NEEDING_SCROLL = 1031
|
||||
/** The height of the bottom padding - 8px for the grid gap, and another 8px for the height
|
||||
* of the padding div. */
|
||||
const PADDING_HEIGHT = 16
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The CSS class to apply inset shadows on the specified side(s). */
|
||||
enum ShadowClass {
|
||||
none = '',
|
||||
top = 'shadow-inset-t-lg',
|
||||
bottom = 'shadow-inset-b-lg',
|
||||
both = 'shadow-inset-v-lg',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Templates ===
|
||||
@ -77,10 +107,8 @@ function TemplatesRender(props: TemplatesRenderProps) {
|
||||
className="h-40 cursor-pointer"
|
||||
>
|
||||
<div className="flex h-full w-full border-dashed-custom rounded-2xl text-primary">
|
||||
<div className="m-auto text-center">
|
||||
<button>
|
||||
<img src={PlusCircledIcon} />
|
||||
</button>
|
||||
<div className="flex flex-col text-center items-center m-auto">
|
||||
<img src={PlusCircledIcon} />
|
||||
<p className="font-semibold text-sm">New empty project</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -130,10 +158,94 @@ export interface TemplatesProps {
|
||||
function Templates(props: TemplatesProps) {
|
||||
const { onTemplateClick } = props
|
||||
|
||||
const [shadowClass, setShadowClass] = react.useState(
|
||||
window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? ShadowClass.bottom : ShadowClass.none
|
||||
)
|
||||
const [isOpen, setIsOpen] = react.useState(() => {
|
||||
/** This must not be in a `useEffect` as it would flash open for one frame.
|
||||
* It can be in a `useLayoutEffect` but as that needs to be checked every re-render,
|
||||
* this is slightly more performant. */
|
||||
const savedIsOpen = localStorage.getItem(IS_TEMPLATES_OPEN_KEY)
|
||||
let result = true
|
||||
if (savedIsOpen != null) {
|
||||
try {
|
||||
result = JSON.parse(savedIsOpen) !== false
|
||||
} catch {
|
||||
// Ignored. This should only happen when a user manually sets invalid JSON into
|
||||
// the `localStorage` key used by this component.
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// This is incorrect, but SAFE, as its value will always be assigned before any hooks are run.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const containerRef = react.useRef<HTMLDivElement>(null!)
|
||||
|
||||
const toggleIsOpen = react.useCallback(() => {
|
||||
setIsOpen(oldIsOpen => !oldIsOpen)
|
||||
}, [])
|
||||
|
||||
const updateShadowClass = () => {
|
||||
const element = containerRef.current
|
||||
const boundingBox = element.getBoundingClientRect()
|
||||
let newShadowClass: ShadowClass
|
||||
const shouldShowTopShadow = element.scrollTop !== 0
|
||||
// `window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL` is repeated. This is intentional,
|
||||
// to avoid adding it as a dependency.
|
||||
const paddingHeight = window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? 0 : PADDING_HEIGHT
|
||||
// Chrome has decimal places in its bounding box, which can overshoot the target size
|
||||
// slightly.
|
||||
const shouldShowBottomShadow =
|
||||
element.scrollTop + boundingBox.height + paddingHeight + 1 < element.scrollHeight
|
||||
if (shouldShowTopShadow && shouldShowBottomShadow) {
|
||||
newShadowClass = ShadowClass.both
|
||||
} else if (shouldShowTopShadow) {
|
||||
newShadowClass = ShadowClass.top
|
||||
} else if (shouldShowBottomShadow) {
|
||||
newShadowClass = ShadowClass.bottom
|
||||
} else {
|
||||
newShadowClass = ShadowClass.none
|
||||
}
|
||||
setShadowClass(newShadowClass)
|
||||
}
|
||||
|
||||
react.useEffect(() => {
|
||||
window.addEventListener('resize', updateShadowClass)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateShadowClass)
|
||||
}
|
||||
})
|
||||
|
||||
react.useEffect(() => {
|
||||
localStorage.setItem(IS_TEMPLATES_OPEN_KEY, JSON.stringify(isOpen))
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="my-2 p-2">
|
||||
<div className="grid gap-2 grid-cols-fill-60-minmax-scrollbar-aware justify-center">
|
||||
<div className="mx-2">
|
||||
<div className="flex items-center my-2">
|
||||
<div className="w-4">
|
||||
<div
|
||||
className={`cursor-pointer transition-all ease-in-out ${
|
||||
isOpen ? 'rotate-90' : ''
|
||||
}`}
|
||||
onClick={toggleIsOpen}
|
||||
>
|
||||
<img src={RotatingArrowIcon} />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold self-center">Templates</h1>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`grid gap-2 grid-cols-fill-60 justify-center overflow-y-scroll scroll-hidden transition-all duration-300 ease-in-out px-4 ${
|
||||
isOpen ? `h-templates-custom ${shadowClass}` : 'h-0'
|
||||
}`}
|
||||
onScroll={updateShadowClass}
|
||||
>
|
||||
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
|
||||
{/* Spacing. */}
|
||||
<div className="col-span-full h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -57,7 +57,7 @@ function TopBar(props: TopBarProps) {
|
||||
}, [isUserMenuVisible])
|
||||
|
||||
return (
|
||||
<div className="flex mb-2 h-8">
|
||||
<div className="flex mx-2 h-8">
|
||||
{supportsLocalBackend && (
|
||||
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5">
|
||||
<button
|
||||
|
@ -107,73 +107,19 @@ body {
|
||||
background-image: url("enso-assets/dashed_border.svg");
|
||||
}
|
||||
|
||||
.grid-cols-fill-60-minmax-scrollbar-aware {
|
||||
/* Graceful degradation for extremely large monitors. */
|
||||
@media screen and (min-width: 1275px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
}
|
||||
.scroll-hidden {
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
/* Each column is 240px (tile) + 8px (padding). */
|
||||
/* Hardcore scrollbar-aware values for monitors up to 3840px wide. */
|
||||
.h-templates-custom {
|
||||
height: 21.5rem;
|
||||
|
||||
@media screen and (min-width: 3755px) and (max-width: 4002px) {
|
||||
grid-template-columns: repeat(14, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 3507px) and (max-width: 3754px) {
|
||||
grid-template-columns: repeat(14, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 3259px) and (max-width: 3506px) {
|
||||
grid-template-columns: repeat(13, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 3011px) and (max-width: 3258px) {
|
||||
grid-template-columns: repeat(12, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2763px) and (max-width: 3010px) {
|
||||
grid-template-columns: repeat(11, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2515px) and (max-width: 2762px) {
|
||||
grid-template-columns: repeat(10, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2267px) and (max-width: 2514px) {
|
||||
grid-template-columns: repeat(9, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2019px) and (max-width: 2266px) {
|
||||
grid-template-columns: repeat(8, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1771px) and (max-width: 2018px) {
|
||||
grid-template-columns: repeat(7, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1523px) and (max-width: 1770px) {
|
||||
grid-template-columns: repeat(6, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1275px) and (max-width: 1522px) {
|
||||
grid-template-columns: repeat(5, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1027px) and (max-width: 1274px) {
|
||||
grid-template-columns: repeat(4, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 779px) and (max-width: 1026px) {
|
||||
grid-template-columns: repeat(3, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (min-width: 531px) and (max-width: 778px) {
|
||||
grid-template-columns: repeat(2, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 530) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
@media screen and (min-width: 1771px) {
|
||||
height: 11rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +48,21 @@ export const theme = {
|
||||
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
|
||||
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
|
||||
0 18px 80px 0 #0000001c`,
|
||||
'inset-t-lg': `inset 0 1px 1.4px -1.4px #00000002, \
|
||||
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
|
||||
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
|
||||
inset 0 36px 51px -51px #00000014`,
|
||||
'inset-b-lg': `inset 0 -1px 1.4px -1.4px #00000002, \
|
||||
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
|
||||
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
|
||||
inset 0 -36px 51px -51px #00000014`,
|
||||
'inset-v-lg': `inset 0 1px 1.4px -1.4px #00000002, \
|
||||
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
|
||||
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
|
||||
inset 0 36px 51px -51px #00000014, inset 0 -1px 1.4px -1.4px #00000002, \
|
||||
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
|
||||
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
|
||||
inset 0 -36px 51px -51px #00000014`,
|
||||
},
|
||||
animation: {
|
||||
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
||||
@ -59,5 +74,8 @@ export const theme = {
|
||||
transitionDuration: {
|
||||
'90000': '90000ms',
|
||||
},
|
||||
gridTemplateColumns: {
|
||||
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user