mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
feat(frontend): reach the hub importer from the create menu, and count the funnel
The picker only existed inside the empty state, which disappears as soon as a workspace holds one item — nothing else in the product linked to `/projects/import`. New → Import now offers a hub project, opening the catalogue in a dialog: a popover anchored to an item inside an open dropdown leaves two melt layers arguing over focus. The list and the import dialog move up to ItemsList, so one dialog serves both doors. `template_setup` records how the credentials step ended — `filled` only when nothing was outstanding, `skipped` carrying how many rows were left — and `template_abandon` records where a dismissed import was given up. `template_picker_open` gains a key naming the entry point. Also on the workspace picker: logging out is a text link on the line that says who you are and an item in the settings menu, rather than the page's accent action, and onboarding's Previous joins the row it belongs to instead of hanging under the button that finishes the form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
co-authored by
Claude Opus 5
parent
c324d2294c
commit
7812095c8d
@@ -1 +1 @@
|
||||
e1851154b61494928ba698d58ba7a50a8f4e991f
|
||||
1b569b727364e8f3db8513fb41e99f1fff1d1406
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
* own slug and `installProject` retargets them, so reading the raw paths here would
|
||||
* look for stubs that are not where they landed. */
|
||||
folder?: string
|
||||
onSkip: () => void
|
||||
/** Left with `outstanding` rows still unfilled, which the caller may want to count. */
|
||||
onSkip: (outstanding: number) => void
|
||||
/** Off where the surface already names the step, e.g. a dialog whose title is it. */
|
||||
showHeading?: boolean
|
||||
/** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */
|
||||
@@ -492,7 +493,7 @@
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
onSkip()
|
||||
onSkip(outstanding)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,7 +565,7 @@
|
||||
{#if row.status === 'done'}
|
||||
<Check size={20} class="text-emerald-600" />
|
||||
{:else if row.status === 'running'}
|
||||
<Loader2 size={20} class="animate-spin text-blue-500" />
|
||||
<Loader2 size={20} class="animate-spin text-accent" />
|
||||
{:else if row.status === 'failed'}
|
||||
<X size={20} class="text-red-500" />
|
||||
{:else if row.status === 'unknown'}
|
||||
|
||||
@@ -1072,8 +1072,8 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, the plan tier and quota shown when the execution meter is opened, and which
|
||||
home-page entry point a new item is created from and the name of any public hub
|
||||
project imported from it, last 30 days)</li
|
||||
home-page entry point a new item is created from, the name of any public hub project
|
||||
imported from it and how far that import got, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
@@ -1127,8 +1127,8 @@
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, the plan tier and quota shown when the execution meter is opened, and which
|
||||
home-page entry point a new item is created from and the name of any public hub
|
||||
project imported from it, last 30 days)</li
|
||||
home-page entry point a new item is created from, the name of any public hub project
|
||||
imported from it and how far that import got, last 30 days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
Loader2,
|
||||
Workflow,
|
||||
Import,
|
||||
Store,
|
||||
PanelLeftClose
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
@@ -36,9 +37,15 @@
|
||||
triggerElement?: HTMLElement
|
||||
/** Which entry point this menu hangs off, for telemetry. */
|
||||
source?: 'toolbar' | 'empty_state'
|
||||
/**
|
||||
* Opens the hub project picker. The menu only offers the entry; the picker and the
|
||||
* import dialog belong to the host, which is the one place a single import modal can
|
||||
* serve both this menu and the empty state's own link.
|
||||
*/
|
||||
onImportHubProject?: () => void
|
||||
}
|
||||
|
||||
let { trigger, triggerElement, source = 'toolbar' }: Props = $props()
|
||||
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
|
||||
|
||||
type Variant = {
|
||||
label: string
|
||||
@@ -241,8 +248,15 @@
|
||||
}
|
||||
|
||||
let activeKey = $state(allOptions[0]?.key)
|
||||
// every option's import action, surfaced together under the bottom "Import" submenu
|
||||
const importActions: Extra[] = allOptions.flatMap((o) => o.extras ?? [])
|
||||
// every option's import action, surfaced together under the bottom "Import" submenu.
|
||||
// The hub project leads and is separated below: the others each paste one artifact the
|
||||
// user already holds, while this one brings a whole project in from somewhere else.
|
||||
const importActions: Extra[] = $derived([
|
||||
...(onImportHubProject
|
||||
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
|
||||
: []),
|
||||
...allOptions.flatMap((o) => o.extras ?? [])
|
||||
])
|
||||
|
||||
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
|
||||
// close all come for free; we only drive the doc panel off the highlighted item.
|
||||
@@ -554,17 +568,24 @@
|
||||
use:hugViewportRight
|
||||
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
|
||||
>
|
||||
{#each importActions as action (action.label)}
|
||||
{#each importActions as action, i (action.label)}
|
||||
<button
|
||||
use:melt={$item}
|
||||
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onclick={() => action.onSelect()}
|
||||
>
|
||||
<Import size={14} class="shrink-0 text-tertiary" />
|
||||
{#if onImportHubProject && i === 0}
|
||||
<Store size={14} class="shrink-0 text-tertiary" />
|
||||
{:else}
|
||||
<Import size={14} class="shrink-0 text-tertiary" />
|
||||
{/if}
|
||||
<span class="text-xs font-medium text-primary whitespace-nowrap">
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
{#if onImportHubProject && i === 0}
|
||||
<div class="mx-1 my-0.5 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import HubTemplatePicker from './HubTemplatePicker.svelte'
|
||||
import type { HubProjectPick } from '$lib/hubProject'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** A project was chosen. The host closes this and opens the import dialog on it. */
|
||||
onPick: (project: HubProjectPick) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
let { open, onPick, onClose }: Props = $props()
|
||||
|
||||
// Bound, not one-way, for the same reason the import dialog binds it: `Modal` dispatches
|
||||
// `confirmed`/`canceled` only, so the X, Escape and the backdrop are visible to the caller
|
||||
// through this value and nowhere else.
|
||||
let modalOpen = $state(false)
|
||||
let wasOpen = false
|
||||
$effect(() => {
|
||||
const shouldBeOpen = open
|
||||
if (shouldBeOpen !== untrack(() => modalOpen)) {
|
||||
modalOpen = shouldBeOpen
|
||||
if (shouldBeOpen) logFeatureUsage('home', 'template_picker_open', { key: 'new_menu' })
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
const isOpen = modalOpen
|
||||
untrack(() => {
|
||||
if (!isOpen && wasOpen) onClose()
|
||||
wasOpen = isOpen
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- A dialog rather than the empty state's popover: this one opens from an item inside an
|
||||
already-open dropdown, and a popover anchored there leaves two melt layers arguing over
|
||||
focus and dismissal. The height is fixed so the list inside has a definite box to page in,
|
||||
the same requirement the popover meets with `fitViewport`. -->
|
||||
<!-- `kind="X"`: picking a card is the action, so a Cancel button under the list would be the
|
||||
only thing in the dialog that looks like one. -->
|
||||
<Modal bind:open={modalOpen} kind="X" title="Import a hub project" class="sm:!max-w-[560px]">
|
||||
<div class="flex h-[min(70vh,520px)] flex-col">
|
||||
<HubTemplatePicker fullWidth {onPick} />
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -11,9 +11,11 @@
|
||||
|
||||
interface Props {
|
||||
onPick: (project: HubProjectPick) => void
|
||||
/** Take the width given instead of the popover's own, for a host that sets one. */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
let { onPick }: Props = $props()
|
||||
let { onPick, fullWidth = false }: Props = $props()
|
||||
|
||||
let list: InfiniteList | undefined = $state(undefined)
|
||||
|
||||
@@ -47,7 +49,7 @@
|
||||
|
||||
<!-- The popover gives this box a definite height; the list takes what the header leaves and
|
||||
scrolls inside it, which is also what lets it page. -->
|
||||
<div class="flex min-h-0 w-[380px] flex-col">
|
||||
<div class="flex min-h-0 flex-col {fullWidth ? 'w-full flex-1' : 'w-[380px]'}">
|
||||
<!-- The hub is named once, as the link to it: a footer row saying the same thing again is
|
||||
a second line spent on somewhere the reader is not going. -->
|
||||
<p class="px-3 pb-2 pt-3 text-[11.5px] leading-snug text-hint">
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
* there rather than importing twice.
|
||||
*/
|
||||
function dismiss() {
|
||||
// Where they left, which is the half of the funnel Finish cannot report: `running`
|
||||
// walked out on an import in progress, `setup` on the credentials it asked for,
|
||||
// `idle` opened the dialog and picked nothing up.
|
||||
if (!finishing) {
|
||||
const stage = execution?.running ? 'running' : onSetupStep ? 'setup' : 'idle'
|
||||
logFeatureUsage('home', 'template_abandon', { key: stage })
|
||||
}
|
||||
if (execution?.running) {
|
||||
execution.abandon()
|
||||
// Not on the way out through Finish: `done` survives a retry, so Finish is clickable
|
||||
@@ -129,10 +136,17 @@
|
||||
// by the same falling edge as the X.
|
||||
let finishing = false
|
||||
|
||||
function finish() {
|
||||
/**
|
||||
* How the credentials step ended, counted alongside the import itself: `filled` only when
|
||||
* nothing was left outstanding — the step disables Finish until then — `skipped` carrying
|
||||
* how many rows were walked away from, and `none` where the project asked for nothing.
|
||||
* Skipping with one credential left and skipping with eight are different problems.
|
||||
*/
|
||||
function finish(setupOutcome: 'filled' | 'skipped' | 'none', outstanding = 1) {
|
||||
// On the way out rather than on the pick: what is worth counting is an import that
|
||||
// landed, not a dialog that was opened and abandoned.
|
||||
if (slug) logFeatureUsage('home', 'template_import', { key: slug })
|
||||
logFeatureUsage('home', 'template_setup', { key: setupOutcome, value: outstanding })
|
||||
finishing = true
|
||||
onImported?.()
|
||||
onClose()
|
||||
@@ -181,7 +195,7 @@
|
||||
setupPending={setup.needed}
|
||||
setupUndecided={setup.undecided}
|
||||
onFolderChange={(f) => (folder = f)}
|
||||
onFinish={() => (setup.needed ? (onSetupStep = true) : finish())}
|
||||
onFinish={() => (setup.needed ? (onSetupStep = true) : finish('none'))}
|
||||
onBack={onClose}
|
||||
onExecution={(e) => (execution = e)}
|
||||
resume={execution}
|
||||
@@ -205,8 +219,8 @@
|
||||
slug={slug ?? ''}
|
||||
{folder}
|
||||
showHeading={false}
|
||||
onSkip={finish}
|
||||
onFinish={finish}
|
||||
onSkip={(outstanding) => finish('skipped', outstanding)}
|
||||
onFinish={() => finish('filled')}
|
||||
onBack={execution ? () => (onSetupStep = false) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import NoItemFound from './NoItemFound.svelte'
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
|
||||
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
|
||||
import ImportProjectModal from './ImportProjectModal.svelte'
|
||||
import type { HubProjectPick } from '$lib/hubProject'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from './FlowIcon.svelte'
|
||||
@@ -975,6 +978,12 @@
|
||||
// An import just landed, so the rows about to replace the empty state are all new: they
|
||||
// fade in one after another rather than appearing as a finished list. Cleared on a timer
|
||||
// because nothing else marks the end — the reload resolves before the rows animate.
|
||||
// The hub import, owned here rather than by either entry point: the empty state's link and
|
||||
// the create menu's Import section open the same dialog, and mounting one per entry point
|
||||
// would put two of them on the page at once while the workspace is still empty.
|
||||
let hubPick = $state<HubProjectPick | undefined>(undefined)
|
||||
let hubPickerOpen = $state(false)
|
||||
|
||||
let justImported = $state(false)
|
||||
let justImportedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
function onImported() {
|
||||
@@ -1774,7 +1783,7 @@
|
||||
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
|
||||
the menu itself does no permission check. -->
|
||||
{#if !$userStore?.operator && showEditButtons}
|
||||
<CreateActionsMenu />
|
||||
<CreateActionsMenu onImportHubProject={() => (hubPickerOpen = true)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1810,7 +1819,7 @@
|
||||
workspace whose direct-deploy protection cleared `showEditButtons`, gets the plain
|
||||
message instead of two actions it may not take. -->
|
||||
{#if workspaceEmpty && !$userStore?.operator && showEditButtons}
|
||||
<WorkspaceEmptyState {onImported} />
|
||||
<WorkspaceEmptyState onPick={(project) => (hubPick = project)} />
|
||||
{:else}
|
||||
<NoItemFound {activeFilters} />
|
||||
{/if}
|
||||
@@ -1930,6 +1939,16 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<HubProjectPickerModal
|
||||
open={hubPickerOpen}
|
||||
onClose={() => (hubPickerOpen = false)}
|
||||
onPick={(project) => {
|
||||
hubPickerOpen = false
|
||||
hubPick = project
|
||||
}}
|
||||
/>
|
||||
<ImportProjectModal pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
|
||||
|
||||
<style>
|
||||
/* Rows arriving after an import, one after another. The animation is declared on the
|
||||
container's children rather than on each row: a wrapper element around a row would make
|
||||
|
||||
@@ -6,16 +6,13 @@
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import CreateActionsMenu from './CreateActionsMenu.svelte'
|
||||
import HubTemplatePicker from './HubTemplatePicker.svelte'
|
||||
import ImportProjectModal from './ImportProjectModal.svelte'
|
||||
|
||||
interface Props {
|
||||
/** An import landed: the list has rows now, so the caller reloads it. */
|
||||
onImported?: () => void
|
||||
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
|
||||
onPick: (project: HubProjectPick) => void
|
||||
}
|
||||
|
||||
let { onImported }: Props = $props()
|
||||
|
||||
let picked = $state<HubProjectPick | undefined>(undefined)
|
||||
let { onPick }: Props = $props()
|
||||
|
||||
// Row opacities: the list fading out of existence. Static on purpose — motion is what
|
||||
// makes a skeleton mean "loading", and this state means "empty".
|
||||
@@ -86,14 +83,15 @@
|
||||
contentStyle="height: min(72vh, 520px);"
|
||||
class="border-b border-transparent text-accent hover:border-accent"
|
||||
triggerAttrs={{ 'aria-label': 'Start from a template' }}
|
||||
on:openChange={(e) => e.detail && logFeatureUsage('home', 'template_picker_open')}
|
||||
on:openChange={(e) =>
|
||||
e.detail && logFeatureUsage('home', 'template_picker_open', { key: 'empty_state' })}
|
||||
>
|
||||
{#snippet trigger()}Start from a template{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<HubTemplatePicker
|
||||
onPick={(project) => {
|
||||
close()
|
||||
picked = project
|
||||
onPick(project)
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -103,8 +101,7 @@
|
||||
{#snippet trigger()}
|
||||
<!-- A bare <button> for a link inside a sentence, signed off by design: <Button>
|
||||
carries its own padding and background and cannot sit inline in running text.
|
||||
The colour is `text-accent` — the `text-blue-500` older links use is the
|
||||
mistake to avoid, not the pattern to copy.
|
||||
Inline links take `text-accent`, never a raw Tailwind blue.
|
||||
The full stop rides inside the snippet: across a component boundary Svelte
|
||||
keeps the markup whitespace, which would leave a gap before it. -->
|
||||
<button
|
||||
@@ -116,5 +113,3 @@
|
||||
</CreateActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportProjectModal pick={picked} onClose={() => (picked = undefined)} {onImported} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
@@ -22,23 +23,16 @@
|
||||
/** Where to go once the workspace exists. It is already the active one by then. */
|
||||
onCreated: (workspaceId: string) => void
|
||||
/**
|
||||
* Told when the form starts handing over to the new workspace and if it comes back, so
|
||||
* a surface with chrome of its own around this one — onboarding's Previous button — can
|
||||
* stand down for the hand-over instead of offering a way back out of a workspace that
|
||||
* now exists. A callback rather than a bound prop: this is something the form reports,
|
||||
* not state it shares, and `$bindable(default)` on an optional prop is banned.
|
||||
* Rendered at the head of the action row — a host's own way back, next to Advanced
|
||||
* settings rather than stranded under the button that finishes the form.
|
||||
*/
|
||||
onCreatingChange?: (creating: boolean) => void
|
||||
leading?: Snippet
|
||||
}
|
||||
|
||||
let { onCreated, onCreatingChange }: Props = $props()
|
||||
let { onCreated, leading }: Props = $props()
|
||||
|
||||
let name = $state('')
|
||||
let creating = $state(false)
|
||||
function setCreating(next: boolean) {
|
||||
creating = next
|
||||
onCreatingChange?.(next)
|
||||
}
|
||||
|
||||
// The full form — id, colour, username, invites — for the person who wants it. Forced on
|
||||
// when the instance does not derive usernames: one is required and a name field has
|
||||
@@ -98,7 +92,7 @@
|
||||
|
||||
async function create() {
|
||||
if (problem || creating) return
|
||||
setCreating(true)
|
||||
creating = true
|
||||
const workspaceName = name.trim()
|
||||
const started = Date.now()
|
||||
try {
|
||||
@@ -109,7 +103,7 @@
|
||||
true
|
||||
)
|
||||
advanced = true
|
||||
setCreating(false)
|
||||
creating = false
|
||||
return
|
||||
}
|
||||
await WorkspaceService.createWorkspace({
|
||||
@@ -130,7 +124,7 @@
|
||||
} catch (error) {
|
||||
console.error('Could not create the workspace:', error)
|
||||
sendUserToast('Could not create the workspace: ' + (error?.body || error?.message), true)
|
||||
setCreating(false)
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -142,6 +136,11 @@
|
||||
</div>
|
||||
{:else if advanced}
|
||||
<CreateWorkspaceInner inModal onFinish={() => onCreated('')} />
|
||||
<!-- The full form has no way back to this one, so the host's way out of the step stays
|
||||
reachable here too — below it, since that form ends on its own action row. -->
|
||||
{#if leading}
|
||||
<div class="mt-6 flex items-center">{@render leading()}</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
@@ -158,11 +157,17 @@
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-4">
|
||||
<!-- A bare <button> as a quiet text link, signed off by design: a second <Button> here
|
||||
would compete with Create workspace for the eye. -->
|
||||
<button class="text-xs text-secondary hover:text-emphasis" onclick={() => (advanced = true)}>
|
||||
Advanced settings
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
{@render leading?.()}
|
||||
<!-- A bare <button> as a quiet text link, signed off by design: a second <Button> here
|
||||
would compete with Create workspace for the eye. -->
|
||||
<button
|
||||
class="text-xs text-secondary hover:text-emphasis"
|
||||
onclick={() => (advanced = true)}
|
||||
>
|
||||
Advanced settings
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="accent" unifiedSize="md" disabled={!!problem} onClick={create}>
|
||||
Create workspace
|
||||
</Button>
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
{#if step === 1}
|
||||
·
|
||||
<a
|
||||
class="text-blue-500 hover:underline"
|
||||
class="text-accent hover:underline"
|
||||
href="{base}/user/logout?rd={encodeURIComponent(logoutReturnTo)}"
|
||||
>
|
||||
Switch account
|
||||
|
||||
@@ -50,8 +50,6 @@
|
||||
let alreadyPlaced = $state(false)
|
||||
// The survey was skipped, so the last step has nothing to go back to.
|
||||
let skippedSurvey = $state(false)
|
||||
// Set by the create form while it hands over to the new workspace.
|
||||
let creatingWorkspace = $state(false)
|
||||
|
||||
async function loadWorkspaceStep() {
|
||||
try {
|
||||
@@ -315,23 +313,20 @@
|
||||
<!-- The same one-field form the workspace picker falls back to, so a user who leaves
|
||||
onboarding early meets it again rather than something new. It owns the name, the
|
||||
id, the advanced form and the hand-over into the workspace. -->
|
||||
<SimpleCreateWorkspace
|
||||
onCreated={leaveOnboarding}
|
||||
onCreatingChange={(v) => (creatingWorkspace = v)}
|
||||
/>
|
||||
|
||||
{#if !skippedSurvey && !creatingWorkspace}
|
||||
<div class="flex flex-row justify-start items-center pt-6">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
on:click={goToPreviousStep}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<SimpleCreateWorkspace onCreated={leaveOnboarding}>
|
||||
{#snippet leading()}
|
||||
{#if !skippedSurvey}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
on:click={goToPreviousStep}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SimpleCreateWorkspace>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
|
||||
import {
|
||||
GitFork,
|
||||
Settings,
|
||||
User,
|
||||
Search,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
LogOut
|
||||
} from 'lucide-svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { canCreateWorkspace } from '$lib/workspaceCreation'
|
||||
import SimpleCreateWorkspace from '$lib/components/workspaceSettings/SimpleCreateWorkspace.svelte'
|
||||
@@ -228,22 +236,18 @@
|
||||
|
||||
<CenteredModal
|
||||
title={showCreate ? 'Create your workspace' : 'Select a workspace'}
|
||||
subtitle={showCreate ? undefined : `Logged in as ${$usersWorkspaceStore?.email}`}
|
||||
centerVertically={false}
|
||||
>
|
||||
{#snippet subtitleSnippet()}
|
||||
<!-- With one thing to do on the page, the way out belongs on the line that says who you
|
||||
are rather than in a footer of its own. -->
|
||||
{#if showCreate}
|
||||
<span class="text-xs text-tertiary">
|
||||
Logged in as <span class="text-secondary">{$usersWorkspaceStore?.email}</span>
|
||||
·
|
||||
<!-- A bare <button> for a link inside the sentence, signed off by design: <Button>
|
||||
cannot sit inline in running text. `text-accent`, not the `text-blue-500` of
|
||||
older links. -->
|
||||
<button class="text-accent hover:underline" onclick={() => logout()}>Log out</button>
|
||||
</span>
|
||||
{/if}
|
||||
<!-- The way out belongs on the line that says who you are, not in a footer as the page's
|
||||
accent action: leaving is not what anyone came here to do. -->
|
||||
<span class="text-xs text-tertiary">
|
||||
Logged in as <span class="text-secondary">{$usersWorkspaceStore?.email}</span>
|
||||
·
|
||||
<!-- A bare <button> for a link inside the sentence, signed off by design: <Button>
|
||||
cannot sit inline in running text. Inline links take `text-accent`. -->
|
||||
<button class="text-accent hover:underline" onclick={() => logout()}>Log out</button>
|
||||
</span>
|
||||
{/snippet}
|
||||
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
|
||||
<div class="flex flex-col">
|
||||
@@ -335,7 +339,7 @@
|
||||
wrapperClasses="w-full"
|
||||
>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
unifiedSize="md"
|
||||
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
|
||||
variant={onlyAdminsWorkspace || noWorkspaces ? 'accent' : 'default'}
|
||||
wrapperClasses="w-full"
|
||||
@@ -476,14 +480,15 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Settings and the way out are for someone who lives here. A user with no workspace yet
|
||||
has one thing to do, and their way out is on the subtitle line. -->
|
||||
<!-- Settings are for someone who lives here; a user with no workspace yet has one thing
|
||||
to do. Logging out rides in this menu, and on the subtitle line above for the create
|
||||
state, which has no menu. -->
|
||||
{#if !showCreate}
|
||||
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
|
||||
<div class="flex items-center mt-10 flex-wrap gap-2">
|
||||
{#if $superadmin}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
onClick={superadminSettings?.openDrawer}
|
||||
startIcon={{ icon: Settings }}
|
||||
dropdownItems={[
|
||||
@@ -491,7 +496,8 @@
|
||||
label: 'User settings',
|
||||
onClick: () => userSettings?.openDrawer(),
|
||||
icon: User
|
||||
}
|
||||
},
|
||||
{ label: 'Log out', onClick: () => logout(), icon: LogOut }
|
||||
]}
|
||||
>
|
||||
Instance settings
|
||||
@@ -499,23 +505,14 @@
|
||||
{:else}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
onClick={() => userSettings?.openDrawer()}
|
||||
startIcon={{ icon: Settings }}
|
||||
dropdownItems={[{ label: 'Log out', onClick: () => logout(), icon: LogOut }]}
|
||||
>
|
||||
User settings
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
onClick={async () => {
|
||||
logout()
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user