mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
fix(frontend): improve workspace page (#7502)
* nit * Improve workspace view by showing a tree * implement search for workspaces * Add collapse expand button * improve unarchive button * nit * move search * nit * add max h * Add keyboard navigation * clean code * Show admin workspaces with other workspaces * Update frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * nit --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<p class="text-sm font-semibold text-emphasis text-center mt-2">
|
||||
<p class="text-xs font-normal text-primary text-center mt-2">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="900px" on:close={removeHash}>
|
||||
<DrawerContent title="User Settings" on:close={closeDrawer}>
|
||||
<DrawerContent title="User settings" on:close={closeDrawer}>
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
{#if scopes == undefined}
|
||||
<div
|
||||
|
||||
@@ -221,7 +221,7 @@ export namespace ButtonType {
|
||||
|
||||
// New unified sizing system
|
||||
export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = {
|
||||
xs: 'px-1',
|
||||
xs: 'px-2',
|
||||
sm: 'px-2', // Regular horizontal padding
|
||||
md: 'px-4',
|
||||
lg: 'px-6'
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
FoldVertical,
|
||||
LayoutDashboard,
|
||||
ListFilterPlus,
|
||||
SearchCode,
|
||||
UnfoldVertical
|
||||
SearchCode
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
|
||||
@@ -502,11 +502,11 @@
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{
|
||||
icon: collapseAll ? UnfoldVertical : FoldVertical
|
||||
icon: collapseAll ? ChevronsUpDown : ChevronsDownUp
|
||||
}}
|
||||
>
|
||||
{#if collapseAll}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
} from '$lib/stores'
|
||||
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents'
|
||||
import { Menu, MenuItem } from '$lib/components/meltComponents'
|
||||
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { page } from '$app/stores'
|
||||
@@ -78,7 +79,7 @@
|
||||
}
|
||||
|
||||
// Group workspaces into parent-child hierarchy using Svelte 5 derived and the new utility
|
||||
const groupedWorkspaces = $derived(() => {
|
||||
const groupedWorkspaces = $derived.by(() => {
|
||||
if (!$userWorkspaces) return []
|
||||
return buildWorkspaceHierarchy($userWorkspaces)
|
||||
})
|
||||
@@ -87,28 +88,6 @@
|
||||
'text-primary flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
</script>
|
||||
|
||||
{#snippet workspaceIcon(
|
||||
workspaceColor: string | undefined,
|
||||
isForked: boolean,
|
||||
parentName: string | undefined
|
||||
)}
|
||||
{@const iconColor = getContrastTextColor(workspaceColor)}
|
||||
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
|
||||
{#if isForked}
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{#if isForked && parentName}
|
||||
Fork of {parentName}
|
||||
{/if}
|
||||
{/snippet}
|
||||
<GitFork size={14} class="flex-shrink-0" style="color: {iconColor}" />
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<Building size={14} style="color: {iconColor}" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
|
||||
@@ -143,7 +122,7 @@
|
||||
{#snippet children({ item })}
|
||||
<div class="divide-y" role="none">
|
||||
<div class="py-1">
|
||||
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
|
||||
{#each groupedWorkspaces as { workspace, depth, isForked, parentName }}
|
||||
{@const isSelected = $workspaceStore === workspace.id}
|
||||
<MenuItem
|
||||
class={twMerge(
|
||||
@@ -164,7 +143,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between min-w-0 w-full">
|
||||
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
|
||||
{@render workspaceIcon(workspace.color, isForked, parentName)}
|
||||
<WorkspaceIcon workspaceColor={workspace.color} {isForked} {parentName} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { GitFork, ChevronUp, ArchiveRestore } from 'lucide-svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { Badge, Button } from '$lib/components/common'
|
||||
import type { UserWorkspace } from '$lib/stores'
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { pluralize } from '$lib/utils'
|
||||
import WorkspaceIcon from './WorkspaceIcon.svelte'
|
||||
import WorkspaceCard from './WorkspaceCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface ExtendedWorkspace extends UserWorkspace {
|
||||
_children?: ExtendedWorkspace[]
|
||||
marked?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: UserWorkspace & { marked?: string }
|
||||
isForked?: boolean
|
||||
depth?: number
|
||||
children?: ExtendedWorkspace[]
|
||||
isExpanded?: boolean
|
||||
expansionStates?: Record<string, boolean>
|
||||
onEnterWorkspace: (workspaceId: string) => Promise<void>
|
||||
onUnarchive?: (workspaceId: string) => Promise<void>
|
||||
onToggleExpand?: (workspaceId: string) => void
|
||||
selectedWorkspaceId?: string | null
|
||||
onMouseEnter?: (workspaceId: string) => void
|
||||
onMouseClick?: () => void
|
||||
onKeyboardNavigation?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
workspace,
|
||||
isForked = false,
|
||||
depth = 0,
|
||||
children = [],
|
||||
isExpanded = false,
|
||||
expansionStates = {},
|
||||
onEnterWorkspace,
|
||||
onUnarchive,
|
||||
onToggleExpand,
|
||||
selectedWorkspaceId,
|
||||
onMouseEnter,
|
||||
onMouseClick,
|
||||
onKeyboardNavigation
|
||||
}: Props = $props()
|
||||
|
||||
const paddingLeft = depth * 24
|
||||
const isSelected = $derived(selectedWorkspaceId === workspace.id)
|
||||
|
||||
// Helper functions
|
||||
function isWorkspaceArchived(workspace: UserWorkspace): boolean {
|
||||
return workspace['deleted'] === true
|
||||
}
|
||||
|
||||
function isWorkspaceDisabled(workspace: UserWorkspace): boolean {
|
||||
return workspace.disabled === true
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (onUnarchive) {
|
||||
await WorkspaceService.unarchiveWorkspace({ workspace: workspace.id })
|
||||
await onUnarchive(workspace.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="block pb-2" style:padding-left={`${paddingLeft}px`}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'border border-border-light rounded-md overflow-hidden transition-all duration-150',
|
||||
isSelected ? 'bg-surface-hover' : 'bg-surface-tertiary'
|
||||
)}
|
||||
data-workspace-id={workspace.id}
|
||||
>
|
||||
<!-- Main workspace card - clickable to enter workspace -->
|
||||
<div
|
||||
class="px-4 py-2 hover:bg-surface-hover transition-colors w-full"
|
||||
class:rounded-lg={children.length === 0}
|
||||
class:rounded-b-none={children.length > 0}
|
||||
class:opacity-60={isWorkspaceDisabled(workspace)}
|
||||
class:cursor-not-allowed={isWorkspaceDisabled(workspace)}
|
||||
class:cursor-pointer={!isWorkspaceDisabled(workspace)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={async () => {
|
||||
onMouseClick?.()
|
||||
if (!isWorkspaceDisabled(workspace)) {
|
||||
await onEnterWorkspace(workspace.id)
|
||||
}
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !isWorkspaceDisabled(workspace)) {
|
||||
e.preventDefault()
|
||||
onKeyboardNavigation?.()
|
||||
onEnterWorkspace(workspace.id)
|
||||
}
|
||||
}}
|
||||
onmouseenter={() => onMouseEnter?.(workspace.id)}
|
||||
>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-row items-center gap-3 flex-1 min-w-0">
|
||||
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex-shrink-0">
|
||||
<WorkspaceIcon
|
||||
workspaceColor={workspace.color}
|
||||
{isForked}
|
||||
parentName={workspace.parent_workspace_id ?? undefined}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-semibold text-primary truncate">
|
||||
{#if workspace.marked}
|
||||
{@html workspace.marked}
|
||||
{:else}
|
||||
{workspace.name}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-secondary text-xs">-</span>
|
||||
{#if workspace.id === 'admins'}
|
||||
<Badge color="blue">{workspace.id}</Badge>
|
||||
{:else}
|
||||
<span class="font-mono text-2xs text-secondary truncate">
|
||||
{workspace.id}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-secondary">
|
||||
as <span class="font-mono">{workspace.username}</span>
|
||||
{#if isWorkspaceArchived(workspace)}
|
||||
<span class="text-red-500 ml-1">(archived)</span>
|
||||
{#if $superadmin && onUnarchive}
|
||||
<Button
|
||||
size="xs2"
|
||||
variant="default"
|
||||
btnClasses="ml-1"
|
||||
propagateEvent={false}
|
||||
onClick={handleUnarchive}
|
||||
startIcon={{ icon: ArchiveRestore }}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if isWorkspaceDisabled(workspace)}
|
||||
<span class="text-red-500 ml-1">(user disabled in this workspace)</span>
|
||||
{/if}
|
||||
{#if workspace.id === 'admins'}
|
||||
<span class="text-accent ml-1">Used to manage your Windmill instance</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forks section - clickable to expand -->
|
||||
{#if children.length > 0}
|
||||
<div
|
||||
class="border-t border-border-light px-4 py-1.5 hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => {
|
||||
onMouseClick?.()
|
||||
onToggleExpand?.(workspace.id)
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onKeyboardNavigation?.()
|
||||
onToggleExpand?.(workspace.id)
|
||||
}
|
||||
}}
|
||||
onmouseenter={() => onMouseEnter?.(workspace.id)}
|
||||
>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-row items-center gap-2 pl-2">
|
||||
<GitFork size={10} class="text-primary" />
|
||||
<span class="text-2xs text-primary">
|
||||
{pluralize(children.length, 'fork', 'forks')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ChevronUp
|
||||
size={16}
|
||||
class={twMerge(
|
||||
'text-secondary transition-transform duration-150',
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Expanded forks -->
|
||||
{#if children.length > 0 && isExpanded}
|
||||
<div class="mt-2 ml-6" transition:slide={{ duration: 150 }}>
|
||||
{#each children as child (child.id)}
|
||||
<WorkspaceCard
|
||||
workspace={child}
|
||||
isForked={true}
|
||||
depth={depth + 1}
|
||||
children={child._children || []}
|
||||
isExpanded={expansionStates[child.id] ?? false}
|
||||
{expansionStates}
|
||||
{onEnterWorkspace}
|
||||
{onUnarchive}
|
||||
{onToggleExpand}
|
||||
{selectedWorkspaceId}
|
||||
{onMouseEnter}
|
||||
{onMouseClick}
|
||||
{onKeyboardNavigation}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Building, GitFork } from 'lucide-svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
workspaceColor?: string
|
||||
isForked?: boolean
|
||||
parentName?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
let { workspaceColor, isForked = false, parentName, size = 14 }: Props = $props()
|
||||
|
||||
const iconColor = $derived(getContrastTextColor(workspaceColor))
|
||||
</script>
|
||||
|
||||
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
|
||||
{#if isForked}
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
{#if isForked && parentName}
|
||||
Fork of {parentName}
|
||||
{/if}
|
||||
{/snippet}
|
||||
<GitFork {size} class="flex-shrink-0" style="color: {iconColor}" />
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<Building {size} style="color: {iconColor}" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import { Building2 } from 'lucide-svelte'
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import WorkspaceCard from './WorkspaceCard.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import type { UserWorkspace } from '$lib/stores'
|
||||
|
||||
interface ExtendedWorkspace extends UserWorkspace {
|
||||
_children?: ExtendedWorkspace[]
|
||||
marked?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaces: UserWorkspace[]
|
||||
onEnterWorkspace: (workspaceId: string) => Promise<void>
|
||||
onUnarchive?: (workspaceId: string) => Promise<void>
|
||||
searchFilter?: string
|
||||
onExpandCollapseAll?: () => void
|
||||
allExpanded?: boolean
|
||||
hasForks?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
workspaces,
|
||||
onEnterWorkspace,
|
||||
onUnarchive,
|
||||
searchFilter = $bindable(''),
|
||||
onExpandCollapseAll = $bindable(),
|
||||
allExpanded = $bindable(false),
|
||||
hasForks = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
// State for manually toggled expansion status
|
||||
let manualExpansionStates = $state<Record<string, boolean>>({})
|
||||
let filteredWorkspaces: (UserWorkspace & { marked?: string })[] | undefined = $state()
|
||||
|
||||
// Keyboard navigation state
|
||||
let selectedWorkspaceId = $state<string | null>(null)
|
||||
let isKeyboardNavigation = $state(false)
|
||||
let scrollContainer: HTMLElement
|
||||
|
||||
// Computed expansion states that include auto-expansion for search results
|
||||
let expansionStates = $derived.by(() => {
|
||||
if (!searchFilter || !filteredWorkspaces || !workspaces) {
|
||||
return manualExpansionStates
|
||||
}
|
||||
|
||||
const matchedWorkspaceIds = new Set(filteredWorkspaces.map((w) => w.id))
|
||||
const autoExpanded: Record<string, boolean> = {}
|
||||
|
||||
// Build children map for descendant checking
|
||||
const childrenMap = new Map<string, string[]>()
|
||||
workspaces.forEach((workspace) => {
|
||||
if (workspace.parent_workspace_id) {
|
||||
if (!childrenMap.has(workspace.parent_workspace_id)) {
|
||||
childrenMap.set(workspace.parent_workspace_id, [])
|
||||
}
|
||||
childrenMap.get(workspace.parent_workspace_id)!.push(workspace.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Function to check if workspace has matching descendants (not itself)
|
||||
function hasMatchingDescendant(workspaceId: string): boolean {
|
||||
const children = childrenMap.get(workspaceId) || []
|
||||
return children.some(
|
||||
(childId) => matchedWorkspaceIds.has(childId) || hasMatchingDescendant(childId)
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-expand workspaces only if they have matching descendants
|
||||
// (not if the workspace itself matches - user can manually expand to see children)
|
||||
workspaces.forEach((workspace) => {
|
||||
if (hasMatchingDescendant(workspace.id)) {
|
||||
autoExpanded[workspace.id] = true
|
||||
}
|
||||
})
|
||||
|
||||
// Combine manual and auto-expanded states
|
||||
return { ...manualExpansionStates, ...autoExpanded }
|
||||
})
|
||||
|
||||
// Build nested hierarchy correctly - always use full workspace list for hierarchy
|
||||
let rootWorkspaces = $derived.by(() => {
|
||||
if (!workspaces) return []
|
||||
|
||||
// Create a map of children for each parent workspace using ALL workspaces
|
||||
const childrenMap = new SvelteMap<string, ExtendedWorkspace[]>()
|
||||
|
||||
// Build children mapping - this correctly handles nested relationships
|
||||
workspaces.forEach((workspace) => {
|
||||
if (workspace.parent_workspace_id) {
|
||||
if (!childrenMap.has(workspace.parent_workspace_id)) {
|
||||
childrenMap.set(workspace.parent_workspace_id, [])
|
||||
}
|
||||
// Find marked version from filtered results if available
|
||||
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
|
||||
const extendedWorkspace = {
|
||||
...workspace,
|
||||
marked: filteredWorkspace?.marked
|
||||
} as ExtendedWorkspace
|
||||
childrenMap.get(workspace.parent_workspace_id)!.push(extendedWorkspace)
|
||||
}
|
||||
})
|
||||
|
||||
// Get IDs of workspaces that match the search
|
||||
const matchedWorkspaceIds = new Set(filteredWorkspaces?.map((w) => w.id) || [])
|
||||
|
||||
// Function to check if a workspace or its descendants match the search
|
||||
function hasMatchingDescendant(workspaceId: string): boolean {
|
||||
if (matchedWorkspaceIds.has(workspaceId)) return true
|
||||
const children = childrenMap.get(workspaceId) || []
|
||||
return children.some((child) => hasMatchingDescendant(child.id))
|
||||
}
|
||||
|
||||
// Recursive function to build full nested hierarchy
|
||||
// parentMatched: if true, include all children regardless of search match
|
||||
function buildWorkspaceWithChildren(
|
||||
workspace: UserWorkspace,
|
||||
parentMatched: boolean = false
|
||||
): ExtendedWorkspace {
|
||||
const directChildren = childrenMap.get(workspace.id) || []
|
||||
const thisWorkspaceMatches = matchedWorkspaceIds.has(workspace.id)
|
||||
|
||||
// If this workspace or a parent matches, show all children
|
||||
// Otherwise, only show children that match or have matching descendants
|
||||
const visibleChildren =
|
||||
searchFilter && !parentMatched && !thisWorkspaceMatches
|
||||
? directChildren.filter((child) => hasMatchingDescendant(child.id))
|
||||
: directChildren
|
||||
|
||||
const childrenWithNestedStructure = visibleChildren.map((child) =>
|
||||
buildWorkspaceWithChildren(child, parentMatched || thisWorkspaceMatches)
|
||||
)
|
||||
|
||||
// Find marked version from filtered results if available
|
||||
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
marked: filteredWorkspace?.marked,
|
||||
_children: childrenWithNestedStructure
|
||||
}
|
||||
}
|
||||
|
||||
// Return only root workspaces - filter based on search if active
|
||||
const rootCandidates = workspaces.filter(
|
||||
(workspace) =>
|
||||
!workspace.parent_workspace_id ||
|
||||
!workspaces.find((w) => w.id === workspace.parent_workspace_id)
|
||||
)
|
||||
|
||||
const visibleRoots = searchFilter
|
||||
? rootCandidates.filter((workspace) => hasMatchingDescendant(workspace.id))
|
||||
: rootCandidates
|
||||
|
||||
return visibleRoots
|
||||
.map((workspace) => buildWorkspaceWithChildren(workspace))
|
||||
.sort((a, b) => {
|
||||
// Admin workspace always goes first
|
||||
if (a.id === 'admins') return -1
|
||||
if (b.id === 'admins') return 1
|
||||
// Then sort alphabetically by name
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
function handleToggleExpand(workspaceId: string) {
|
||||
const currentState = expansionStates[workspaceId] ?? false
|
||||
manualExpansionStates = { ...manualExpansionStates, [workspaceId]: !currentState }
|
||||
}
|
||||
|
||||
// Get IDs of workspaces that have children (can be expanded)
|
||||
let workspacesWithChildren = $derived.by(() => {
|
||||
if (!workspaces) return []
|
||||
const parentIds = new Set(
|
||||
workspaces.filter((w) => w.parent_workspace_id).map((w) => w.parent_workspace_id)
|
||||
)
|
||||
return workspaces.filter((w) => parentIds.has(w.id)).map((w) => w.id)
|
||||
})
|
||||
|
||||
// Check if all expandable workspaces are currently expanded
|
||||
let allExpandedInternal = $derived(
|
||||
workspacesWithChildren.length > 0 &&
|
||||
workspacesWithChildren.every((id) => expansionStates[id] === true)
|
||||
)
|
||||
|
||||
// Sync internal state to bindable props
|
||||
$effect(() => {
|
||||
allExpanded = allExpandedInternal
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
hasForks = workspacesWithChildren.length > 0
|
||||
})
|
||||
|
||||
export function handleExpandCollapseAll() {
|
||||
const newState = !allExpandedInternal
|
||||
const newExpansionStates: Record<string, boolean> = {}
|
||||
workspacesWithChildren.forEach((id) => {
|
||||
newExpansionStates[id] = newState
|
||||
})
|
||||
manualExpansionStates = newExpansionStates
|
||||
}
|
||||
|
||||
// Generate flattened navigation order for keyboard navigation
|
||||
const flatNavigationOrder = $derived.by(() => {
|
||||
const result: string[] = []
|
||||
|
||||
function addWorkspaceAndChildren(workspace: ExtendedWorkspace) {
|
||||
result.push(workspace.id)
|
||||
if (workspace._children && expansionStates[workspace.id]) {
|
||||
workspace._children.forEach((child) => addWorkspaceAndChildren(child))
|
||||
}
|
||||
}
|
||||
|
||||
rootWorkspaces.forEach((workspace) => addWorkspaceAndChildren(workspace))
|
||||
return result
|
||||
})
|
||||
|
||||
// Keyboard navigation handlers
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// Allow navigation keys even when search input has focus
|
||||
const navigationKeys = [
|
||||
'ArrowDown',
|
||||
'ArrowUp',
|
||||
'Home',
|
||||
'End',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Enter',
|
||||
' ',
|
||||
'Escape'
|
||||
]
|
||||
const activeElement = document.activeElement
|
||||
|
||||
// Skip navigation only if user is typing in textarea or non-search inputs
|
||||
if (
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
(activeElement?.tagName === 'INPUT' && !navigationKeys.includes(event.key))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enable keyboard navigation on arrow keys
|
||||
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
enableKeyboardNavigation()
|
||||
}
|
||||
|
||||
// Only handle navigation if keyboard navigation is active
|
||||
if (!isKeyboardNavigation) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flatNavigationOrder.length === 0) return
|
||||
|
||||
const currentIndex = selectedWorkspaceId ? flatNavigationOrder.indexOf(selectedWorkspaceId) : -1
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
const nextIndex = currentIndex < flatNavigationOrder.length - 1 ? currentIndex + 1 : 0
|
||||
selectedWorkspaceId = flatNavigationOrder[nextIndex]
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : flatNavigationOrder.length - 1
|
||||
selectedWorkspaceId = flatNavigationOrder[prevIndex]
|
||||
break
|
||||
}
|
||||
case 'Home': {
|
||||
event.preventDefault()
|
||||
selectedWorkspaceId = flatNavigationOrder[0]
|
||||
break
|
||||
}
|
||||
case 'End': {
|
||||
event.preventDefault()
|
||||
selectedWorkspaceId = flatNavigationOrder[flatNavigationOrder.length - 1]
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
|
||||
event.preventDefault()
|
||||
if (!expansionStates[selectedWorkspaceId]) {
|
||||
handleToggleExpand(selectedWorkspaceId)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
|
||||
event.preventDefault()
|
||||
if (expansionStates[selectedWorkspaceId]) {
|
||||
handleToggleExpand(selectedWorkspaceId)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
if (selectedWorkspaceId) {
|
||||
event.preventDefault()
|
||||
const workspace = workspaces.find((w) => w.id === selectedWorkspaceId)
|
||||
if (workspace && !workspace.disabled) {
|
||||
onEnterWorkspace(selectedWorkspaceId)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
selectedWorkspaceId = null
|
||||
isKeyboardNavigation = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset selection when workspaces change or search filter changes
|
||||
$effect(() => {
|
||||
if (rootWorkspaces.length === 0) {
|
||||
selectedWorkspaceId = null
|
||||
} else if (selectedWorkspaceId && !flatNavigationOrder.includes(selectedWorkspaceId)) {
|
||||
// If currently selected workspace is no longer visible, reset to first visible
|
||||
selectedWorkspaceId = flatNavigationOrder[0] || null
|
||||
}
|
||||
})
|
||||
|
||||
// Enable keyboard navigation when user starts navigating
|
||||
function enableKeyboardNavigation() {
|
||||
if (!isKeyboardNavigation && rootWorkspaces.length > 0) {
|
||||
isKeyboardNavigation = true
|
||||
if (!selectedWorkspaceId) {
|
||||
selectedWorkspaceId = flatNavigationOrder[0] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mouse interactions - disable keyboard mode when mouse is used
|
||||
function handleMouseEnter(workspaceId: string) {
|
||||
if (isKeyboardNavigation) {
|
||||
selectedWorkspaceId = workspaceId
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseClick() {
|
||||
isKeyboardNavigation = false
|
||||
selectedWorkspaceId = null
|
||||
}
|
||||
|
||||
// Scroll selected workspace into view
|
||||
function scrollToSelectedWorkspace() {
|
||||
if (!selectedWorkspaceId || !scrollContainer) return
|
||||
|
||||
// Find the workspace card element by data attribute
|
||||
const selectedElement = scrollContainer.querySelector(
|
||||
`[data-workspace-id="${selectedWorkspaceId}"]`
|
||||
)
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when selection changes
|
||||
$effect(() => {
|
||||
if (selectedWorkspaceId && isKeyboardNavigation) {
|
||||
scrollToSelectedWorkspace()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<!-- Search Items Component for fuzzy search with highlighting -->
|
||||
<SearchItems
|
||||
filter={searchFilter}
|
||||
items={workspaces}
|
||||
bind:filteredItems={filteredWorkspaces}
|
||||
f={(workspace) => workspace.name + ' (' + workspace.id + ')'}
|
||||
/>
|
||||
|
||||
<div class="space-y-4 max-h-[50vh] overflow-auto" bind:this={scrollContainer}>
|
||||
<!-- Workspace Tree -->
|
||||
<div class="space-y-2">
|
||||
{#each rootWorkspaces as workspace (workspace.id)}
|
||||
<WorkspaceCard
|
||||
{workspace}
|
||||
children={workspace._children || []}
|
||||
isExpanded={expansionStates[workspace.id] ?? false}
|
||||
{expansionStates}
|
||||
{onEnterWorkspace}
|
||||
{onUnarchive}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
{selectedWorkspaceId}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseClick={handleMouseClick}
|
||||
onKeyboardNavigation={enableKeyboardNavigation}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if rootWorkspaces.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<Building2 size={48} class="text-secondary mx-auto mb-3" />
|
||||
<p class="text-sm text-secondary">
|
||||
{searchFilter ? 'No workspaces match your search' : 'No workspaces available'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -801,7 +801,7 @@ export function pluralize(quantity: number, word: string, customPlural?: string)
|
||||
if (quantity == 1) {
|
||||
return `${quantity} ${word}`
|
||||
} else if (customPlural) {
|
||||
return `${quantity} ${customPlural}}`
|
||||
return `${quantity} ${customPlural}`
|
||||
} else {
|
||||
return `${quantity} ${word}s`
|
||||
}
|
||||
|
||||
@@ -80,16 +80,16 @@
|
||||
</label>
|
||||
{/if}
|
||||
<div class="flex flex-row justify-between pt-4 gap-x-1">
|
||||
<Button variant="default" size="sm" href="{base}/user/workspaces"
|
||||
<Button variant="default" unifiedSize="md" href="{base}/user/workspaces"
|
||||
>← Back to workspaces</Button
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
disabled={checking || (!automateUsernameCreation && (errorUsername != '' || !username))}
|
||||
class="place-items-end bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 border rounded"
|
||||
type="button"
|
||||
on:click={acceptInvite}
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
onClick={acceptInvite}
|
||||
>
|
||||
Accept invite
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Crown, GitFork, Settings } from 'lucide-svelte'
|
||||
import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { refreshSuperadmin } from '$lib/refreshUser'
|
||||
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
|
||||
import WorkspaceTreeView from '$lib/components/workspace/WorkspaceTreeView.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import type { UserWorkspace } from '$lib/stores'
|
||||
|
||||
let invites: WorkspaceInvite[] = []
|
||||
@@ -34,6 +35,12 @@
|
||||
let workspaces: UserWorkspace[] | undefined = undefined
|
||||
let showAllForks: boolean = false
|
||||
|
||||
// Workspace tree controls
|
||||
let workspaceSearchFilter = ''
|
||||
let workspaceAllExpanded = false
|
||||
let workspaceHasForks = false
|
||||
let workspaceTreeView: WorkspaceTreeView | undefined = undefined
|
||||
|
||||
let userSettings: UserSettings
|
||||
let superadminSettings: SuperadminSettings
|
||||
|
||||
@@ -84,20 +91,8 @@
|
||||
}
|
||||
$: list_all_as_super_admin != undefined && $userWorkspaces && handleListWorkspaces()
|
||||
|
||||
$: adminsInstance = workspaces?.find((x) => x.id == 'admins') || $superadmin
|
||||
|
||||
// Complete workspace hierarchy with all forks
|
||||
$: forkedWorkspacesHierarchy = (() => {
|
||||
if (!workspaces) return []
|
||||
|
||||
// Filter out admin workspace
|
||||
const nonAdminWorkspaces = workspaces.filter((x) => x.id !== 'admins')
|
||||
|
||||
return buildWorkspaceHierarchy(nonAdminWorkspaces)
|
||||
})()
|
||||
|
||||
$: groupedNonAdminWorkspaces = forkedWorkspacesHierarchy
|
||||
$: noWorkspaces = $superadmin && groupedNonAdminWorkspaces.length == 0
|
||||
$: allWorkspaces = workspaces || []
|
||||
$: noWorkspaces = $superadmin && allWorkspaces.length == 0
|
||||
|
||||
async function getCreateWorkspaceRequireSuperadmin() {
|
||||
const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin')
|
||||
@@ -123,6 +118,20 @@
|
||||
|
||||
async function speakFriendAndEnterWorkspace(workspaceId: string) {
|
||||
loading = true
|
||||
|
||||
// Special handling for admins workspace
|
||||
if (workspaceId === 'admins') {
|
||||
workspaceStore.set('admins')
|
||||
if (rd?.startsWith('http')) {
|
||||
window.location.href = rd
|
||||
return
|
||||
}
|
||||
await goto(rd ?? '/')
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Regular workspace handling
|
||||
workspaceStore.set(undefined)
|
||||
workspaceStore.set(workspaceId)
|
||||
$userStore = await getUserExt($workspaceStore!)
|
||||
@@ -154,6 +163,10 @@
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
function workspaceExpandCollapseAll() {
|
||||
workspaceTreeView?.handleExpandCollapseAll()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $superadmin}
|
||||
@@ -161,38 +174,47 @@
|
||||
{/if}
|
||||
|
||||
<CenteredModal title="Select a workspace" subtitle="Logged in as {$usersWorkspaceStore?.email}">
|
||||
<div class="flex flex-row items-center gap-2 justify-between">
|
||||
<h2 class="mb-4 inline-flex gap-2 text-sm font-semibold text-emphasis">
|
||||
<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 $superadmin}
|
||||
<div class="flex flex-row-reverse pb-2">
|
||||
<Toggle
|
||||
bind:checked={list_all_as_super_admin}
|
||||
options={{ right: 'List all workspaces as superadmin' }}
|
||||
/>
|
||||
{#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 adminsInstance}
|
||||
<Button
|
||||
btnClasses="w-full mt-2 mb-4 truncate bg-surface-tertiary hover:bg-surface-secondary"
|
||||
size="sm"
|
||||
on:click={async () => {
|
||||
workspaceStore.set('admins')
|
||||
loading = true
|
||||
if (rd?.startsWith('http')) {
|
||||
window.location.href = rd
|
||||
return
|
||||
}
|
||||
await goto(rd ?? '/')
|
||||
loading = false
|
||||
}}
|
||||
variant="default"
|
||||
>Manage Windmill on the superadmins workspace
|
||||
</Button>
|
||||
{#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}
|
||||
@@ -202,58 +224,25 @@
|
||||
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}
|
||||
{#each groupedNonAdminWorkspaces as { workspace, depth, isForked } (workspace.id)}
|
||||
<label class="block pb-2" style:padding-left={`${depth * 24}px`}>
|
||||
<Button
|
||||
variant="default"
|
||||
btnClasses="bg-surface-tertiary hover:bg-surface-secondary"
|
||||
disabled={workspace.disabled}
|
||||
on:click={async () => {
|
||||
if (!workspace.disabled) {
|
||||
speakFriendAndEnterWorkspace(workspace.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if isForked}
|
||||
<GitFork size={12} class="text-primary mr-2 flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="flex-1 items-center">
|
||||
{#if workspace.color}
|
||||
<div
|
||||
class="inline-block w-4 h-4 mr-2 rounded-full border"
|
||||
style="background-color: {workspace.color}"
|
||||
></div>
|
||||
{/if}
|
||||
<span class="font-mono text-secondary">{workspace.id}</span> -
|
||||
<span class:text-secondary={isForked}>{workspace.name}</span>
|
||||
as
|
||||
<span class="font-mono" class:text-secondary={isForked}>{workspace.username}</span>
|
||||
{#if workspace['deleted']}
|
||||
<span class="text-red-500"> (archived)</span>
|
||||
{/if}
|
||||
{#if workspace.disabled}
|
||||
<span class="text-red-500"> (user disabled in this workspace)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
{#if $superadmin && workspace['deleted']}
|
||||
<Button
|
||||
size="xs"
|
||||
btnClasses="w-full mt-1"
|
||||
variant="default"
|
||||
on:click={async () => {
|
||||
await WorkspaceService.unarchiveWorkspace({ workspace: workspace.id })
|
||||
loadWorkspacesAsAdmin()
|
||||
}}
|
||||
>
|
||||
Unarchive {workspace.id}
|
||||
</Button>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each new Array(3) as _}
|
||||
{#each new Array(3) as _, i (i)}
|
||||
<Skeleton layout={[[2], 0.5]} />
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -261,10 +250,11 @@
|
||||
{#if createWorkspace}
|
||||
<div class="flex flex-row-reverse pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
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"
|
||||
>+ Create a new workspace
|
||||
</Button>
|
||||
</div>
|
||||
@@ -272,10 +262,19 @@
|
||||
|
||||
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
|
||||
|
||||
<h2 class="mt-6 mb-4 text-sm font-semibold text-emphasis">Invites to join a Workspace</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 mt-2"> You don't have new invites at the moment. </p>
|
||||
<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
|
||||
@@ -318,20 +317,19 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if workspaces}
|
||||
<div class="flex flex-row pt-6 pb-2">
|
||||
<Toggle size="xs" bind:checked={showAllForks} options={{ right: 'Show workspace forks' }} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAllForks}
|
||||
{@const allWorkspacesList = workspaces || []}
|
||||
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
|
||||
|
||||
<h2 class="mb-4 text-sm font-semibold text-emphasis">Forks of the workspaces you're in</h2>
|
||||
<div class="mt-4"></div>
|
||||
{#if filteredInvites.length == 0}
|
||||
<p class="text-xs text-secondary mt-2"> There isn't anything here </p>
|
||||
<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
|
||||
@@ -357,18 +355,21 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
|
||||
<a
|
||||
class="font-semibold text-xs p-1"
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="xs"
|
||||
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
|
||||
? `&rd=${encodeURIComponent(rd)}`
|
||||
: ''}"
|
||||
>
|
||||
Accept
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
class="text-red-700 font-semibold text-xs p-1"
|
||||
on:click={async () => {
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
destructive
|
||||
onClick={async () => {
|
||||
await UserService.declineInvite({
|
||||
requestBody: { workspace_id: invite.workspace_id }
|
||||
})
|
||||
@@ -377,7 +378,7 @@
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -387,25 +388,33 @@
|
||||
{#if $superadmin}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
unifiedSize="md"
|
||||
on:click={superadminSettings.openDrawer}
|
||||
startIcon={{ icon: Crown }}
|
||||
startIcon={{ icon: Settings }}
|
||||
dropdownItems={[
|
||||
{
|
||||
label: 'User settings',
|
||||
onClick: () => userSettings.openDrawer(),
|
||||
icon: User
|
||||
}
|
||||
]}
|
||||
>
|
||||
Superadmin settings
|
||||
Instance settings
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onClick={() => userSettings.openDrawer()}
|
||||
startIcon={{ icon: Settings }}
|
||||
>
|
||||
User settings
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
on:click={() => userSettings.openDrawer()}
|
||||
startIcon={{ icon: Settings }}
|
||||
>
|
||||
User settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
on:click={async () => {
|
||||
logout()
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user