Home screen (#7517)

- Closes https://github.com/enso-org/cloud-v2/issues/580
- Adds home screen

Other changes:
- Typing in the search bar from the home page, switches to the drive page. This is easy to change/remove, of course

# Important Notes
There are minor differences from the design:
- The Enso logo has opacity 0.6 to match the text color, rather than 0.665
- The list of samples is different
- The border-radius on the "create empty project" tile was changed from 18px to 16px, to match every other border-radius (especially the ones on the tiles for the other samples)

Implementation notes:
- The "new project" circled plus icon has a different color to the primary text color as well, but that has been left as-is
- The sample descriptions have a backdrop-blur, but the background image no longer extends underneath it
- There are currently no inset shadows for the home screen, but it will be easy to copy them from the old implementation from the old templates list
- "Read what's new in Enso 3.0 Beta" currently links to https://enso.org/, rather than a blog post (which does not yet exist)
- The new template backgrounds have been replaced with SVGs. The Excel one uses the Excel logo from Wikipedia; the new geospatial analysis one was converted to SVG via auto-tracing.

There are also several placeholders:
- Sample author icon
- Sample author
- Sample open count
- Sample like ount
This commit is contained in:
somebody1234 2023-09-01 01:03:39 +10:00 committed by GitHub
parent eefe74ed93
commit 73b864640b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1747 additions and 480 deletions

View File

@ -1,4 +0,0 @@
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="none" rx="16" ry="16" stroke="#3e515f" stroke-width="4"
stroke-dasharray="11 11" stroke-linecap="butt" />
</svg>

Before

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,5 @@
<svg width="100" height="67" viewBox="0 0 100 67" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M84.3624 5.61115C77.858 2.95206 70.9914 1.06562 63.938 0C62.9728 1.54072 62.0995 3.12592 61.3217 4.74896C53.8084 3.738 46.1679 3.738 38.6546 4.74896C37.8764 3.12609 37.0031 1.54091 36.0383 0C28.9804 1.07461 22.1092 2.96554 15.5983 5.62505C2.67241 22.7018 -0.831596 39.3545 0.920406 55.7708C8.49013 60.7649 16.9628 64.563 25.9701 67C27.9983 64.5642 29.793 61.9801 31.3352 59.2751C28.4061 58.2983 25.579 57.093 22.8866 55.6734C23.5952 55.2145 24.2882 54.7417 24.9579 54.2828C32.792 57.5726 41.3426 59.2783 49.9998 59.2783C58.657 59.2783 67.2076 57.5726 75.0418 54.2828C75.7192 54.7765 76.4122 55.2493 77.113 55.6734C74.4155 57.0953 71.5832 58.3029 68.6489 59.2821C70.1892 61.9858 71.984 64.5677 74.0139 67C83.029 64.5728 91.5081 60.7765 99.0792 55.7777C101.135 36.7401 95.5675 20.2405 84.3624 5.61115ZM33.5544 45.6749C28.6721 45.6749 24.6386 41.7186 24.6386 36.8514C24.6386 31.9842 28.532 27.9932 33.5388 27.9932C38.5456 27.9932 42.548 31.9842 42.4623 36.8514C42.3767 41.7186 38.5301 45.6749 33.5544 45.6749ZM66.4453 45.6749C61.5552 45.6749 57.5373 41.7186 57.5373 36.8514C57.5373 31.9842 61.4307 27.9932 66.4453 27.9932C71.4599 27.9932 75.4311 31.9842 75.3455 36.8514C75.2598 41.7186 71.421 45.6749 66.4453 45.6749Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0.4 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.4" cy="8" r="7" stroke="black" stroke-width="2" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M12.02 10.8112C12.3647 10.4101 12.573 9.88852 12.573 9.31828C12.573 8.05263 11.5469 7.02661 10.2813 7.02661C9.49094 7.02661 8.79404 7.4267 8.38207 8.0354C7.79798 8.82866 6.85752 9.34336 5.79686 9.34336C5.07403 9.34336 4.40704 9.10432 3.87061 8.70098L3.87076 8.70196C3.98182 9.41859 4.26135 10.0986 4.68636 10.6862C5.11138 11.2738 5.66976 11.7522 6.31561 12.082C6.96147 12.4118 7.67636 12.5836 8.40155 12.5834C9.12673 12.5831 9.84151 12.4108 10.4871 12.0806C11.0855 11.7745 11.6086 11.3409 12.02 10.8112Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 317 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0.8 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15.3316 8.82566L8.80005 15L2.26851 8.82566C1.49808 8.12297 0.972708 7.15615 0.835606 6.06842L0.800049 6H0.827513C0.809365 5.83583 0.800049 5.669 0.800049 5.5C0.800049 3.01472 2.81477 1 5.30005 1C6.71368 1 7.97507 1.65183 8.80005 2.67134C9.62503 1.65183 10.8864 1 12.3 1C14.7853 1 16.8 3.01472 16.8 5.5C16.8 5.669 16.7907 5.83583 16.7726 6H16.8L16.7645 6.06842C16.6274 7.15615 16.102 8.12297 15.3316 8.82566Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0.8 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16.8 2V6H14.8V3.41425L10.2162 7.99806C9.82571 8.38859 9.19254 8.38859 8.80202 7.99806C8.41149 7.60754 8.41149 6.97437 8.80202 6.58385L13.3859 2H10.8V0H14.8C15.9046 0 16.8 0.89543 16.8 2ZM2.80005 2H8.80005V0H2.80005C1.69548 0 0.800049 0.89543 0.800049 2V14C0.800049 15.1046 1.69548 16 2.80005 16H14.8C15.9046 16 16.8 15.1046 16.8 14V8H14.8V14H2.80005V2Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 544 B

View File

@ -1,4 +0,0 @@
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke-width="0.5" stroke="#3e515fe5"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

Before

Width:  |  Height:  |  Size: 255 B

View File

@ -0,0 +1,5 @@
<svg width="51" height="50" viewBox="0 0 51 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2 12H24.2V24H13.2V26H24.2V36H26.2V26H37.2V24H26.2V12Z"
fill="black" fill-opacity="0.66" />
<rect x="1.19995" y="1" width="48" height="48" rx="24" stroke="black" stroke-opacity="0.66" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View File

@ -0,0 +1,33 @@
<svg width="159" height="53.4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 6409.83 2130">
<defs>
<g id="excel">
<path fill="#185C37"
d="M1437.8 1011.8 532.5 852v1180.4c0 53.9 43.7 97.6 97.6 97.6h1562c54 0 97.7-43.7 97.7-97.6v-434.9l-852-585.8z" />
<path fill="#21A366"
d="M1437.8 0H630c-53.9 0-97.6 43.7-97.6 97.6v434.9l905.3 532.5 479.2 159.8 372.8-159.8V532.5L1437.8 0z" />
<path fill="#107C41" d="M532.5 532.5h905.3V1065H532.5V532.5z" />
<path d="M1180.4 426H532.5v1331.3h647.9a98 98 0 0 0 97.6-97.7v-1136a98 98 0 0 0-97.6-97.6z" opacity=".1" />
<path d="M1127.1 479.3H532.5v1331.2h594.6a98 98 0 0 0 97.7-97.6v-1136a98 98 0 0 0-97.7-97.6z"
opacity=".2" />
<path d="M1127.1 479.3H532.5V1704h594.6a98 98 0 0 0 97.7-97.6V576.9a98 98 0 0 0-97.7-97.6z" opacity=".2" />
<path d="M1073.9 479.3H532.5V1704h541.4a98 98 0 0 0 97.6-97.6V576.9a98 98 0 0 0-97.6-97.6z" opacity=".2" />
<linearGradient id="a" x1="203.5" x2="968" y1="1729" y2="405" gradientTransform="matrix(1 0 0 -1 0 2132)"
gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#18884f" />
<stop offset=".5" style="stop-color:#117e43" />
<stop offset="1" style="stop-color:#0b6631" />
</linearGradient>
<path fill="url(#a)"
d="M97.6 479.3H1074c53.9 0 97.6 43.6 97.6 97.6V1553c0 54-43.7 97.6-97.6 97.6H97.6c-53.9 0-97.6-43.7-97.6-97.6V577c0-54 43.7-97.6 97.6-97.6z" />
<path fill="#FFF"
d="M302.3 1382.3 507.6 1064 319.5 747.7h151.3L573.5 950c9.5 19.3 16 33.5 19.5 43h1.3a798.7 798.7 0 0 1 21.3-44.7l109.8-200.5h138.9l-193 314.5 197.9 320H721.4l-118.6-222.1a186.3 186.3 0 0 1-14.2-29.7H587c-3.5 10-8.1 19.7-13.8 28.8l-122 223H302.2z" />
<path fill="#33C481" d="M2192.1 0h-754.3v532.5h852V97.6C2289.8 43.7 2246 0 2192 0z" />
<path fill="#107C41" d="M1437.8 1065h852v532.5h-852V1065z" />
</g>
</defs>
<use xlink:href="#excel" />
<rect x="2681.1" y="1010.8" width="1043.4" height="130.4" fill="#107C41" />
<rect x="3139.7" y="554.65" width="130.4" height="1043.4" fill="#107C41" />
<use xlink:href="#excel" x="4120" />
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,12 @@
<svg width="100" height="70" viewBox="0 0 100 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_16501_39008)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M88.1761 2.8209C92.4088 3.94999 95.7107 7.25572 96.8554 11.4773C99.8491 23.5209 99.6541 46.2909 96.9183 58.5227C95.7862 62.7443 92.4717 66.0374 88.239 67.1791C76.2893 70.1273 22.7673 69.7634 11.5094 67.1791C7.27674 66.05 3.97485 62.7443 2.8302 58.5227C0.00629854 47.0436 0.201267 22.7682 2.7673 11.54C3.89938 7.31845 7.21385 4.02526 11.4466 2.88363C27.4214 -0.440917 82.4906 0.631719 88.1761 2.8209ZM40.3774 20.3218L66.0378 35L40.3774 49.6782V20.3218Z"
fill="white" />
</g>
<defs>
<clipPath id="clip0_16501_39008">
<rect width="100" height="69" fill="white" transform="translate(0 0.5)" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 879 B

View File

@ -20,7 +20,7 @@ const TRUSTED_HOSTS = [
]
/** The list of hosts that the app can open external links to. */
const TRUSTED_EXTERNAL_HOSTS = ['discord.gg']
const TRUSTED_EXTERNAL_HOSTS = ['enso.org', 'www.youtube.com', 'discord.gg', 'github.com']
/** The list of URLs a new WebView can be pointed to. */
const WEBVIEW_URL_WHITELIST: string[] = []

View File

@ -55,39 +55,19 @@ function esbuildPluginGenerateTailwind(): esbuild.Plugin {
return {
name: 'enso-generate-tailwind',
setup: build => {
/** An entry in the cache of already processed CSS files. */
interface CacheEntry {
contents: string
lastModified: number
}
const cachedOutput: Record<string, CacheEntry> = {}
let tailwindConfigLastModified = 0
let tailwindConfigWasModified = true
const cssProcessor = postcss([
tailwindcss({
config: TAILWIND_CONFIG_PATH,
}),
tailwindcssNesting(),
])
build.onStart(async () => {
const tailwindConfigNewLastModified = (await fs.stat(TAILWIND_CONFIG_PATH)).mtimeMs
tailwindConfigWasModified =
tailwindConfigLastModified !== tailwindConfigNewLastModified
tailwindConfigLastModified = tailwindConfigNewLastModified
})
build.onLoad({ filter: /tailwind\.css$/ }, async loadArgs => {
const lastModified = (await fs.stat(loadArgs.path)).mtimeMs
let output = cachedOutput[loadArgs.path]
if (!output || output.lastModified !== lastModified || tailwindConfigWasModified) {
console.log(`Processing CSS file '${loadArgs.path}'.`)
const content = await fs.readFile(loadArgs.path, 'utf8')
const result = await cssProcessor.process(content, { from: loadArgs.path })
console.log(`Processed CSS file '${loadArgs.path}'.`)
output = { contents: result.css, lastModified }
cachedOutput[loadArgs.path] = output
}
console.log(`Processing CSS file '${loadArgs.path}'.`)
const content = await fs.readFile(loadArgs.path, 'utf8')
const result = await cssProcessor.process(content, { from: loadArgs.path })
console.log(`Processed CSS file '${loadArgs.path}'.`)
return {
contents: output.contents,
contents: result.content,
loader: 'css',
watchFiles: [loadArgs.path, TAILWIND_CONFIG_PATH],
}

View File

@ -2,8 +2,6 @@
* interactive components. */
import * as React from 'react'
import * as common from 'enso-common'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as backendModule from '../backend'
@ -22,13 +20,12 @@ import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as app from '../../components/app'
import * as pageSwitcher from './pageSwitcher'
import * as spinner from './spinner'
import Chat, * as chat from './chat'
import DriveView from './driveView'
import Drive from './drive'
import Editor from './editor'
import Templates from './templates'
import Home from './home'
import TheModal from './theModal'
import TopBar from './topBar'
@ -48,7 +45,6 @@ export interface DashboardProps {
/** The component that contains the entire UI. */
export default function Dashboard(props: DashboardProps) {
const { supportsLocalBackend, appRunner, initialProjectName, projectManagerUrl } = props
const navigate = hooks.useNavigate()
const logger = loggerProvider.useLogger()
const session = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
@ -97,6 +93,12 @@ export default function Dashboard(props: DashboardProps) {
}
}, [page, /* should never change */ unsetModal])
React.useEffect(() => {
if (query !== '') {
setPage(pageSwitcher.Page.drive)
}
}, [query])
React.useEffect(() => {
let currentBackend = backend
if (
@ -322,11 +324,10 @@ export default function Dashboard(props: DashboardProps) {
)
}, [])
const driveHiddenClass = page === pageSwitcher.Page.drive ? '' : 'hidden'
return (
<>
<div
className={`flex flex-col gap-2 relative select-none text-primary text-xs h-screen pb-2 ${
className={`flex flex-col relative select-none text-primary text-xs h-screen pb-2 ${
page === pageSwitcher.Page.editor ? 'cursor-none pointer-events-none' : ''
}`}
onContextMenu={event => {
@ -363,65 +364,27 @@ export default function Dashboard(props: DashboardProps) {
setProjectStartupInfo(null)
}}
/>
{isListingRemoteDirectoryWhileOffline ? (
<div className={`grow grid place-items-center mx-2 ${driveHiddenClass}`}>
<div className="flex flex-col gap-4">
<div className="text-base text-center">You are not signed in.</div>
<button
className="text-base text-white bg-help rounded-full self-center leading-170 h-8 py-px w-16"
onClick={() => {
navigate(app.LOGIN_PATH)
}}
>
Login
</button>
</div>
</div>
) : isListingLocalDirectoryAndWillFail ? (
<div className={`grow grid place-items-center mx-2 ${driveHiddenClass}`}>
<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 mx-2 ${driveHiddenClass}`}>
<div className="text-base text-center">
We will review your user details and enable the cloud experience for you
shortly.
</div>
</div>
) : (
<>
<Templates
hidden={page !== pageSwitcher.Page.drive}
onTemplateClick={doCreateProject}
/>
<DriveView
hidden={page !== pageSwitcher.Page.drive}
page={page}
initialProjectName={initialProjectName}
query={query}
projectStartupInfo={projectStartupInfo}
queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
doCreateProject={doCreateProject}
doOpenEditor={openEditor}
doCloseEditor={closeEditor}
loadingProjectManagerDidFail={loadingProjectManagerDidFail}
isListingRemoteDirectoryWhileOffline={
isListingRemoteDirectoryWhileOffline
}
isListingLocalDirectoryAndWillFail={isListingLocalDirectoryAndWillFail}
isListingRemoteDirectoryAndWillFail={
isListingRemoteDirectoryAndWillFail
}
/>
</>
)}
<Home hidden={page !== pageSwitcher.Page.home} onTemplateClick={doCreateProject} />
<Drive
hidden={page !== pageSwitcher.Page.drive}
page={page}
initialProjectName={initialProjectName}
query={query}
projectStartupInfo={projectStartupInfo}
queuedAssetEvents={queuedAssetEvents}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
doCreateProject={doCreateProject}
doOpenEditor={openEditor}
doCloseEditor={closeEditor}
loadingProjectManagerDidFail={loadingProjectManagerDidFail}
isListingRemoteDirectoryWhileOffline={isListingRemoteDirectoryWhileOffline}
isListingLocalDirectoryAndWillFail={isListingLocalDirectoryAndWillFail}
isListingRemoteDirectoryAndWillFail={isListingRemoteDirectoryAndWillFail}
/>
<TheModal />
<Editor
hidden={page !== pageSwitcher.Page.editor}
supportsLocalBackend={supportsLocalBackend}

View File

@ -1,6 +1,8 @@
/** @file The directory header bar and directory item listing. */
import * as React from 'react'
import * as common from 'enso-common'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as authProvider from '../../authentication/providers/auth'
@ -8,17 +10,18 @@ import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as app from '../../components/app'
import * as pageSwitcher from './pageSwitcher'
import AssetsTable from './assetsTable'
import CategorySwitcher from './categorySwitcher'
import DriveBar from './driveBar'
// =================
// === DriveView ===
// =================
// =============
// === Drive ===
// =============
/** Props for a {@link DriveView}. */
export interface DriveViewProps {
/** Props for a {@link Drive}. */
export interface DriveProps {
page: pageSwitcher.Page
hidden: boolean
initialProjectName: string | null
@ -45,7 +48,7 @@ export interface DriveViewProps {
}
/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function DriveView(props: DriveViewProps) {
export default function Drive(props: DriveProps) {
const {
page,
hidden,
@ -65,6 +68,7 @@ export default function DriveView(props: DriveViewProps) {
isListingLocalDirectoryAndWillFail,
isListingRemoteDirectoryAndWillFail,
} = props
const navigate = hooks.useNavigate()
const { organization } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
@ -120,9 +124,36 @@ export default function DriveView(props: DriveViewProps) {
}
}, [page])
return (
return isListingRemoteDirectoryWhileOffline ? (
<div className={`grow grid place-items-center mx-2 ${hidden ? 'hidden' : ''}`}>
<div className="flex flex-col gap-4">
<div className="text-base text-center">You are not signed in.</div>
<button
className="text-base text-white bg-help rounded-full self-center leading-170 h-8 py-px w-16"
onClick={() => {
navigate(app.LOGIN_PATH)
}}
>
Login
</button>
</div>
</div>
) : isListingLocalDirectoryAndWillFail ? (
<div className={`grow grid place-items-center mx-2 ${hidden ? 'hidden' : ''}`}>
<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 mx-2 ${hidden ? 'hidden' : ''}`}>
<div className="text-base text-center">
We will review your user details and enable the cloud experience for you shortly.
</div>
</div>
) : (
<div
className={`flex flex-col flex-1 overflow-hidden gap-2.5 px-3.25 ${
className={`flex flex-col flex-1 overflow-hidden gap-2.5 px-3.25 mt-8 ${
hidden ? 'hidden' : ''
}`}
>

View File

@ -0,0 +1,45 @@
/** @file Home screen. */
import * as React from 'react'
import * as spinner from './spinner'
import Samples from './samples'
import WhatsNew from './whatsNew'
// ============
// === Home ===
// ============
/** Props for a {@link Home}. */
export interface HomeProps {
hidden: boolean
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (state: spinner.SpinnerState | null) => void
) => void
}
/** Home screen. */
export default function Home(props: HomeProps) {
const { hidden, onTemplateClick } = props
return (
<div
className={`flex flex-col flex-1 overflow-auto scroll-hidden gap-12 ${
hidden ? 'hidden' : ''
}`}
>
{/* For spacing */}
<div />
{/* Header */}
<div className="flex flex-col gap-2 px-9.75 py-2.25">
<h1 className="self-center text-center leading-144.5 text-4xl py-0.5">
Welcome to Enso Community
</h1>
<h2 className="self-center text-center font-normal leading-144.5 text-xl py-0.5">
Explore templates, plugins, and data sources to kickstart your next big idea.
</h2>
</div>
<WhatsNew />
<Samples onTemplateClick={onTemplateClick} />
</div>
)
}

View File

@ -20,7 +20,7 @@ export enum Page {
/** Error text for each page. */
const ERRORS: Record<Page, string | null> = {
[Page.home]: 'Not implemented yet.',
[Page.home]: null,
[Page.drive]: null,
[Page.editor]: 'No project is currently open.',
}
@ -51,9 +51,7 @@ export default function PageSwitcher(props: PageSwitcherProps) {
<div className="flex items-center shrink-0 gap-4">
{PAGE_DATA.map(pageData => {
const isDisabled =
pageData.page === page ||
pageData.page === Page.home ||
(pageData.page === Page.editor && isEditorDisabled)
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled)
return (
<Button
key={pageData.page}

View File

@ -0,0 +1,248 @@
/** @file Renders the list of templates from which a project can be created. */
import * as React from 'react'
import GeoImage from 'enso-assets/geo.svg'
import SpreadsheetsImage from 'enso-assets/spreadsheets.svg'
import VisualizeImage from 'enso-assets/visualize.png'
import HeartIcon from 'enso-assets/heart.svg'
import Logo from 'enso-assets/enso_logo.svg'
import OpenCountIcon from 'enso-assets/open_count.svg'
import ProjectIcon from 'enso-assets/project_icon.svg'
import Spinner, * as spinner from './spinner'
import SvgMask from '../../authentication/components/svgMask'
// =================
// === Constants ===
// =================
/** The size (both width and height) of the spinner, in pixels. */
const SPINNER_SIZE_PX = 50
/** The duration of the "spinner done" animation. */
const SPINNER_DONE_DURATION_MS = 1000
/** A placeholder author for a sample, for use until the backend implements an endpoint. */
const DUMMY_AUTHOR = 'Enso Team'
/** A placeholder number of times a sample has been opened, for use until the backend implements
* an endpoint. */
const DUMMY_OPEN_COUNT = 10
/** A placeholder number of likes for a sample, for use until the backend implements an endpoint. */
const DUMMY_LIKE_COUNT = 10
// =========================
// === List of templates ===
// =========================
/** Template metadata. */
export interface Sample {
title: string
description: string
id: string
background?: string
}
/** The full list of templates. */
export const SAMPLES: Sample[] = [
{
title: 'Colorado COVID',
id: 'Colorado_COVID',
description: 'Learn to glue multiple spreadsheets to analyses all your data at once.',
},
{
title: 'KMeans',
id: 'KMeans',
description: 'Learn where to open a coffee shop to maximize your income.',
},
{
title: 'NASDAQ Returns',
id: 'NASDAQReturns',
description: 'Learn how to clean your data to prepare it for advanced analysis.',
},
{
title: 'Combine spreadsheets',
id: 'Orders',
description: 'Glue multiple spreadsheets together to analyse all your data at once.',
background: `url('${SpreadsheetsImage}') center / 50% no-repeat, rgba(255, 255, 255, 0.30)`,
},
{
title: 'Geospatial analysis',
id: 'Restaurants',
description: 'Learn where to open a coffee shop to maximize your income.',
background: `url('${GeoImage}') 50% 20% / 100% no-repeat`,
},
{
title: 'Analyze GitHub stars',
id: 'Stargazers',
description: "Find out which of Enso's repositories are most popular over time.",
background: `url("${VisualizeImage}") center / cover`,
},
]
// =====================
// === ProjectsEntry ===
// =====================
/** Props for an {@link ProjectsEntry}. */
interface InternalProjectsEntryProps {
onTemplateClick: (
name: null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new blank project. */
function ProjectsEntry(props: InternalProjectsEntryProps) {
const { onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
return (
<div className="flex flex-col gap-1.5 h-51">
<button
className="relative grow cursor-pointer"
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(null, newSpinnerState => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
window.setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
})
}}
>
<div className="absolute bg-frame rounded-2xl w-full h-full opacity-60" />
<div className="relative flex rounded-2xl w-full h-full">
<div className="flex flex-col text-center items-center gap-3 m-auto">
{spinnerState != null ? (
<div className="p-2">
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
</div>
) : (
<img src={ProjectIcon} />
)}
<p className="font-semibold text-sm">New empty project</p>
</div>
</div>
</button>
<div className="h-4.5" />
</div>
)
}
// ===================
// === ProjectTile ===
// ===================
/** Props for a {@link ProjectTile}. */
interface InternalProjectTileProps {
template: Sample
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new project based on a template. */
function ProjectTile(props: InternalProjectTileProps) {
const { template, onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const author = DUMMY_AUTHOR
const opens = DUMMY_OPEN_COUNT
const likes = DUMMY_LIKE_COUNT
const onSpinnerStateChange = React.useCallback(
(newSpinnerState: spinner.SpinnerState | null) => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
window.setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
},
[]
)
return (
<div className="flex flex-col gap-1.5 h-51">
<button
key={template.title}
className="relative flex flex-col grow cursor-pointer text-left"
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(template.id, onSpinnerStateChange)
}}
>
<div
style={{
background: template.background,
}}
className={`rounded-t-2xl w-full h-25 ${
template.background != null ? '' : 'bg-frame'
}`}
/>
<div className="grow bg-frame backdrop-blur rounded-b-2xl w-full px-4 pt-1.75 pb-3.5">
<h2 className="text-sm font-bold leading-144.5 py-0.5">{template.title}</h2>
<div className="text-xs text-ellipsis leading-144.5 pb-px">
{template.description}
</div>
</div>
{spinnerState != null && (
<div className="absolute grid w-full h-25 place-items-center">
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
</div>
)}
</button>
<div className="flex justify-between text-primary h-4.5 px-4 opacity-70">
<div className="flex gap-1.5">
<SvgMask src={Logo} />
<span className="font-bold leading-144.5 pb-px">{author}</span>
</div>
<div className="flex gap-3">
{/* Opens */}
<div className="flex gap-1.5">
<SvgMask src={OpenCountIcon} />
<span className="font-bold leading-144.5 pb-px">{opens}</span>
</div>
{/* Likes */}
<div className="flex gap-1.5">
<SvgMask src={HeartIcon} />
<span className="font-bold leading-144.5 pb-px">{likes}</span>
</div>
</div>
</div>
</div>
)
}
// ===============
// === Samples ===
// ===============
/** Props for a {@link Samples}. */
export interface SamplesProps {
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (state: spinner.SpinnerState | null) => void
) => void
}
/** A list of sample projects. */
export default function Samples(props: SamplesProps) {
const { onTemplateClick } = props
return (
<div className="flex flex-col gap-4 px-4.75">
<h2 className="text-xl leading-144.5 py-0.5">Sample and community projects</h2>
<div className="grid gap-2 grid-cols-fill-60">
<ProjectsEntry onTemplateClick={onTemplateClick} />
{SAMPLES.map(template => (
<ProjectTile
key={template.id}
template={template}
onTemplateClick={onTemplateClick}
/>
))}
</div>
</div>
)
}

View File

@ -1,332 +0,0 @@
/** @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 GeoImage from 'enso-assets/geo.png'
import SpreadsheetsImage from 'enso-assets/spreadsheets.png'
import VisualizeImage from 'enso-assets/visualize.png'
import * as localStorageModule from '../localStorage'
import * as localStorageProvider from '../../providers/localStorage'
import Spinner, * as spinner from './spinner'
// =================
// === Constants ===
// =================
/** 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
/** The size (both width and height) of the spinner, in pixels. */
const SPINNER_SIZE = 64
/** The duration of the "spinner done" animation. */
const SPINNER_DONE_DURATION_MS = 1000
// =============
// === 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',
}
// =============
// === Types ===
// =============
/** Template metadata. */
export interface Template {
title: string
description: string
id: string
background: string
}
// =================
// === Constants ===
// =================
/** The full list of templates. */
export const TEMPLATES: [Template, ...Template[]] = [
{
title: 'Colorado COVID',
id: 'Colorado_COVID',
description: 'Learn to glue multiple spreadsheets to analyses all your data at once.',
background: '#6b7280',
},
{
title: 'KMeans',
id: 'KMeans',
description: 'Learn where to open a coffee shop to maximize your income.',
background: '#6b7280',
},
{
title: 'NASDAQ Returns',
id: 'NASDAQReturns',
description: 'Learn how to clean your data to prepare it for advanced analysis.',
background: '#6b7280',
},
{
title: 'Combine spreadsheets',
id: 'Orders',
description: 'Glue multiple spreadsheets together to analyse all your data at once.',
background: `url("${SpreadsheetsImage}") 50% 11% / 50% no-repeat, #479366`,
},
{
title: 'Geospatial analysis',
id: 'Restaurants',
description: 'Learn where to open a coffee shop to maximize your income.',
background: `url("${GeoImage}") 50% 0% / 186.7768% no-repeat, #181818`,
},
{
title: 'Analyze GitHub stars',
id: 'Stargazers',
description: "Find out which of Enso's repositories are most popular over time.",
background: `url("${VisualizeImage}") center / cover, #dddddd`,
},
]
// ==========================
// === EmptyProjectButton ===
// ==========================
/** Props for an {@link EmptyProjectButton}. */
interface InternalEmptyProjectButtonProps {
onTemplateClick: (
name: null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new blank project. */
function EmptyProjectButton(props: InternalEmptyProjectButtonProps) {
const { onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
return (
<button
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(null, newSpinnerState => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
window.setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
})
}}
className="cursor-pointer relative text-primary h-40"
>
<div className="flex h-full w-full border-dashed-custom rounded-2xl">
<div className="flex flex-col text-center items-center m-auto">
{spinnerState != null ? (
<div className="p-2">
<Spinner size={SPINNER_SIZE} state={spinnerState} />
</div>
) : (
<img src={PlusCircledIcon} />
)}
<p className="font-semibold text-sm">New empty project</p>
</div>
</div>
</button>
)
}
// ======================
// === TemplateButton ===
// ======================
/** Props for a {@link TemplateButton}. */
interface InternalTemplateButtonProps {
template: Template
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** A button that, when clicked, creates and opens a new project based on a template. */
function TemplateButton(props: InternalTemplateButtonProps) {
const { template, onTemplateClick } = props
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const onSpinnerStateChange = React.useCallback(
(newSpinnerState: spinner.SpinnerState | null) => {
setSpinnerState(newSpinnerState)
if (newSpinnerState === spinner.SpinnerState.done) {
window.setTimeout(() => {
setSpinnerState(null)
}, SPINNER_DONE_DURATION_MS)
}
},
[]
)
return (
<button
key={template.title}
className="h-40 cursor-pointer"
onClick={() => {
setSpinnerState(spinner.SpinnerState.initial)
onTemplateClick(template.id, onSpinnerStateChange)
}}
>
<div
style={{
background: template.background,
}}
className="relative flex flex-col justify-end h-full w-full rounded-2xl overflow-hidden text-white text-left"
>
<div className="bg-black bg-opacity-30 px-4 py-2">
<h2 className="text-sm font-bold">{template.title}</h2>
<div className="text-xs h-16 text-ellipsis py-2">{template.description}</div>
</div>
{spinnerState != null && (
<div className="absolute grid w-full h-full place-items-center">
<Spinner size={SPINNER_SIZE} state={spinnerState} />
</div>
)}
</div>
</button>
)
}
// =======================
// === TemplatesRender ===
// =======================
/** Props for a {@link TemplatesRender}. */
interface InternalTemplatesRenderProps {
// Later this data may be requested and therefore needs to be passed dynamically.
templates: Template[]
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (spinnerState: spinner.SpinnerState | null) => void
) => void
}
/** Render all templates, and a button to create an empty project. */
function TemplatesRender(props: InternalTemplatesRenderProps) {
const { templates, onTemplateClick } = props
return (
<>
<EmptyProjectButton onTemplateClick={onTemplateClick} />
{templates.map(template => (
<TemplateButton
key={template.id}
template={template}
onTemplateClick={onTemplateClick}
/>
))}
</>
)
}
// =================
// === Templates ===
// =================
/** Props for a {@link Templates}. */
export interface TemplatesProps {
hidden: boolean
onTemplateClick: (
name: string | null,
onSpinnerStateChange: (state: spinner.SpinnerState | null) => void
) => void
}
/** A container for a {@link TemplatesRender} which passes it a list of templates. */
export default function Templates(props: TemplatesProps) {
const { hidden, onTemplateClick } = props
const { localStorage } = localStorageProvider.useLocalStorage()
const [shadowClass, setShadowClass] = React.useState(
window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? ShadowClass.bottom : ShadowClass.none
)
const [isOpen, setIsOpen] = React.useState(
() => localStorage.get(localStorageModule.LocalStorageKey.isTemplatesListOpen) !== false
)
// 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.set(localStorageModule.LocalStorageKey.isTemplatesListOpen, isOpen)
}, [isOpen, /* should never change */ localStorage])
return (
<div className={`mx-2 ${hidden ? 'hidden' : ''}`}>
<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

@ -73,7 +73,11 @@ export default function TopBar(props: TopBarProps) {
}, [])
return (
<div className="relative flex ml-4.75 mr-2.25 mt-2.25 h-8 gap-6 z-1">
<div
className={`relative flex ml-4.75 mr-2.25 h-8 gap-6 z-1 ${
page !== pageSwitcher.Page.home ? 'mt-2.25' : 'my-2.25'
}`}
>
<PageSwitcher page={page} setPage={setPage} isEditorDisabled={isEditorDisabled} />
{supportsLocalBackend && page !== pageSwitcher.Page.editor && (
<BackendSwitcher setBackendType={setBackendType} />

View File

@ -0,0 +1,77 @@
/** @file Community updates for the app. */
import * as React from 'react'
import DiscordIcon from 'enso-assets/discord.svg'
import IntegrationsImage from 'enso-assets/integrations.png'
import YoutubeIcon from 'enso-assets/youtube.svg'
// ================
// === WhatsNew ===
// ================
/** Community updates for the app. */
export default function WhatsNew() {
return (
<div className="flex flex-col gap-4 px-4.75">
<h2 className="text-xl leading-144.5 py-0.5">Discover what&rsquo;s new</h2>
<div className="grid gap-3 grid-cols-fill-75">
<a
className="relative whatsnew-span-2 col-span-2 bg-v3 text-tag-text rounded-2xl h-45"
rel="noreferrer"
target="_blank"
href="https://enso.org/"
style={{
background: `url(${IntegrationsImage}) top -85px right -390px / 1055px`,
}}
>
<div className="absolute flex flex-col bottom-0 p-4">
<span className="text-xl font-bold leading-144.5 py-0.5">
Read what&rsquo;s new in Enso 3.0 Beta
</span>
<span className="text-sm leading-144.5 py-0.5">
Learn about Enso Cloud, new data libraries, and Enso AI.
</span>
</div>
</a>
<a
className="relative bg-youtube text-tag-text rounded-2xl h-45"
rel="noreferrer"
target="_blank"
href="https://www.youtube.com/c/Enso_org"
>
<img
className="absolute top-6 left-1/2 -translate-x-1/2 mx-auto"
src={YoutubeIcon}
/>
<div className="absolute flex flex-col bottom-0 p-4">
<span className="text-xl font-bold leading-144.5 py-0.5">
Watch weekly Enso tutorials
</span>
<span className="text-sm leading-144.5 py-0.5">
Subscribe not to miss new weekly tutorials.
</span>
</div>
</a>
<a
className="relative bg-discord text-tag-text rounded-2xl h-45"
rel="noreferrer"
target="_blank"
href="https://discord.gg/enso"
>
<img
className="absolute top-7 left-1/2 -translate-x-1/2 mx-auto"
src={DiscordIcon}
/>
<div className="absolute flex flex-col bottom-0 p-4">
<span className="text-xl font-bold leading-144.5 py-0.5">
Join our community server
</span>
<span className="text-sm leading-144.5 py-0.5">
Chat with our team and other Enso users.
</span>
</div>
</a>
</div>
</div>
)
}

View File

@ -112,10 +112,6 @@ body {
stroke-dasharray: calc(12 * 6.2832) 0;
}
.border-dashed-custom {
background-image: url("enso-assets/dashed_border.svg");
}
.scroll-hidden {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
@ -146,14 +142,6 @@ body {
background-color: rgba(255, 255, 255, 0.4);
}
.h-templates-custom {
height: 21.5rem;
@media screen and (min-width: 110.6875rem) {
height: 11rem;
}
}
.search-bar.search-bar {
@media screen and (max-width: 59rem) {
position: unset;
@ -161,6 +149,12 @@ body {
left: unset;
}
}
.whatsnew-span-2.whatsnew-span-2 {
@media screen and (max-width: 40.5625rem) {
grid-column: span 1 / span 1;
}
}
}
.pointer-events-none-recursive,

View File

@ -36,6 +36,9 @@ export const theme = {
share: '#64b526',
inversed: '#ffffff',
delete: 'rgba(243, 24, 10, 0.87)',
v3: '#252423',
youtube: '#c62421',
discord: '#404796',
dim: 'rgba(0, 0, 0, 0.25)',
frame: 'rgba(255, 255, 255, 0.40)',
'frame-selected': 'rgba(255, 255, 255, 0.70)',
@ -59,6 +62,8 @@ export const theme = {
fontSize: {
xs: '0.71875rem',
sm: '0.8125rem',
xl: '1.1875rem',
'4xl': '2.375rem',
},
lineHeight: {
'144.5': '144.5%',
@ -70,17 +75,22 @@ export const theme = {
'1.75': '0.4375rem',
'2.25': '0.5625rem',
'3.25': '0.8125rem',
'3.5': '0.875rem',
'4.5': '1.125rem',
'4.75': '1.1875rem',
'5.5': '1.375rem',
'6.5': '1.625rem',
'9.5': '2.375rem',
'9.75': '2.4375rem',
'13': '3.25rem',
'18': '4.5rem',
'25': '6.25rem',
'29': '7.25rem',
'30': '7.5rem',
'30.25': '7.5625rem',
'42': '10.5rem',
'45': '11.25rem',
'51': '12.75rem',
'51.5': '12.875rem',
'54': '13.5rem',
'57.5': '14.375rem',
@ -114,24 +124,6 @@ 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`,
'soft-dark': `0 0.5px 2.2px 0px #00000010, 0 1.2px 5.3px 0px #00000014, \
0 2.3px 10px 0 #0000001c, 0 4px 18px 0 #00000022, 0 7.5px 33.4px 0 #00000028, \
0 18px 80px 0 #00000038`,
'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',
@ -151,6 +143,7 @@ inset 0 -36px 51px -51px #00000014`,
},
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
'fill-75': 'repeat(auto-fill, minmax(18.75rem, 1fr))',
},
},
}