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:
somebody1234 2023-06-30 23:59:34 +10:00 committed by GitHub
parent 66894bd79e
commit 8504295e5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 525 additions and 421 deletions

View 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

View File

@ -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 ? (

View File

@ -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>
)

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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))',
},
},
}