feat(frontend): Badge component and script page (#617)

* fix(frontend): Consistent tooltip font size

* feature(frontend): Add new badge component

* feature(frontend): Add color type to badge

* feature(frontend): Add copy to clipboard utility

* feature(frontend): Update common badge component

* feature(frontend): Update badge to handle icons

* feature(frontend): Update script page design

* feat(frontend): Add capitalize option to badges

* fix(frontend): Fix shared badge text display
This commit is contained in:
Ádám Kovács
2022-09-23 12:10:15 +02:00
committed by GitHub
parent 7d070b5a10
commit f8b62f6e9f
6 changed files with 352 additions and 232 deletions
+5 -13
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import { userStore } from '$lib/stores'
import { faPeopleGroup } from '@fortawesome/free-solid-svg-icons'
import Icon from 'svelte-awesome'
import Badge from './common/badge/Badge.svelte'
import Badge from './Badge.svelte'
export let extraPerms: Record<string, boolean> = {}
export let canWrite: boolean
@@ -48,15 +47,8 @@
}
</script>
{#if kind == 'read' || kind == 'write'}
<span class="mr-1 align-center">
{#if kind == 'read'}
<Badge tooltip={reason}>
<Icon data={faPeopleGroup} scale={0.7} />
read</Badge
>
{:else if kind == 'write'}
<Badge tooltip={reason}><Icon data={faPeopleGroup} scale={0.7} /></Badge>
{/if}
</span>
{#if kind === 'read' || kind === 'write'}
<Badge icon={{ data: faPeopleGroup }} capitalize color="blue">
{kind}
</Badge>
{/if}
+1 -1
View File
@@ -58,6 +58,6 @@
<style>
#tooltip {
@apply z-50 font-normal text-gray-300 bg-zinc-800 p-4 rounded-xl whitespace-normal;
@apply z-50 text-base font-normal text-gray-300 bg-zinc-800 p-4 rounded-xl whitespace-normal;
}
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { CloseButton } from 'flowbite-svelte'
import Icon from 'svelte-awesome'
import { type BadgeColor, type BadgeIconProps, ColorModifier } from './model'
export let color: BadgeColor = 'gray'
export let large = false
export let href = ''
export let rounded = false
export let index = false
export let dismissable = false
export let baseClass = 'text-center -mb-0.5'
export let capitalize = false
export let icon: BadgeIconProps | undefined = undefined
let defaulIconProps: BadgeIconProps = {
data: undefined,
position: 'left',
scale: 0.7
}
let hidden = false
const colors: Record<BadgeColor, string> = {
gray: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-200 dark:text-blue-800',
red: 'bg-red-100 text-red-800 dark:bg-red-200 dark:text-red-900',
green: 'bg-green-100 text-green-800 dark:bg-green-200 dark:text-green-900',
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-200 dark:text-yellow-900',
indigo: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-200 dark:text-indigo-900',
purple: 'bg-purple-100 text-purple-800 dark:bg-purple-200 dark:text-purple-900',
pink: 'bg-pink-100 text-pink-800 dark:bg-pink-200 dark:text-pink-900',
['dark-gray']: 'bg-gray-500 text-gray-100',
['dark-blue']: 'bg-blue-500 text-blue-100',
['dark-red']: 'bg-red-500 text-white',
['dark-green']: 'bg-green-500 text-green-100',
['dark-yellow']: 'bg-yellow-300 text-yellow-800',
['dark-indigo']: 'bg-indigo-500 text-indigo-100',
['dark-purple']: 'bg-purple-500 text-purple-100',
['dark-pink']: 'bg-pink-500 text-pink-100'
}
const hovers: Partial<Record<BadgeColor, string>> = {
gray: 'hover:bg-gray-200 dark:hover:bg-gray-300',
blue: 'hover:bg-blue-200 dark:hover:bg-blue-300',
red: 'hover:bg-red-200 dark:hover:bg-red-300',
green: 'hover:bg-green-200 dark:hover:bg-green-300',
yellow: 'hover:bg-yellow-200 dark:hover:bg-yellow-300',
indigo: 'hover:bg-indigo-200 dark:hover:bg-indigo-300',
purple: 'hover:bg-purple-200 dark:hover:bg-purple-300',
pink: 'hover:bg-pink-200 dark:hover:bg-pink-300'
}
$: badgeClass = classNames(
baseClass,
large ? 'text-sm font-medium' : 'text-xs font-semibold',
colors[color],
href &&
(color.startsWith(ColorModifier) ? hovers[color.replace(ColorModifier, '')] : hovers[color]),
rounded ? 'rounded-full px-2 py-1' : 'rounded px-2.5 py-0.5',
index
? 'absolute flex justify-center items-center font-bold overflow-hidden border-2 border-white dark:border-gray-900 ' +
(large ? 'w-7 h-7 -top-3.5 -right-3.5' : 'w-6 h-6 -top-3 -right-3')
: '',
$$props.class
)
$: iconProps = icon ? { ...defaulIconProps, ...icon } : { data: undefined }
const handleHide = () => (hidden = !hidden)
</script>
<span class="inline-flex justify-center items-center">
<svelte:element
this={href ? 'a' : 'span'}
{href}
{...$$restProps}
class={badgeClass}
class:hidden
class:capitalize
>
{#if iconProps.data && iconProps.position === 'left'}
<Icon {...iconProps} />
{/if}
<slot />
{#if iconProps.data && iconProps.position === 'right'}
<Icon {...iconProps} />
{/if}
{#if dismissable}
<CloseButton
{color}
on:click={handleHide}
size={large ? 'sm' : 'xs'}
class="ml-1.5 -mr-1.5"
/>
{/if}
</svelte:element>
</span>
@@ -0,0 +1,9 @@
import type { IconProps } from 'svelte-awesome/components/Icon.svelte'
type BaseColor = 'blue' | 'gray' | 'red' | 'green' | 'yellow' | 'indigo' | 'purple' | 'pink'
export const ColorModifier = 'dark-'
export type BadgeColor = BaseColor | `${typeof ColorModifier}${BaseColor}`
export interface BadgeIconProps extends IconProps {
position?: 'left' | 'right'
}
+13
View File
@@ -571,3 +571,16 @@ export function scriptLangToEditorLang(lang: Script.language): 'typescript' | 'p
return lang
}
}
export async function copyToClipboard(value: string, sendToast = true): Promise<boolean> {
let success = false
if (navigator?.clipboard) {
success = await navigator.clipboard
.writeText(value)
.then(() => true)
.catch(() => false)
}
sendToast &&
sendUserToast(success ? 'Copied to clipboard!' : "Couldn't copy to clipboard", !success)
return success
}
+230 -218
View File
@@ -15,7 +15,8 @@
displayDaysAgo,
canWrite,
defaultIfEmptyString,
scriptToHubUrl
scriptToHubUrl,
copyToClipboard
} from '$lib/utils'
import Icon from 'svelte-awesome'
import {
@@ -28,7 +29,8 @@
faShare,
faSpinner,
faGlobe,
faCodeFork
faCodeFork,
faClipboard
} from '@fortawesome/free-solid-svg-icons'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -41,6 +43,10 @@
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { onDestroy } from 'svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
let script: Script | undefined
let topHash: string | undefined
@@ -50,9 +56,17 @@
let shareModal: ShareModal
$: {
if ($workspaceStore) {
loadScript($page.params.hash)
$: if ($workspaceStore) {
loadScript($page.params.hash)
}
$: webhooks = {
uuid: {
hash: `${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run/h/${script?.hash}`,
path: `${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run/p/${script?.path}`
},
result: {
hash: `${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run_wait_result/h/${script?.hash}`,
path: `${$page.url.hostname}/api/w/${$workspaceStore}/jobs/run_wait_result/p/${script?.path}`
}
}
@@ -118,35 +132,100 @@
</script>
<CenteredPage>
<div class="flex flex-row justify-between">
<h1>
{script?.path ?? 'Loading...'}
<span class="whitespace-nowrap">
<a href="/scripts/get/{script?.hash}"
><span class="commit-hash">{truncateHash(script?.hash ?? '')}</span></a
>
<Tooltip>Each script version has an immutable hash.</Tooltip>
</span>
{#if script?.is_template}
<span class="mx-2 bg-blue-500 rounded-md bg-opacity-25 text-sm font-normal px-1 py-px"
>Template</span
>
{/if}
{#if script && script.kind != 'script'}
<span class="mx-2 bg-blue-500 rounded-md bg-opacity-25 text-sm font-normal px-1 py-px"
>{script.kind}</span
>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
{#if deploymentInProgress}
<span class="bg-yellow-200 text-gray-700 text-xs rounded px-1 mx-3">
Deployment in progress <Icon class="animate-spin" data={faSpinner} scale={0.8} />
<div class="flex flex-row flex-wrap justify-between gap-4">
<div>
<div class="flex items-center flex-wrap mb-2">
<h1 class="font-bold text-blue-500 break-all !p-0 mr-2">
{script?.path ?? 'Loading...'}
</h1>
<div class="flex items-center gap-2">
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
{#if deploymentInProgress}
<Badge
color="yellow"
icon={{ data: faSpinner, position: 'right', class: 'animate-spin' }}
>
Deployment in progress
</Badge>
{/if}
</div>
</div>
<p class="mb-2">
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
<span class="text-sm text-gray-500">
{#if script}
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by || 'unknown'}
{/if}
</span>
{/if}
</h1>
</p>
</div>
{#if script}
<div class="flex flex-row-reverse px-6">
<div class="flex items-start flex-wrap gap-1">
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/run/{script.hash}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faPlay} scale={0.5} />
<span class="pl-1">Run</span>
</div>
</a>
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/edit/{script.hash}?step=2"
class:disabled={!can_write}
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faEdit} scale={0.5} />
<span class="pl-1">Edit</span>
</div>
</a>
{#if !topHash}
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/add?template={script.path}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faCodeFork} scale={0.5} />
<span class="pl-1">Use as template/Fork</span>
</div>
</a>
{/if}
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/runs/{script.path}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faList} scale={0.5} />
<span class="pl-1">View runs</span>
</div>
</a>
<a
target="_blank"
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href={scriptToHubUrl(
script.content,
script.summary,
script.description ?? '',
script.kind
).toString()}
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faGlobe} scale={0.5} />
<span class="pl-1">Publish to Hub</span>
</div>
</a>
<Dropdown
dropdownItems={[
{
@@ -187,213 +266,146 @@
}
]}
/>
<div class="px-1">
<a
target="_blank"
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href={scriptToHubUrl(
script.content,
script.summary,
script.description ?? '',
script.kind
).toString()}
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faGlobe} scale={0.5} />
<span class="pl-1">Publish to Hub</span>
</div>
</a>
</div>
<div class="px-1">
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/runs/{script.path}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faList} scale={0.5} />
<span class="pl-1">View runs</span>
</div>
</a>
</div>
{#if !topHash}
<div class="px-1">
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/edit/{script.hash}?step=2"
class:disabled={!can_write}
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faEdit} scale={0.5} />
<span class="pl-1">Edit</span>
</div>
</a>
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/add?template={script.path}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faCodeFork} scale={0.5} />
<span class="pl-1">Use as template/Fork</span>
</div>
</a>
</div>
{/if}
<div class="px-1">
<a
class="inline-flex items-center default-button bg-transparent hover:bg-blue-500 text-blue-700 font-normal hover:text-white py-0 px-1 border-blue-500 hover:border-transparent rounded"
href="/scripts/run/{script.hash}"
>
<div class="inline-flex items-center justify-center">
<Icon class="text-blue-500" data={faPlay} scale={0.5} />
<span class="pl-1">Run</span>
</div>
</a>
</div>
</div>
{/if}
</div>
<ShareModal bind:this={shareModal} kind="script" path={script?.path ?? ''} />
<div class="grid grid-cols-1 gap-6 max-w-7xl pb-6">
<div class="flex flex-col gap-8 max-w-7xl pb-2">
{#if script === undefined}
<p>loading</p>
{:else}
<p class="text-sm">Edited {displayDaysAgo(script.created_at ?? '')} by {script.created_by}</p>
<h2>{script.summary}</h2>
<div class="prose">
<SvelteMarkdown source={defaultIfEmptyString(script.description, 'No description')} />
<div>
<h2 class="font-bold mt-8 mb-2">{script.summary}</h2>
<div class="prose">
<SvelteMarkdown source={defaultIfEmptyString(script.description, 'No description')} />
</div>
</div>
{#if script.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Error deploying this script</p>
<p>This script has not been deployed successfully because of the following errors:</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre>
</div>
{/if}
{#if topHash}
<div class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
<p class="font-bold">Not HEAD</p>
<p>
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/get/{topHash}">Go to the HEAD of this path</a>
</p>
</div>
{/if}
{#if script.archived}
<div class="bg-red-100 border-l-4 border-red-500 text-orange-700 p-4" role="alert">
<p class="font-bold">Archived</p>
<p>This version was archived</p>
</div>
{/if}
{#if script.deleted}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
<p class="font-bold">Deleted</p>
<p>The content of this script was deleted (by an admin, no less)</p>
</div>
{/if}
<div>
<h3>
Current hash <Tooltip
>The hash is an immutable and perpetual unique identifier for this version of this
script. The history of all hashes of a script constitute its lineage. This mechanism
shares some of the principles of git which identify each commit with an equivalent hash</Tooltip
>
</h3>
<p class="text-gray-700">
<a href="/scripts/get/{script?.hash}">{script?.hash}</a>
</p>
<h3 class="whitespace-nowrap mt-2">
Webhook to run this script and get job's uuid as response
<Tooltip
>Send a POST http request with a token as bearer token (or pass it as query arg 'token')
and the args respecting the corresponding jsonschema as payload. To create a permanent
token, go to your user setting by clicking your username on the top-left.</Tooltip
>
</h3>
<pre><code
>By hash: <a
href="//{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run/h/{script?.hash}"
>{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run/h/{script?.hash}</a
></code
></pre>
<pre><code
>By path: <a
href="//{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run/p/{script?.path}"
>{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run/p/{script?.path}</a
></code
></pre>
<h3 class="whitespace-nowrap mt-2">
Endpoint to run this script and get job's result as response
<Tooltip
>Send a POST http request with a token as bearer token (or pass it as query arg 'token')
and the args respecting the corresponding jsonschema as payload. To create a permanent
token, go to your user setting by clicking your username on the top-left.</Tooltip
>
</h3>
<pre><code
><a
href="//{$page.url
.hostname}/api/w/{$workspaceStore}/jobs/run_wait_result/p/{script?.path}"
>{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run_wait_result/p/{script?.path}</a
></code
></pre>
<pre><code
><a
href="//{$page.url
.hostname}/api/w/{$workspaceStore}/jobs/run_wait_result/h/{script?.hash}"
>{$page.url.hostname}/api/w/{$workspaceStore}/jobs/run_wait_result/h/{script?.hash}</a
></code
></pre>
</div>
<div>
<h3>
Previous versions of this script <Tooltip
>When you edit a script, a new hash is created and the hashes corresponding to the
previous versions of this script are archived</Tooltip
>
</h3>
<ul>
{#each script?.parent_hashes ?? [] as p_hash}
<li><a href="/scripts/get/{p_hash}">{p_hash}</a></li>
{/each}
</ul>
</div>
<div>
<div class="grid grid-cols-2 gap-4 pb-1 mb-3 border-b">
<h3 class="text-gray-700 ">
Arguments JSON schema <Tooltip
>The jsonschema defines the constraints that the payload must respect to be compatible
with the input parameters of this script. The UI form is generated automatically from
the script jsonschema. See <a href="https://json-schema.org/"
>jsonschema documentation</a
></Tooltip
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2">
{#if script.lock_error_logs}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p class="font-bold">Error deploying this script</p>
<p>This script has not been deployed successfully because of the following errors:</p>
<pre class="w-full text-xs mt-2 whitespace-pre-wrap">{script.lock_error_logs}</pre>
</div>
{/if}
{#if topHash}
<div
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4"
role="alert"
>
</h3>
<p class="font-bold">Not HEAD</p>
<p>
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/get/{topHash}">Go to the HEAD of this path</a>
</p>
</div>
{/if}
{#if script.archived}
<div class="bg-red-100 border-l-4 border-red-500 text-orange-700 p-4" role="alert">
<p class="font-bold">Archived</p>
<p>This version was archived</p>
</div>
{/if}
{#if script.deleted}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
<p class="font-bold">Deleted</p>
<p>The content of this script was deleted (by an admin, no less)</p>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col lg:flex-row gap-4">
<div class="lg:w-1/2">
<h3 class="text-lg mb-1 font-bold text-gray-600">Webhooks</h3>
<div class="border rounded-sm shadow-sm p-4">
<Tabs selected="uuid">
<Tab value="uuid">UUID</Tab>
<Tab value="result">Result</Tab>
<svelte:fragment slot="content">
{#each Object.keys(webhooks) as key}
<TabContent value={key}>
<ul>
{#each Object.keys(webhooks[key]) as type}
{@const url = webhooks[key][type]}
<li class="flex justify-between items-center mt-2">
<a
href={'//' + url}
class="whitespace-nowrap text-ellipsis overflow-hidden mr-1"
>
{url}
</a>
<div class="flex">
<Badge color="dark-gray" capitalize>
{type}
</Badge>
<button
on:click|preventDefault={() => copyToClipboard(url)}
class="flex items-center bg-blue-600 text-white rounded-md px-2 ml-2"
>
<Icon data={faClipboard} />
<span class="ml-1">Copy</span>
</button>
</div>
</li>
{/each}
</ul>
</TabContent>
{/each}
</svelte:fragment>
</Tabs>
</div>
</div>
<div class="lg:w-1/2">
<h3 class="text-lg mb-1 font-bold text-gray-600">Versions</h3>
<div class="border rounded-sm shadow-sm p-4">
<h4 class="font-bold text-gray-500">Current</h4>
<div class="mt-1">
{script?.hash}
</div>
<h4 class="font-bold text-gray-500 mt-2">Previous</h4>
{#if script?.parent_hashes?.length}
<ul class="max-h-20 overflow-y-auto">
{#each script.parent_hashes as hash}
<li class="mt-1">
<a href="/scripts/get/{hash}">{hash}</a>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-gray-500">There are no previous versions</p>
{/if}
</div>
</div>
</div>
<div>
<h3 class="text-lg mb-1 font-bold text-gray-600">
Arguments JSON schema
<Tooltip>
The jsonschema defines the constraints that the payload must respect to be compatible
with the input parameters of this script. The UI form is generated automatically from
the script jsonschema. See
<a href="https://json-schema.org/"> jsonschema documentation </a>
</Tooltip>
</h3>
<SchemaViewer schema={script.schema} />
</div>
<div>
<h3 class="text-gray-700 pb-1 mb-3 border-b">Code</h3>
<h3 class="text-lg mb-1 font-bold text-gray-600">Code</h3>
<HighlightCode language={script.language} code={script.content} />
</div>
<div>
<h3 class="text-gray-700 pb-1 mb-3 border-b">Dependencies lock file</h3>
<pre class="text-xs">{script.lock}</pre>
<h3 class="text-lg mb-1 font-bold text-gray-600">Dependencies lock file</h3>
{#if script?.lock}
<pre class="text-xs">{script.lock}</pre>
{:else}
<p class="text-sm text-gray-500">There is no lock file for this script</p>
{/if}
</div>
{/if}
</div>
</CenteredPage>
<style>
h3 {
@apply text-lg mb-2 mt-4 text-gray-600;
}
</style>