mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 16:02:28 +00:00
feat(frontend): redesign home with AI chatbox, FilterSearchbar and New/Import actions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
import { HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { Code2, LayoutDashboard, Loader2, Plus, Download, ChevronDown } from 'lucide-svelte'
|
||||
import YAML from 'yaml'
|
||||
|
||||
// Import drawer state — shared across flow / WAC / app import flows.
|
||||
let flowDrawer: Drawer | undefined = $state(undefined)
|
||||
let wacDrawer: Drawer | undefined = $state(undefined)
|
||||
let appDrawer: Drawer | undefined = $state(undefined)
|
||||
let pendingFlowRaw: string = $state('')
|
||||
let pendingWacRaw: string = $state('')
|
||||
let pendingAppRaw: string = $state('')
|
||||
let flowImportType: 'yaml' | 'json' = $state('yaml')
|
||||
let wacImportType: 'yaml' | 'json' = $state('yaml')
|
||||
let appImportType: 'yaml' | 'json' = $state('yaml')
|
||||
let appKind: 'lowcode' | 'fullcode' = $state('lowcode')
|
||||
|
||||
async function importFlowRaw() {
|
||||
$importFlowStore =
|
||||
flowImportType === 'yaml' ? YAML.parse(pendingFlowRaw) : JSON.parse(pendingFlowRaw)
|
||||
await goto(`${base}/flows/add`)
|
||||
flowDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
async function importWacRaw() {
|
||||
$importScriptStore =
|
||||
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw) : JSON.parse(pendingWacRaw)
|
||||
await goto(`${base}/scripts/add?import=true`)
|
||||
wacDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
async function importAppRaw() {
|
||||
const parsed = appImportType === 'yaml' ? YAML.parse(pendingAppRaw) : JSON.parse(pendingAppRaw)
|
||||
if (appKind === 'fullcode') {
|
||||
// /apps_raw/add triggers a full page reload (cross-origin isolation),
|
||||
// so the in-memory importStore would be lost — use sessionStorage.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
|
||||
await goto(`${base}/apps_raw/add`)
|
||||
} else {
|
||||
$importStore = parsed
|
||||
await goto(`${base}/apps/add`)
|
||||
}
|
||||
appDrawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
let newItems: Item[] = $derived([
|
||||
{
|
||||
displayName: 'Script',
|
||||
icon: Code2,
|
||||
href: `${base}/scripts/add`
|
||||
},
|
||||
...(HOME_SHOW_CREATE_FLOW
|
||||
? [{ displayName: 'Flow', icon: BarsStaggered, href: `${base}/flows/add` } as Item]
|
||||
: []),
|
||||
...(HOME_SHOW_CREATE_APP
|
||||
? [
|
||||
{
|
||||
displayName: 'App',
|
||||
icon: LayoutDashboard,
|
||||
submenuItems: [
|
||||
{ displayName: 'Low-code app', href: `${base}/apps/add` },
|
||||
{ displayName: 'Full-code app', href: `${base}/apps_raw/add` }
|
||||
]
|
||||
} as Item
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Workflow-as-Code',
|
||||
icon: Code2,
|
||||
submenuItems: [
|
||||
{ displayName: 'TypeScript', href: `${base}/scripts/add?wac=typescript` },
|
||||
{ displayName: 'Python', href: `${base}/scripts/add?wac=python` }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
let importItems: Item[] = $derived([
|
||||
{
|
||||
displayName: 'Import script',
|
||||
icon: Code2,
|
||||
action: () => wacDrawer?.openDrawer?.()
|
||||
},
|
||||
...(HOME_SHOW_CREATE_FLOW
|
||||
? [
|
||||
{
|
||||
displayName: 'Import flow',
|
||||
icon: BarsStaggered,
|
||||
action: () => flowDrawer?.openDrawer?.()
|
||||
} as Item
|
||||
]
|
||||
: []),
|
||||
...(HOME_SHOW_CREATE_APP
|
||||
? [
|
||||
{
|
||||
displayName: 'Import low-code app',
|
||||
icon: LayoutDashboard,
|
||||
action: () => {
|
||||
appKind = 'lowcode'
|
||||
appImportType = 'yaml'
|
||||
appDrawer?.openDrawer?.()
|
||||
}
|
||||
} as Item,
|
||||
{
|
||||
displayName: 'Import full-code app',
|
||||
icon: LayoutDashboard,
|
||||
action: () => {
|
||||
appKind = 'fullcode'
|
||||
appImportType = 'yaml'
|
||||
appDrawer?.openDrawer?.()
|
||||
}
|
||||
} as Item
|
||||
]
|
||||
: [])
|
||||
])
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<DropdownV2
|
||||
items={newItems}
|
||||
placement="bottom-end"
|
||||
aiId="home-create-new"
|
||||
aiDescription="Create a new script, flow, app or workflow-as-code"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Plus }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<DropdownV2
|
||||
items={importItems}
|
||||
placement="bottom-end"
|
||||
aiId="home-import"
|
||||
aiDescription="Import a script, flow or app from YAML/JSON"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Download }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
|
||||
<!-- Import flow drawer -->
|
||||
<Drawer bind:this={flowDrawer} size="800px">
|
||||
<DrawerContent title="Import flow from YAML/JSON" on:close={() => flowDrawer?.closeDrawer?.()}>
|
||||
<Tabs bind:selected={flowImportType}>
|
||||
<Tab value="yaml" label="YAML" />
|
||||
<Tab value="json" label="JSON" />
|
||||
{#snippet content()}
|
||||
<div class="relative pt-2 h-full">
|
||||
{#key flowImportType}
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:code={pendingFlowRaw}
|
||||
lang={flowImportType}
|
||||
class="h-full"
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
{#snippet actions()}
|
||||
<Button size="sm" on:click={importFlowRaw}>Import</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Import workflow-as-code drawer -->
|
||||
<Drawer bind:this={wacDrawer} size="800px">
|
||||
<DrawerContent
|
||||
title="Import script / Workflow-as-Code"
|
||||
on:close={() => wacDrawer?.closeDrawer?.()}
|
||||
>
|
||||
<Tabs bind:selected={wacImportType}>
|
||||
<Tab value="yaml" label="YAML" />
|
||||
<Tab value="json" label="JSON" />
|
||||
{#snippet content()}
|
||||
<div class="relative pt-2 h-full">
|
||||
{#key wacImportType}
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:code={pendingWacRaw}
|
||||
lang={wacImportType}
|
||||
class="h-full"
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
{#snippet actions()}
|
||||
<Button size="sm" on:click={importWacRaw}>Import</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Import app drawer -->
|
||||
<Drawer bind:this={appDrawer} size="800px">
|
||||
<DrawerContent
|
||||
title={appKind === 'fullcode' ? 'Import full-code app' : 'Import low-code app'}
|
||||
on:close={() => appDrawer?.closeDrawer?.()}
|
||||
>
|
||||
<Tabs bind:selected={appImportType}>
|
||||
<Tab value="yaml" label="YAML" />
|
||||
<Tab value="json" label="JSON" />
|
||||
{#snippet content()}
|
||||
<div class="relative pt-2 h-full">
|
||||
{#key appImportType}
|
||||
{#await import('$lib/components/SimpleEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:code={pendingAppRaw}
|
||||
lang={appImportType}
|
||||
class="h-full"
|
||||
fixedOverflowWidgets={false}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
{#snippet actions()}
|
||||
<Button size="sm" on:click={importAppRaw}>Import</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { browser } from '$app/environment'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Sparkles, ArrowUp, Code2, LayoutDashboard, Clock } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { createSession } from '$lib/components/sessions/sessionState.svelte'
|
||||
import { getOrCreateRuntime } from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import type { LatestItem } from './homeFilter'
|
||||
|
||||
let { latest = [] }: { latest?: LatestItem[] } = $props()
|
||||
|
||||
const aiEnabled = isGlobalAiEnabled()
|
||||
|
||||
let input = $state('')
|
||||
let submitting = $state(false)
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
'Create a workflow that triggers on a Discord message, checks for offensive language with an AI agent and possibly blocks them',
|
||||
'Build an app to browse and edit rows of my Postgres database',
|
||||
'Sync new Stripe customers into a Google Sheet every hour',
|
||||
'Summarize incoming support emails and post them to Slack',
|
||||
'Generate a weekly report from my analytics and email it to the team'
|
||||
]
|
||||
|
||||
const reducedMotion = browser && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
let placeholder = $state(reducedMotion ? EXAMPLE_PROMPTS[0] : '')
|
||||
let promptIndex = $state(0)
|
||||
|
||||
// Typewriter placeholder: type a prompt, hold, delete, advance to the next.
|
||||
// Re-runs whenever promptIndex changes (set when a prompt finishes deleting).
|
||||
$effect(() => {
|
||||
if (reducedMotion || !aiEnabled) return
|
||||
const prompt = EXAMPLE_PROMPTS[promptIndex]
|
||||
let char = 0
|
||||
let deleting = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const tick = () => {
|
||||
if (!deleting) {
|
||||
char++
|
||||
placeholder = prompt.slice(0, char)
|
||||
if (char >= prompt.length) {
|
||||
deleting = true
|
||||
timer = setTimeout(tick, 2800)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, 32)
|
||||
} else {
|
||||
char--
|
||||
placeholder = prompt.slice(0, char)
|
||||
if (char <= 0) {
|
||||
promptIndex = (promptIndex + 1) % EXAMPLE_PROMPTS.length
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, 16)
|
||||
}
|
||||
}
|
||||
timer = setTimeout(tick, 500)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const text = input.trim()
|
||||
if (!text || submitting) return
|
||||
submitting = true
|
||||
try {
|
||||
const session = createSession()
|
||||
const runtime = getOrCreateRuntime(session)
|
||||
// Fire the first message (beforeSend materialises + commits the session)
|
||||
// and navigate immediately so the session page renders the in-flight chat.
|
||||
void runtime.manager.sendRequest({ instructions: text })
|
||||
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
function hrefFor(it: LatestItem): string {
|
||||
const ws = $workspaceStore
|
||||
if (it.type === 'script') {
|
||||
return it.draft_only
|
||||
? `${base}/scripts/edit/${it.path}`
|
||||
: `${base}/scripts/get/${it.hash}?workspace=${ws}`
|
||||
}
|
||||
if (it.type === 'flow') {
|
||||
return it.draft_only
|
||||
? `${base}/flows/edit/${it.path}`
|
||||
: `${base}/flows/get/${it.path}?workspace=${ws}`
|
||||
}
|
||||
const seg = it.raw_app ? '_raw' : ''
|
||||
return it.draft_only
|
||||
? `${base}/apps${seg}/edit/${it.path}`
|
||||
: `${base}/apps${seg}/get/${it.path}`
|
||||
}
|
||||
|
||||
function relativeTime(t?: number): string {
|
||||
if (!t) return ''
|
||||
const diff = Date.now() - t
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months}mo ago`
|
||||
return `${Math.floor(months / 12)}y ago`
|
||||
}
|
||||
|
||||
const typeColor: Record<LatestItem['type'], string> = {
|
||||
script: 'var(--color-gray-500, #6b7280)',
|
||||
flow: '#14b8a6',
|
||||
app: '#fb923c',
|
||||
raw_app: '#fb923c'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if aiEnabled}
|
||||
<div class="w-full pt-6 pb-2">
|
||||
<div
|
||||
class="relative rounded-2xl border border-border bg-surface shadow-sm focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-4">
|
||||
<div class="mt-1 text-blue-500 dark:text-blue-400 shrink-0">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={input}
|
||||
onkeydown={onKeydown}
|
||||
rows={2}
|
||||
{placeholder}
|
||||
class="flex-1 resize-none bg-transparent outline-none text-primary placeholder:text-tertiary text-sm leading-relaxed min-h-[3rem] max-h-40"
|
||||
></textarea>
|
||||
<div class="shrink-0 self-end">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
iconOnly
|
||||
startIcon={{ icon: ArrowUp }}
|
||||
disabled={!input.trim() || submitting}
|
||||
on:click={submit}
|
||||
aiId="home-ai-new-session"
|
||||
aiDescription="Start a new AI session from the home chatbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-2 -mt-1 text-2xs text-tertiary">
|
||||
Describe what you want to build — Windmill AI will create it for you. Press Enter to start.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if latest.length > 0}
|
||||
<div class="w-full pt-4 pb-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-secondary">
|
||||
<Clock size={14} />
|
||||
<span class="text-xs font-semibold uppercase tracking-wide">Latest edited</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{#each latest as it (it.type + '/' + it.path)}
|
||||
<a
|
||||
href={hrefFor(it)}
|
||||
class="flex items-center gap-3 rounded-md border border-border bg-surface hover:bg-surface-hover transition-colors p-3 min-w-0"
|
||||
>
|
||||
<div class="shrink-0" style="color: {typeColor[it.type]}">
|
||||
{#if it.type === 'script'}
|
||||
<Code2 size={18} />
|
||||
{:else if it.type === 'flow'}
|
||||
<BarsStaggered size={18} />
|
||||
{:else}
|
||||
<LayoutDashboard size={18} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-primary truncate font-medium">
|
||||
{it.summary || it.path}
|
||||
</div>
|
||||
<div class="text-2xs text-tertiary truncate">{it.path}</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-2xs text-tertiary whitespace-nowrap">
|
||||
{relativeTime(it.time)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Badge, Button, Skeleton } from '$lib/components/common'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
AppService,
|
||||
@@ -13,47 +13,48 @@
|
||||
} from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ListFilterPlus,
|
||||
SearchCode,
|
||||
Tag
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
|
||||
import { ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
|
||||
|
||||
import SearchItems from '../SearchItems.svelte'
|
||||
import ListFilters from './ListFilters.svelte'
|
||||
import NoItemFound from './NoItemFound.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from './FlowIcon.svelte'
|
||||
import { canWrite, getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { page } from '$app/state'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import Item from './Item.svelte'
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import type { HomeFilterValue, LatestItem } from './homeFilter'
|
||||
|
||||
interface Props {
|
||||
filter?: string
|
||||
subtab?: 'flow' | 'script' | 'app'
|
||||
filterValue: HomeFilterValue
|
||||
itemKind?: 'all' | 'script' | 'flow' | 'app'
|
||||
showEditButtons?: boolean
|
||||
onMeta?: (meta: {
|
||||
owners: string[]
|
||||
labels: string[]
|
||||
loading: boolean
|
||||
latest: LatestItem[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
let {
|
||||
filter = $bindable(''),
|
||||
subtab = $bindable('script'),
|
||||
showEditButtons = true
|
||||
}: Props = $props()
|
||||
let { filterValue, itemKind = 'all', showEditButtons = true, onMeta }: Props = $props()
|
||||
|
||||
// Filter inputs are owned by the parent toolbar (FilterSearchbar) and read
|
||||
// from `filterValue`. The list itself stays the source of truth for the
|
||||
// loaded items, the fuzzy search, and keyboard navigation.
|
||||
let filter = $derived(filterValue._default_ ?? '')
|
||||
let ownerFilter = $derived(filterValue.path)
|
||||
let labelFilters = $derived(
|
||||
(filterValue.label ?? '')
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
let archived = $derived(!!filterValue.archived)
|
||||
let includeWithoutMain = $derived(!filterValue.exclude_library)
|
||||
let filterUserFolders = $derived(!!filterValue.user_folders_only)
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
canWrite: boolean
|
||||
@@ -76,10 +77,6 @@
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
|
||||
let itemKind = $state(
|
||||
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
|
||||
)
|
||||
|
||||
let loading = $state(true)
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
@@ -154,9 +151,6 @@
|
||||
return true // should not happen
|
||||
}
|
||||
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let labelFilter: string | undefined = $state(undefined)
|
||||
|
||||
const cmp = new Intl.Collator('en').compare
|
||||
|
||||
const opts: uFuzzy.Options = {
|
||||
@@ -210,11 +204,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let archived = $state(false)
|
||||
|
||||
const TREE_VIEW_SETTING_NAME = 'treeView'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
|
||||
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
|
||||
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
@@ -223,16 +213,6 @@
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
let includeWithoutMain = $state(
|
||||
getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME)
|
||||
? getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME) == 'true'
|
||||
: true
|
||||
)
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
|
||||
let viewCodeDrawer: Drawer | undefined = $state()
|
||||
let viewCodeTitle: string | undefined = $state()
|
||||
@@ -251,11 +231,6 @@
|
||||
}
|
||||
|
||||
let collapseAll = $state(true)
|
||||
let owners = $derived(
|
||||
Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
$effect(() => {
|
||||
if ($userStore && $workspaceStore) {
|
||||
;[archived, includeWithoutMain]
|
||||
@@ -304,30 +279,35 @@
|
||||
function itemLabels(x: { labels?: string[]; inherited_labels?: string[] }): string[] {
|
||||
return [...(x.labels ?? []), ...(x.inherited_labels ?? [])]
|
||||
}
|
||||
let owners = $derived(
|
||||
Array.from(
|
||||
new Set(combinedItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
let allLabels = $derived(
|
||||
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
|
||||
)
|
||||
// Latest-edited items for the home hero, by recency only (ignoring the
|
||||
// starred-first ordering used for the main list).
|
||||
let latestEdited = $derived(
|
||||
[...(combinedItems ?? [])]
|
||||
.sort((a, b) => (b.time ?? 0) - (a.time ?? 0))
|
||||
.slice(0, 6) as LatestItem[]
|
||||
)
|
||||
// Surface the owners/labels/loading/latest the parent toolbar + hero need to
|
||||
// build the FilterSearchbar schema, presets, and "Latest edited" cards.
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
labelFilter = undefined
|
||||
}
|
||||
onMeta?.({ owners, labels: allLabels, loading, latest: latestEdited })
|
||||
})
|
||||
|
||||
let preFilteredItems = $derived(
|
||||
ownerFilter != undefined
|
||||
? combinedItems?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
(x.type == itemKind || itemKind == 'all') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
|
||||
(labelFilter == undefined || itemLabels(x).includes(labelFilter))
|
||||
)
|
||||
: combinedItems?.filter(
|
||||
(x) =>
|
||||
(x.type == itemKind || itemKind == 'all') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
|
||||
(labelFilter == undefined || itemLabels(x).includes(labelFilter))
|
||||
)
|
||||
combinedItems?.filter(
|
||||
(x) =>
|
||||
(ownerFilter == undefined || x.path.startsWith(ownerFilter + '/')) &&
|
||||
(x.type == itemKind || itemKind == 'all') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) &&
|
||||
(labelFilters.length == 0 || labelFilters.every((l) => itemLabels(x).includes(l)))
|
||||
)
|
||||
)
|
||||
let items = $derived(filter !== '' ? filteredItems : preFilteredItems)
|
||||
let displayedItems = $derived((items ?? []).slice(0, nbDisplayed))
|
||||
@@ -348,30 +328,13 @@
|
||||
firstWorkspaceRun = false
|
||||
return
|
||||
}
|
||||
// On workspace switch, melt-ui restores focus to the workspace-picker trigger
|
||||
// button asynchronously after the menu closes. Without overriding it, pressing
|
||||
// an arrow key would re-open / re-highlight the workspace picker instead of
|
||||
// moving the items-list selection. Run several times to win the focus race.
|
||||
const focusSearch = () => {
|
||||
const el = document.getElementById('home-search-input') as HTMLInputElement | null
|
||||
el?.focus()
|
||||
}
|
||||
focusSearch()
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
focusSearch()
|
||||
requestAnimationFrame(focusSearch)
|
||||
})
|
||||
const timeoutId = setTimeout(focusSearch, 100)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
filter
|
||||
itemKind
|
||||
ownerFilter
|
||||
labelFilter
|
||||
labelFilters
|
||||
archived
|
||||
// Skip while pendingAutoSelect is true (initial load / workspace switch);
|
||||
// the auto-select effect below will set the index once items appear.
|
||||
if (!pendingAutoSelect) {
|
||||
@@ -419,7 +382,7 @@
|
||||
const target = e.target as HTMLElement | null
|
||||
|
||||
// When focus is inside a row's action buttons, handle arrow keys ourselves:
|
||||
// - Left/Right cycle between buttons (Left from the first returns to search).
|
||||
// - Left/Right cycle between buttons.
|
||||
// - Up/Down move to the same-position button on the previous/next row.
|
||||
// All other keys pass through so Enter/Space activate the focused button normally.
|
||||
// This must run BEFORE the skipSelector check, since the dropdown ellipsis
|
||||
@@ -447,8 +410,6 @@
|
||||
e.preventDefault()
|
||||
if (currentIdx > 0) {
|
||||
buttons[currentIdx - 1].focus()
|
||||
} else {
|
||||
;(document.getElementById('home-search-input') as HTMLInputElement | null)?.focus()
|
||||
}
|
||||
} else {
|
||||
// ArrowUp / ArrowDown: move to same-position button on prev/next row.
|
||||
@@ -501,20 +462,14 @@
|
||||
const tag = target.tagName
|
||||
const isEditable =
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||||
const isOurSearch = target.id === 'home-search-input'
|
||||
if (isEditable && !isOurSearch) return
|
||||
if (isEditable) return
|
||||
if (target.closest(skipSelector)) return
|
||||
}
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active?.closest(skipSelector)) return
|
||||
|
||||
// ArrowRight from search input / body → focus first action button of selected row.
|
||||
// Guard: if cursor is in the middle of typed search text, let the cursor move.
|
||||
// ArrowRight from body → focus first action button of selected row.
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return
|
||||
}
|
||||
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
|
||||
const buttons = getSelectedRowActionButtons()
|
||||
if (buttons.length > 0) {
|
||||
@@ -523,14 +478,6 @@
|
||||
}
|
||||
return
|
||||
}
|
||||
// ArrowLeft from search input with cursor at start: no-op (let default handle).
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionStart !== 0) return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (displayedItems.length === 0) return
|
||||
@@ -579,12 +526,13 @@
|
||||
$effect(() => {
|
||||
storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function reloadAll() {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
@@ -615,161 +563,13 @@
|
||||
|
||||
<CenteredPage wrapperClasses="w-full" handleOverflow={false}>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center justify-between w-full"
|
||||
use:triggerableByAI={{
|
||||
id: 'home-items-list',
|
||||
description: 'Lists of scripts, flows, and apps'
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
if (itemKind != 'all') {
|
||||
subtab = v
|
||||
}
|
||||
setQuery(page.url, 'kind', v)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" size="md" {item} />
|
||||
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="relative text-primary grow min-w-[100px]">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput
|
||||
inputProps={{
|
||||
autofocus: true,
|
||||
placeholder: HOME_SEARCH_PLACEHOLDER,
|
||||
id: 'home-search-input'
|
||||
}}
|
||||
size="md"
|
||||
bind:value={filter}
|
||||
class="!pr-10"
|
||||
/>
|
||||
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 56.966 56.966"
|
||||
style="enable-background:new 0 0 56.966 56.966;"
|
||||
xml:space="preserve"
|
||||
width="512px"
|
||||
height="512px"
|
||||
>
|
||||
<path
|
||||
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={() => openSearchWithPrefilledText('#')}
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
endIcon={{
|
||||
icon: SearchCode
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters
|
||||
syncQuery
|
||||
bind:selectedFilter={ownerFilter}
|
||||
filters={owners}
|
||||
bottomMargin={false}
|
||||
/>
|
||||
{#if allLabels.length > 0}
|
||||
<div class="gap-1.5 w-full flex flex-wrap mt-2">
|
||||
{#each allLabels as label (label)}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
clickable
|
||||
selected={label === labelFilter}
|
||||
title="Label: {label}"
|
||||
onclick={() => {
|
||||
labelFilter = labelFilter === label ? undefined : label
|
||||
}}
|
||||
>
|
||||
<Tag size={10} class="inline -mt-px" />{label}
|
||||
{#if label === labelFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
{#if !loading}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
|
||||
<Popover floatingConfig={{ placement: 'bottom-end' }}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: ListFilterPlus
|
||||
}}
|
||||
nonCaptureEvent
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-4">
|
||||
<span class="text-sm font-semibold text-emphasis">Filters</span>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
|
||||
{#if $userStore && !$userStore.operator}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={includeWithoutMain}
|
||||
options={{ right: 'Include library scripts' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if filterUserFoldersType === 'only f/*'}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if filterUserFoldersType === 'u/username and f/*'}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore?.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mb-1 items-center h-6">
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
@@ -809,12 +609,7 @@
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
on:reload={reloadAll}
|
||||
{showCode}
|
||||
/>
|
||||
{:else}
|
||||
@@ -826,12 +621,7 @@
|
||||
on:flowChanged={loadFlows}
|
||||
on:appChanged={loadApps}
|
||||
on:rawAppChanged={loadRawApps}
|
||||
on:reload={() => {
|
||||
loadScripts(includeWithoutMain)
|
||||
loadFlows()
|
||||
loadApps()
|
||||
loadRawApps()
|
||||
}}
|
||||
on:reload={reloadAll}
|
||||
{showCode}
|
||||
showEditButton={showEditButtons}
|
||||
keyboardSelected={selectedIndex === i}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Folder, Tag, Archive, Library, Users } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec, FilterInstanceRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildHomeFilterSchema({
|
||||
owners,
|
||||
labels,
|
||||
showUserFoldersFilter,
|
||||
userFoldersLabel
|
||||
}: {
|
||||
owners: string[]
|
||||
labels: string[]
|
||||
showUserFoldersFilter?: boolean
|
||||
userFoldersLabel?: string
|
||||
}) {
|
||||
return {
|
||||
_default_: {
|
||||
type: 'string' as const,
|
||||
hidden: true
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: owners.map((o) => ({ label: o, value: o })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Owner / folder',
|
||||
icon: Folder,
|
||||
description: 'Filter by owner or folder prefix (e.g. u/alice or f/team)'
|
||||
},
|
||||
label: {
|
||||
type: 'oneof' as const,
|
||||
options: labels.map((l) => ({ label: l, value: l })),
|
||||
allowNegative: false,
|
||||
allowMultiple: true,
|
||||
label: 'Label',
|
||||
icon: Tag,
|
||||
description: 'Filter by label (comma-separated to require multiple)'
|
||||
},
|
||||
archived: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Only archived',
|
||||
icon: Archive,
|
||||
description: 'Show only archived items'
|
||||
},
|
||||
exclude_library: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Exclude library scripts',
|
||||
icon: Library,
|
||||
description: 'Hide scripts without a main function'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only items in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
|
||||
export type HomeFilterSchema = ReturnType<typeof buildHomeFilterSchema>
|
||||
export type HomeFilterValue = Partial<FilterInstanceRec<HomeFilterSchema>>
|
||||
|
||||
export type LatestItem = {
|
||||
type: 'script' | 'flow' | 'app' | 'raw_app'
|
||||
path: string
|
||||
summary?: string
|
||||
time?: number
|
||||
starred?: boolean
|
||||
hash?: string
|
||||
draft_only?: boolean
|
||||
raw_app?: boolean
|
||||
workspace_id?: string
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
|
||||
import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from '$lib/components/home/FlowIcon.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import CreateActionsFlow from '$lib/components/flows/CreateActionsFlow.svelte'
|
||||
import CreateActionsScript from '$lib/components/scripts/CreateActionsScript.svelte'
|
||||
import HomeCreateActions from '$lib/components/home/HomeCreateActions.svelte'
|
||||
import HomeHero from '$lib/components/home/HomeHero.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildHomeFilterSchema, type LatestItem } from '$lib/components/home/homeFilter'
|
||||
import { getScriptByPath } from '$lib/scripts'
|
||||
import type { HubItem } from '$lib/components/flows/pickers/model'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
@@ -20,32 +24,30 @@
|
||||
GitFork,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Code,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
PlugZap
|
||||
PlugZap,
|
||||
SearchCode
|
||||
} from 'lucide-svelte'
|
||||
import { hubBaseUrlStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
import ItemsList from '$lib/components/home/ItemsList.svelte'
|
||||
import CreateActionsApp from '$lib/components/flows/CreateActionsApp.svelte'
|
||||
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
import { HOME_SHOW_HUB, HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
|
||||
import { HOME_SHOW_HUB } from '$lib/consts'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
|
||||
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
|
||||
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
|
||||
import { onMount, setContext } from 'svelte'
|
||||
import { getContext, onMount, setContext, untrack } from 'svelte'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
|
||||
import TutorialBanner from '$lib/components/home/TutorialBanner.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { z } from 'zod'
|
||||
|
||||
type Tab = 'hub' | 'workspace'
|
||||
|
||||
@@ -55,11 +57,53 @@
|
||||
: 'workspace'
|
||||
)
|
||||
|
||||
let subtab: 'flow' | 'script' | 'app' = $state('script')
|
||||
function selectTab(t: Tab) {
|
||||
tab = t
|
||||
if (typeof window !== 'undefined') window.location.hash = t
|
||||
}
|
||||
|
||||
const searchParams = useSearchParams(z.object({ search: z.string().nullable() }))
|
||||
const getFilter = () => searchParams.search ?? ''
|
||||
const setFilter = (v: string) => (searchParams.search = v === '' ? null : v)
|
||||
// Unified item-kind toggle (All / Scripts / Flows / Apps). Drives workspace
|
||||
// filtering and, for the Hub tab, which picker is shown ('all' → scripts).
|
||||
let itemKind: 'all' | 'script' | 'flow' | 'app' = $state(
|
||||
(page.url.searchParams.get('kind') as 'all' | 'script' | 'flow' | 'app') ?? 'all'
|
||||
)
|
||||
let hubKind = $derived(itemKind === 'all' ? 'script' : itemKind)
|
||||
|
||||
// Meta surfaced by ItemsList (owners/labels for the filter schema, latest for
|
||||
// the hero). Empty until the workspace list has loaded.
|
||||
let homeMeta = $state<{
|
||||
owners: string[]
|
||||
labels: string[]
|
||||
loading: boolean
|
||||
latest: LatestItem[]
|
||||
}>({ owners: [], labels: [], loading: true, latest: [] })
|
||||
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
|
||||
let homeFilterSchema = $derived(
|
||||
buildHomeFilterSchema({
|
||||
owners: homeMeta.owners,
|
||||
labels: homeMeta.labels,
|
||||
showUserFoldersFilter: filterUserFoldersType !== undefined,
|
||||
userFoldersLabel:
|
||||
filterUserFoldersType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => homeFilterSchema))
|
||||
|
||||
const getFilter = () => filters.val._default_ ?? ''
|
||||
const setFilter = (v: string) => (filters.val._default_ = v === '' ? undefined : v)
|
||||
|
||||
let presets = $derived([
|
||||
...homeMeta.owners.map((o) => ({ name: o, value: `path:\\ ${o}` })),
|
||||
...homeMeta.labels.map((l) => ({ name: l, value: `label:\\ ${l}` }))
|
||||
])
|
||||
|
||||
let flowViewer: Drawer | undefined = $state(undefined)
|
||||
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
|
||||
@@ -117,6 +161,10 @@
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
// Check if there's a tutorial parameter in the URL
|
||||
const tutorialParam = page.url.searchParams.get('tutorial')
|
||||
@@ -293,21 +341,17 @@
|
||||
title="Home"
|
||||
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
|
||||
>
|
||||
{#if $userStore?.operator}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
btnClasses="whitespace-nowrap"
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
btnClasses="whitespace-nowrap"
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && showCreateButtons}
|
||||
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
|
||||
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
||||
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
||||
<HomeCreateActions />
|
||||
{/if}
|
||||
</PageHeader>
|
||||
|
||||
@@ -315,97 +359,89 @@
|
||||
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
{#if !$userStore?.operator}
|
||||
<div class="flex w-full items-center gap-3 pb-2">
|
||||
<div class="min-w-0 flex-1 overflow-auto scrollbar-hidden">
|
||||
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
|
||||
<Tab value="workspace" label="Workspace" icon={Building} />
|
||||
{#if HOME_SHOW_HUB}
|
||||
<Tab value="hub" label="Hub" icon={Globe2} />
|
||||
{/if}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
btnClasses="whitespace-nowrap shrink-0"
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
</div>
|
||||
{#if tab == 'workspace'}
|
||||
<HomeHero latest={homeMeta.latest} />
|
||||
{/if}
|
||||
|
||||
<!-- Unified toolbar: Workspace/Hub + kind toggles + filter searchbar -->
|
||||
<div class="flex flex-wrap items-center gap-2 w-full pt-3 pb-2">
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<ToggleButtonGroup selected={tab} onSelected={(v) => selectTab(v as Tab)} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="workspace" label="Workspace" icon={Building} {item} />
|
||||
<ToggleButton value="hub" label="Hub" icon={Globe2} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
onSelected={(v) => setQuery(page.url, 'kind', v, window.location.hash)}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
<ToggleButton value="script" label="Scripts" icon={Code2} {item} />
|
||||
<ToggleButton value="flow" label="Flows" icon={FlowIcon} selectedColor="#14b8a6" {item} />
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<div class="grow min-w-[12rem]">
|
||||
<FilterSearchbar
|
||||
schema={homeFilterSchema}
|
||||
bind:value={filters.val}
|
||||
{presets}
|
||||
placeholder="Search scripts, flows & apps..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => openSearchWithPrefilledText?.('#')}
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
endIcon={{ icon: SearchCode }}
|
||||
btnClasses="whitespace-nowrap"
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
{#if tab == 'hub'}
|
||||
<Button
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href={$hubBaseUrlStore}
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tab == 'hub'}
|
||||
<div class="flex flex-col gap-y-16">
|
||||
<div class="flex flex-col pb-8">
|
||||
{#snippet toggleKinds()}
|
||||
<ToggleButtonGroup
|
||||
bind:selected={subtab}
|
||||
onSelected={(v) => {
|
||||
setQuery(page.url, 'kind', v, window.location.hash)
|
||||
}}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="script" label="Scripts" icon={Code} {item} />
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
target="_blank"
|
||||
href={$hubBaseUrlStore}
|
||||
variant="default"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if subtab == 'script'}
|
||||
{#if hubKind == 'script'}
|
||||
<PickHubScript
|
||||
syncQuery
|
||||
bind:filter={getFilter, setFilter}
|
||||
on:pick={(e) => viewCode(e.detail)}
|
||||
>
|
||||
{#snippet children()}
|
||||
{@render toggleKinds?.()}
|
||||
{/snippet}
|
||||
</PickHubScript>
|
||||
{:else if subtab == 'flow'}
|
||||
/>
|
||||
{:else if hubKind == 'flow'}
|
||||
<PickHubFlow
|
||||
syncQuery
|
||||
bind:filter={getFilter, setFilter}
|
||||
on:pick={(e) => viewFlow(e.detail)}
|
||||
>
|
||||
{#snippet children()}
|
||||
{@render toggleKinds?.()}
|
||||
{/snippet}
|
||||
</PickHubFlow>
|
||||
{:else if subtab == 'app'}
|
||||
/>
|
||||
{:else if hubKind == 'app'}
|
||||
<PickHubApp
|
||||
syncQuery
|
||||
bind:filter={getFilter, setFilter}
|
||||
on:pick={(e) => viewApp(e.detail)}
|
||||
>
|
||||
{#snippet children()}
|
||||
{@render toggleKinds?.()}
|
||||
{/snippet}
|
||||
</PickHubApp>
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +449,12 @@
|
||||
</div>
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<ItemsList bind:filter={getFilter, setFilter} bind:subtab showEditButtons={showCreateButtons} />
|
||||
<ItemsList
|
||||
filterValue={filters.val}
|
||||
{itemKind}
|
||||
showEditButtons={showCreateButtons}
|
||||
onMeta={(m) => (homeMeta = m)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user