fix(frontend): improve centered page overflow (#7515)

* center page top when needed

* remove debug message
This commit is contained in:
Guilhem
2026-01-08 06:26:32 +00:00
committed by GitHub
parent 710987a422
commit b8c8df080b
6 changed files with 338 additions and 304 deletions
@@ -4,41 +4,60 @@
import WindmillIcon from './icons/WindmillIcon.svelte'
import LoginPageHeader from './LoginPageHeader.svelte'
export let subtitle: string | undefined = undefined
export let title = 'Windmill'
export let disableLogo = false
export let large = false
interface Props {
subtitle?: string | undefined
title?: string
disableLogo?: boolean
large?: boolean
centerVertically?: boolean
children?: import('svelte').Snippet
}
let {
subtitle = undefined,
title = 'Windmill',
disableLogo = false,
large = false,
centerVertically = true,
children
}: Props = $props()
setLicense()
</script>
<div class="center-center min-h-screen p-4 relative bg-surface-secondary">
<div class="flex flex-col gap-2 items-center w-full">
<div
class="flex justify-center h-screen p-4 relative bg-surface-secondary overflow-auto"
class:items-center={centerVertically}
style="scrollbar-gutter: stable both-edges;"
>
<div class="flex flex-col gap-2 items-center w-full pb-8 h-fit">
{#if (!disableLogo && !$enterpriseLicense) || !$whitelabelNameStore}
<div class="hidden lg:block">
<div>
<WindmillIcon height="100px" width="100px" spin="slow" />
<WindmillIcon size={centerVertically ? 64 : 48} spin="slow" />
</div>
<h2 class="text-center py-2 text-primary">Windmill</h2>
</div>
{:else}
<div class="pt-8"></div>
{/if}
<div class="mb-4">
<h1 class="text-center text-lg text-emphasis font-semibold">
{title}
</h1>
{#if subtitle}
<p class="text-xs font-normal text-primary text-center mt-2">
{subtitle}
</p>
{/if}
</div>
<div
class="rounded-md bg-surface w-full {large
? 'max-w-5xl'
: 'max-w-[640px]'} p-4 sm:py-8 sm:px-10 mb-6 md:mb-20 z-10"
: 'max-w-[640px]'} p-4 sm:py-8 sm:px-10 z-10"
>
<div class="mb-10">
<h1 class="text-center text-lg text-emphasis font-semibold">
{title}
</h1>
{#if subtitle}
<p class="text-xs font-normal text-primary text-center mt-2">
{subtitle}
</p>
{/if}
</div>
<slot />
{@render children?.()}
</div>
</div>
@@ -344,7 +344,7 @@
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
</script>
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace">
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace" centerVertically={false}>
<div class="flex flex-col gap-8">
{#if isFork}
<div class="flex flex-block gap-2">
@@ -593,7 +593,7 @@
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between pt-12 gap-4">
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
<Button
disabled={forkCreationLoading}
variant="default"
@@ -7,7 +7,7 @@
let saved = false
</script>
<CenteredModal large title="Instance settings">
<CenteredModal large title="Instance settings" centerVertically={false}>
<InstanceSettings on:saved={() => (saved = true)} />
<p class="text-secondary text-sm px-2 py-4">
You can change these settings later in the instance settings but finishing setup will leave this
@@ -173,191 +173,129 @@
<SuperadminSettings bind:this={superadminSettings} />
{/if}
<CenteredModal title="Select a workspace" subtitle="Logged in as {$usersWorkspaceStore?.email}">
<div class="flex flex-row items-center gap-2 justify-between mb-4">
<h2 class="inline-flex gap-2 text-sm font-semibold text-emphasis flex-shrink-0">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
{#if allWorkspaces.length > 1}
<div class="flex gap-2 items-center">
<div class="relative text-primary flex-1 max-w-48">
<TextInput
inputProps={{
placeholder: 'Search workspaces...'
}}
size="sm"
bind:value={workspaceSearchFilter}
class="!pr-8"
/>
<Search size={14} class="text-secondary absolute right-2 top-0 mt-2" />
</div>
{#if workspaceHasForks}
<Button
onClick={() => workspaceExpandCollapseAll?.()}
title={workspaceAllExpanded ? 'Collapse all' : 'Expand all'}
startIcon={{ icon: workspaceAllExpanded ? ChevronsDownUp : ChevronsUpDown }}
size="xs2"
variant="default"
>
{workspaceAllExpanded ? 'Collapse' : 'Expand'}
</Button>
{/if}
</div>
{/if}
</div>
{#if $superadmin}
<div class="flex justify-end mb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
size="xs"
/>
</div>
{/if}
{#if workspaces && $usersWorkspaceStore}
{#if workspaces.length == 0}
<p class="text-xs text-secondary mt-2">
You are not a member of any workspace yet. Accept an invitation {#if createWorkspace}or
create your own{/if}
workspace.
</p>
{:else}
<WorkspaceTreeView
workspaces={allWorkspaces}
onEnterWorkspace={speakFriendAndEnterWorkspace}
onUnarchive={async (_workspaceId) => {
if (list_all_as_super_admin) {
loadWorkspacesAsAdmin()
} else {
loadWorkspaces()
}
}}
bind:searchFilter={workspaceSearchFilter}
bind:allExpanded={workspaceAllExpanded}
bind:hasForks={workspaceHasForks}
bind:this={workspaceTreeView}
/>
{/if}
{:else}
{#each new Array(3) as _, i (i)}
<Skeleton layout={[[2], 0.5]} />
{/each}
{/if}
{#if createWorkspace}
<div class="flex flex-row-reverse pt-4">
<Button
unifiedSize="sm"
btnClasses={noWorkspaces ? 'animate-bounce hover:animate-none' : ''}
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</div>
{/if}
<CenteredModal
title="Select a workspace"
subtitle="Logged in as {$usersWorkspaceStore?.email}"
centerVertically={false}
>
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2 justify-between mb-4">
<h2 class="inline-flex gap-2 text-sm font-semibold text-emphasis flex-shrink-0">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
<div class="flex flex-row items-center justify-between mt-8">
<h2 class="text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
{#if workspaces}
<Toggle size="xs" bind:checked={showAllForks} options={{ right: 'Show workspace forks' }} />
{/if}
</div>
<div class="mt-4"></div>
{#if nonForkInvites.length == 0}
<p class="text-xs text-secondary"> You don't have new invites at the moment. </p>
{/if}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
size="xs2"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
>
Accept
</Button>
<Button
variant="subtle"
size="xs2"
on:click={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
destructive
>
Decline
</Button>
</div>
{#if allWorkspaces.length > 1}
<div class="flex gap-2 items-center">
<div class="relative text-primary flex-1 max-w-48">
<TextInput
inputProps={{
placeholder: 'Search workspaces...'
}}
size="sm"
bind:value={workspaceSearchFilter}
class="!pr-8"
/>
<Search size={14} class="text-secondary absolute right-2 top-0 mt-2" />
</div>
{#if workspaceHasForks}
<Button
onClick={() => workspaceExpandCollapseAll?.()}
title={workspaceAllExpanded ? 'Collapse all' : 'Expand all'}
startIcon={{ icon: workspaceAllExpanded ? ChevronsDownUp : ChevronsUpDown }}
size="xs2"
variant="default"
>
{workspaceAllExpanded ? 'Collapse' : 'Expand'}
</Button>
{/if}
</div>
{/if}
</div>
{/each}
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
{#if $superadmin}
<div class="flex justify-end mb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
size="xs"
/>
</div>
{/if}
{#if workspaces && $usersWorkspaceStore}
{#if workspaces.length == 0}
<p class="text-xs text-secondary mt-2">
You are not a member of any workspace yet. Accept an invitation {#if createWorkspace}or
create your own{/if}
workspace.
</p>
{:else}
<WorkspaceTreeView
workspaces={allWorkspaces}
onEnterWorkspace={speakFriendAndEnterWorkspace}
onUnarchive={async (_workspaceId) => {
if (list_all_as_super_admin) {
loadWorkspacesAsAdmin()
} else {
loadWorkspaces()
}
}}
bind:searchFilter={workspaceSearchFilter}
bind:allExpanded={workspaceAllExpanded}
bind:hasForks={workspaceHasForks}
bind:this={workspaceTreeView}
/>
{/if}
{:else}
{#each new Array(3) as _, i (i)}
<Skeleton layout={[[2], 0.5]} />
{/each}
{/if}
{#if createWorkspace}
<div class="flex flex-row-reverse pt-4">
<Button
unifiedSize="sm"
btnClasses={noWorkspaces ? 'animate-bounce hover:animate-none' : ''}
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</div>
{/if}
<div class="flex flex-row items-center justify-between mt-8">
<h2 class="text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
{#if workspaces}
<Toggle size="xs" bind:checked={showAllForks} options={{ right: 'Show workspace forks' }} />
{/if}
</div>
<div class="mt-4"></div>
{#if filteredInvites.length == 0}
<p class="text-xs text-secondary"
>There are no invites to join the forks of any workspace you're in.</p
>
{:else}
<span class="mb-2 text-xs font-normal text-secondary">Forks of the workspaces you're in</span>
{#if nonForkInvites.length == 0}
<p class="text-xs text-secondary"> You don't have new invites at the moment. </p>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<div class="flex items-center gap-2">
{#if inviteWorkspace?.parent_workspace_id}
<GitFork size={12} class="text-secondary flex-shrink-0" />
{/if}
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
</div>
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{/if}
{#if invite.parent_workspace_id}
<div class="text-secondary text-2xs mt-1">
Fork of {invite.parent_workspace_id}
</div>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
unifiedSize="xs"
size="xs2"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
@@ -367,60 +305,129 @@
<Button
variant="subtle"
unifiedSize="xs"
destructive
onClick={async () => {
size="xs2"
on:click={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
destructive
>
Decline
</Button>
</div>
</div>
{/each}
{/if}
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button
variant="default"
unifiedSize="md"
on:click={superadminSettings.openDrawer}
startIcon={{ icon: Settings }}
dropdownItems={[
{
label: 'User settings',
onClick: () => userSettings.openDrawer(),
icon: User
}
]}
>
Instance settings
</Button>
{:else}
<Button
variant="default"
unifiedSize="md"
onClick={() => userSettings.openDrawer()}
startIcon={{ icon: Settings }}
>
User settings
</Button>
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
<div class="mt-4"></div>
{#if filteredInvites.length == 0}
<p class="text-xs text-secondary"
>There are no invites to join the forks of any workspace you're in.</p
>
{:else}
<span class="mb-2 text-xs font-normal text-secondary"
>Forks of the workspaces you're in</span
>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<div class="flex items-center gap-2">
{#if inviteWorkspace?.parent_workspace_id}
<GitFork size={12} class="text-secondary flex-shrink-0" />
{/if}
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
</div>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{/if}
{#if invite.parent_workspace_id}
<div class="text-secondary text-2xs mt-1">
Fork of {invite.parent_workspace_id}
</div>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
unifiedSize="xs"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
>
Accept
</Button>
<Button
variant="subtle"
unifiedSize="xs"
destructive
onClick={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
>
Decline
</Button>
</div>
</div>
{/each}
{/if}
<Button
variant="accent"
unifiedSize="md"
on:click={async () => {
logout()
}}
>
Log out
</Button>
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button
variant="default"
unifiedSize="md"
on:click={superadminSettings.openDrawer}
startIcon={{ icon: Settings }}
dropdownItems={[
{
label: 'User settings',
onClick: () => userSettings.openDrawer(),
icon: User
}
]}
>
Instance settings
</Button>
{:else}
<Button
variant="default"
unifiedSize="md"
onClick={() => userSettings.openDrawer()}
startIcon={{ icon: Settings }}
>
User settings
</Button>
{/if}
<Button
variant="accent"
unifiedSize="md"
on:click={async () => {
logout()
}}
>
Log out
</Button>
</div>
</div>
</CenteredModal>
<!-- <div class="center-center min-h-screen p-4">
@@ -30,7 +30,7 @@
Token will be posted to {host == 'localhost' ? 'your local machine' : host} to port {port}
</p>
<div class="flex flex-row justify-around pt-4 gap-x-1">
<Button variant="default" size="sm" href={base}>Decline</Button>
<Button variant="accent" size="sm" on:click={authorizeToken}>Authorize</Button>
<Button variant="default" destructive size="lg" href={base}>Decline</Button>
<Button variant="accent" size="lg" on:click={authorizeToken}>Authorize</Button>
</div>
</CenteredModal>
@@ -10,7 +10,7 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { LogIn, AlertTriangle } from 'lucide-svelte'
import { LogIn, AlertTriangle, ExternalLink } from 'lucide-svelte'
import { mergeSchema } from '$lib/common'
import { emptyString } from '$lib/utils'
import { Alert } from '$lib/components/common'
@@ -158,7 +158,7 @@
<ScheduleEditor bind:this={scheduleEditor} />
<CenteredModal title="Approval for resuming of flow" disableLogo>
<CenteredModal title="Approval for resuming of flow" disableLogo centerVertically={false}>
{#if error}
<div class="space-y-6">
{#if error.startsWith('Not authorized:')}
@@ -182,23 +182,22 @@
{:else}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
<div class="w-full">
<h2 class="mt-4">Approvers</h2>
<h2 class="text-sm font-semibold text-emphasis">Approvers</h2>
<div class="my-4">
<div class="mt-2 text-xs font-normal text-primary">
{#if currentApprovers.length > 0}
<ul>
{#each currentApprovers as approver}
<li
><b
>{approver.approver}<Tooltip
>Unique id of approval: {approver.resume_id}</Tooltip
></b
></li
>
<li>
<p>
{approver.approver}
<Tooltip>Unique id of approval: {approver.resume_id}</Tooltip>
</p>
</li>
{/each}
</ul>
{:else}
<p class="text-sm"
<p class="text-xs text-secondary"
>No current approvers for this step (approval steps can require more than one
approval)</p
>
@@ -212,7 +211,7 @@
</div>
</div>
{#if !completed}
<h2 class="mt-4 mb-2">Flow arguments</h2>
<h2 class="mt-4 mb-2 text-sm font-semibold text-emphasis">Flow arguments</h2>
<JobArgs
id={job?.id}
@@ -221,78 +220,87 @@
/>
{/if}
<div class="mt-8">
<div class="mt-8"></div>
<div class="p-4 rounded-md bg-surface-tertiary shadow-md">
{#if approver}
<p>Approving as: <b>{approver}</b></p>
<p class="text-xs font-normal text-primary mb-2"
>Approving as: <b class="font-semibold text-emphasis">{approver}</b></p
>
{/if}
{#if completed}
<Alert type="info" title="Flow completed"
>The flow is not running anymore. You cannot cancel or resume it.
</Alert>
{:else if alreadyResumed}
<Alert type="info" title="Flow already resumed">
You have already approved this flow to be resumed
</Alert>
{/if}
{#if description != undefined}
<DisplayResult noControls result={description} />
{/if}
{#if schema && Object.keys(schema).length > 0 && !completed}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
bind:isValid={valid}
schema={mergeSchema(schema, enum_payload)}
bind:args={default_payload}
/>
{/if}
{/if}
{#if !completed}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap">
{#if !job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel}
<Button
variant="accent"
destructive
on:click|once={cancel}
size="lg"
disabled={completed || alreadyResumed}>Deny</Button
>
{:else}
<div></div>
{/if}
<Button
variant="accent"
on:click|once={resume}
size="lg"
disabled={completed || alreadyResumed || !valid}>Approve</Button
>
</div>
{/if}
{#if !completed && !alreadyResumed && job?.raw_flow?.modules?.[approvalStep]?.suspend?.user_auth_required && job?.raw_flow?.modules?.[approvalStep]?.suspend?.self_approval_disabled && $userStore && $userStore.email === job.email && ($userStore.is_admin || $userStore.is_super_admin)}
<div class="mt-2">
<Alert type="warning" title="Warning">
As an administrator, by resuming or cancelling this stage of the flow, you bypass the
self-approval interdiction.
</Alert>
</div>
{/if}
</div>
{#if completed}
<div class="my-2"
><p><b>The flow is not running anymore. You cannot cancel or resume it.</b></p></div
>
{:else if alreadyResumed}
<div class="my-2"><p><b>You have already approved this flow to be resumed</b></p></div>
{/if}
{#if description != undefined}
<DisplayResult noControls result={description} />
{/if}
{#if schema && !completed}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
bind:isValid={valid}
schema={mergeSchema(schema, enum_payload)}
bind:args={default_payload}
/>
{/if}
{/if}
{#if !completed}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap mt-2">
{#if !job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel}
<Button
btnClasses="grow"
variant="accent"
destructive
on:click|once={cancel}
size="md"
disabled={completed || alreadyResumed}>Deny</Button
>
{:else}
<div></div>
{/if}
<Button
btnClasses="grow"
variant="accent"
on:click|once={resume}
size="md"
disabled={completed || alreadyResumed || !valid}>Approve</Button
>
</div>
{/if}
{#if !completed && !alreadyResumed && job?.raw_flow?.modules?.[approvalStep]?.suspend?.user_auth_required && job?.raw_flow?.modules?.[approvalStep]?.suspend?.self_approval_disabled && $userStore && $userStore.email === job.email && ($userStore.is_admin || $userStore.is_super_admin)}
<div class="mt-2">
<Alert type="warning" title="Warning">
As an administrator, by resuming or cancelling this stage of the flow, you bypass the
self-approval interdiction.
</Alert>
</div>
{/if}
<div class="mt-4 flex flex-row flex-wrap justify-between">
<a target="_blank" rel="noreferrer" href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
>Flow run details (require auth)</a
<a
class="text-accent text-xs"
target="_blank"
rel="noreferrer"
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
>Open run details (require auth) <ExternalLink size={12} class="inline" /></a
>
</div>
{#if job && job.raw_flow && !completed}
<h2 class="mt-10">Flow details</h2>
<div class="border border-gray-700">
<h2 class="mt-10 text-sm font-semibold text-emphasis mb-2">Flow details</h2>
<div class="rounded-md overflow-hidden">
<FlowGraphV2
workspace={job.workspace_id}
triggerNode={false}