Compare commits

...

23 Commits

Author SHA1 Message Date
GitHub Action
5a71450b85 [automated] Apply ESLint and Prettier fixes 2025-12-01 22:10:56 +00:00
coderabbitai[bot]
6031e6ccf2 📝 Add docstrings to drjkl/byom-2
Docstrings generation was requested by @guill.

* https://github.com/Comfy-Org/ComfyUI_frontend/pull/6969#issuecomment-3583609253

The following files were modified:

* `src/components/dialog/confirm/confirmDialog.ts`
* `src/composables/useFeatureFlags.ts`
* `src/platform/assets/services/assetService.ts`
* `src/services/dialogService.ts`
* `src/stores/dialogStore.ts`
2025-12-01 22:08:29 +00:00
DrJKL
4417b0d907 Update flag name 2025-12-01 13:40:51 -08:00
DrJKL
ff988cd6f1 cleanup: More consistent asset name 2025-12-01 13:39:14 -08:00
DrJKL
38cc93b8f6 Grammar is hard 2025-12-01 13:35:24 -08:00
DrJKL
23d8ccd394 UX: Gate the buttons behind a feature flag 2025-12-01 13:31:42 -08:00
DrJKL
23d01f0b34 UX: Handle failed update 2025-12-01 13:24:24 -08:00
DrJKL
64a7955eca UX: Add progress and confirmation to deletion 2025-12-01 13:09:08 -08:00
DrJKL
a5bd37ef56 Properly resolve MaybeRef 2025-12-01 12:00:00 -08:00
Alexander Brown
ca539a6fd0 Update src/platform/assets/components/AssetBadgeGroup.vue
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-01 11:50:44 -08:00
DrJKL
585e47f2df We should remove that reexport and standardize the import 2025-12-01 11:48:17 -08:00
DrJKL
aa13e4c385 i18n: internationalize deletion confirmation 2025-12-01 11:48:17 -08:00
DrJKL
57ed0afde6 nits: Typo and unused ref 2025-12-01 11:48:17 -08:00
DrJKL
3a18d27b9d WIP: Real API calls 2025-12-01 11:48:16 -08:00
DrJKL
f9e8dfdbe7 style: Hide details button for now 2025-12-01 11:48:16 -08:00
DrJKL
ed68b085cc style: Make sure extra properties hug the bottom of the card. 2025-12-01 11:48:15 -08:00
DrJKL
bb9475fed6 Cleanup: Remove dark-theme variants in buttonTypes. Really need to migrate this to CVA and setup the right types. 2025-12-01 11:48:14 -08:00
DrJKL
ba43494d32 WIP: Deleting assets, UI only, also not wired up 2025-12-01 11:48:14 -08:00
DrJKL
df82698f1e type: Add component prop typesafety to dialogs 2025-12-01 11:48:13 -08:00
DrJKL
d4b993b16d style: Fix card padding 2025-12-01 11:48:13 -08:00
DrJKL
04ab767649 WIP: Renaming Assets, UI, not hooked up to backend. 2025-12-01 11:48:12 -08:00
DrJKL
0b7d0f1d35 style: non-functional buttons and menu 2025-12-01 11:48:11 -08:00
DrJKL
0dfe36f1f8 style: Align card styles with Designs 2025-12-01 11:48:08 -08:00
21 changed files with 536 additions and 179 deletions

View File

@@ -1,17 +1,15 @@
<template> <template>
<div :class="iconGroupClasses"> <div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
)
"
>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-secondary-background shadow-sm',
'transition-all duration-200',
'cursor-pointer'
)
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="relative inline-flex items-center"> <div class="relative inline-flex items-center">
<IconButton :size="size" :type="type" @click="toggle"> <IconButton :size="size" :type="type" @click="popover?.toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" /> <i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" /> <i v-else class="icon-[lucide--more-vertical] text-sm" />
</IconButton> </IconButton>
@@ -25,8 +25,18 @@
) )
} }
}" }"
@show="$emit('menuOpened')" @show="
@hide="$emit('menuClosed')" () => {
isOpen = true
$emit('menuOpened')
}
"
@hide="
() => {
isOpen = false
$emit('menuClosed')
}
"
> >
<div class="flex min-w-40 flex-col gap-2 p-2"> <div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" /> <slot :close="hide" />
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean isVertical?: boolean
} }
const popover = ref<InstanceType<typeof Popover>>()
const { const {
size = 'md', size = 'md',
type = 'secondary', type = 'secondary',
@@ -61,15 +69,15 @@ defineEmits<{
menuClosed: [] menuClosed: []
}>() }>()
const toggle = (event: Event) => { const isOpen = ref(false)
popover.value?.toggle(event) const popover = ref<InstanceType<typeof Popover>>()
}
const hide = () => { function hide() {
popover.value?.hide() popover.value?.hide()
} }
defineExpose({ defineExpose({
hide hide,
isOpen
}) })
</script> </script>

View File

@@ -18,8 +18,8 @@
...inputAttrs ...inputAttrs
} }
}" }"
@keyup.enter="blurInputElement" @keyup.enter.capture.stop="blurInputElement"
@keyup.escape="cancelEditing" @keyup.escape.stop="cancelEditing"
@click.stop @click.stop
@pointerdown.stop.capture @pointerdown.stop.capture
@pointermove.stop.capture @pointermove.stop.capture
@@ -38,7 +38,7 @@ const {
} = defineProps<{ } = defineProps<{
modelValue: string modelValue: string
isEditing?: boolean isEditing?: boolean
inputAttrs?: Record<string, any> inputAttrs?: Record<string, string>
}>() }>()
const emit = defineEmits(['update:modelValue', 'edit', 'cancel']) const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])

View File

@@ -14,6 +14,7 @@
<component <component
:is="item.headerComponent" :is="item.headerComponent"
v-if="item.headerComponent" v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key" :id="item.key"
/> />
<h3 v-else :id="item.key"> <h3 v-else :id="item.key">

View File

@@ -0,0 +1,19 @@
<template>
<div
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText } = defineProps<{
promptText?: MaybeRefOrGetter<string>
}>()
const promptTextReal = computed(() => toValue(promptText))
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
:class="confirmClass"
@click="$emit('confirm')"
/>
</section>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import TextButton from '@/components/button/TextButton.vue'
const { t } = useI18n()
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()
defineEmits<{
cancel: []
confirm: []
}>()
const confirmTextX = computed(() => confirmText || t('g.confirm'))
const cancelTextX = computed(() => cancelText || t('g.cancel'))
const disabled = computed(() => toValue(optionsDisabled))
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
>
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
}>()
</script>

View File

@@ -0,0 +1,39 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
/**
* Open a confirmation dialog composed of the standard confirm header, body, and footer.
*
* Forwards any provided `headerProps`, `props`, and `footerProps` to the corresponding components.
*
* @param options - Optional configuration with `headerProps`, `props`, and `footerProps` to customize the header, body, and footer components
* @returns A dialog handle representing the shown confirmation dialog
*/
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { headerProps, props, footerProps } = options
return dialogStore.showDialog({
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,
headerProps,
props,
footerProps,
dialogComponentProps: {
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -9,11 +9,20 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata', SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size', MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4', MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled' MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
} }
/** /**
* Composable for reactive access to server-side feature flags * Provides reactive accessors for server-side feature flags.
*
* Exposes a readonly `flags` object containing convenience getters for known server feature keys
* and a `featureFlag` helper that returns a computed value for an arbitrary feature path,
* optionally using a supplied default when the feature is not present.
*
* @returns An object with:
* - `flags`: a readonly reactive object with predefined getters for common server feature flags
* - `featureFlag`: a generic function `(featurePath: string, defaultValue?) => ComputedRef<T>` that yields a computed feature value
*/ */
export function useFeatureFlags() { export function useFeatureFlags() {
const flags = reactive({ const flags = reactive({
@@ -31,6 +40,12 @@ export function useFeatureFlags() {
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false false
) )
},
get assetUpdateOptionsEnabled() {
return api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
} }
}) })

View File

@@ -163,6 +163,7 @@ export const i18n = createI18n({
legacy: false, legacy: false,
locale: navigator.language.split('-')[0] || 'en', locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en', fallbackLocale: 'en',
escapeParameter: true,
messages, messages,
// Ignore warnings for locale options as each option is in its own language. // Ignore warnings for locale options as each option is in its own language.
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español" // e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"

View File

@@ -1,6 +1,5 @@
{ {
"g": { "g": {
"beta": "Beta",
"user": "User", "user": "User",
"currentUser": "Current user", "currentUser": "Current user",
"empty": "Empty", "empty": "Empty",
@@ -125,6 +124,7 @@
"searchKeybindings": "Search Keybindings", "searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions", "searchExtensions": "Search Extensions",
"search": "Search", "search": "Search",
"searchPlaceholder": "Search...",
"noResultsFound": "No Results Found", "noResultsFound": "No Results Found",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found", "noTasksFound": "No Tasks Found",
@@ -2093,7 +2093,6 @@
"connectionError": "Please check your connection and try again", "connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.", "failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder", "noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Import model", "uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai", "uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.", "uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
@@ -2152,6 +2151,13 @@
"media": { "media": {
"threeDModelPlaceholder": "3D Model", "threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio" "audioPlaceholder": "Audio"
},
"deletion": {
"header": "Delete this model?",
"body": "This model will be permanently removed from your library.",
"inProgress": "Deleting {assetName}...",
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
} }
}, },
"mediaAsset": { "mediaAsset": {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-1"> <div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<span <span
v-for="badge in badges" v-for="badge in badges"
:key="badge.label" :key="badge.label"

View File

@@ -26,7 +26,7 @@
v-model="searchQuery" v-model="searchQuery"
:autofocus="true" :autofocus="true"
size="lg" size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')" :placeholder="$t('g.searchPlaceholder')"
class="max-w-96" class="max-w-96"
/> />
<IconTextButton <IconTextButton

View File

@@ -1,53 +1,106 @@
<template> <template>
<component <div
:is="interactive ? 'button' : 'div'" v-if="!deletedLocal"
data-component-id="AssetCard" data-component-id="AssetCard"
:data-asset-id="asset.id" :data-asset-id="asset.id"
v-bind="elementProps" :aria-labelledby="titleId"
:class="cardClasses" :aria-describedby="descId"
@click="interactive && $emit('select', asset)" :tabindex="interactive ? 0 : -1"
@keydown.enter="interactive && $emit('select', asset)" :class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
)
"
@keydown.enter.self="interactive && $emit('select', asset)"
> >
<div class="relative aspect-square w-full overflow-hidden rounded-xl"> <div class="relative aspect-square w-full overflow-hidden rounded-xl">
<img
v-if="shouldShowImage"
:src="asset.preview_url"
class="h-full w-full object-contain"
/>
<div <div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400" :src="asset.preview_url"
></div> :alt="displayName"
class="size-full object-contain cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" /> <AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="flags.assetUpdateOptionsEnabled"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
dropdownMenuButton?.isOpen && 'visible'
)
"
>
<IconButton v-if="false" size="sm">
<i class="icon-[lucide--file-text]" />
</IconButton>
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<IconTextButton
:label="$t('g.rename')"
type="secondary"
size="md"
@click="startAssetRename"
>
<template #icon>
<i class="icon-[lucide--pencil]" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('g.delete')"
type="secondary"
size="md"
@click="confirmDeletion"
>
<template #icon>
<i class="icon-[lucide--trash-2]" />
</template>
</IconTextButton>
</template>
</MoreButton>
</IconGroup>
</div> </div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')"> <div class="max-h-32 flex flex-col gap-2 justify-between flex-auto">
<div> <h3
<h3 :id="titleId"
:id="titleId" v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.name, showDelay: tooltipDelay }" :class="
:class=" cn(
cn( 'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere', 'text-base-foreground'
'text-base-foreground' )
) "
" >
> <EditableText
{{ asset.name }} :model-value="displayName"
</h3> :is-editing="isEditing"
<p :input-attrs="{ 'data-testid': 'asset-name-input' }"
:id="descId" @edit="assetRename"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }" @cancel="assetRename()"
:class=" />
cn( </h3>
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]', <p
'text-muted-foreground' :id="descId"
) v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
" :class="
> cn(
{{ asset.description }} 'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
</p> )
</div> "
<div :class="cn('flex gap-4 text-xs text-muted-foreground')"> >
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1"> <span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" /> <i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }} {{ asset.stats.stars }}
@@ -62,73 +115,133 @@
</span> </span>
</div> </div>
</div> </div>
</component> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useImage } from '@vueuse/core' import { useImage } from '@vueuse/core'
import { computed, useId } from 'vue' import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue' import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{ const { asset, interactive } = defineProps<{
asset: AssetDisplayItem asset: AssetDisplayItem
interactive?: boolean interactive?: boolean
}>() }>()
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
)
const titleId = useId() const titleId = useId()
const descId = useId() const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const tooltipDelay = computed<number>(() => const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay') settingStore.get('LiteGraph.Node.TooltipDelay')
) )
const { error } = useImage({ const { isLoading, error } = useImage({
src: props.asset.preview_url ?? '', src: asset.preview_url ?? '',
alt: props.asset.name alt: asset.name
}) })
const shouldShowImage = computed(() => props.asset.preview_url && !error.value) function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
const promptText = ref<string>(t('assetBrowser.deletion.body'))
const optionsDisabled = ref(false)
const confirmDialog = showConfirmDialog({
headerProps: {
title: t('assetBrowser.deletion.header')
},
props: {
promptText
},
footerProps: {
confirmText: t('g.delete'),
// TODO: These need to be put into the new Button Variants once we have them.
confirmClass: cn(
'bg-danger-200 text-base-foreground hover:bg-danger-200/80 focus:bg-danger-200/80 focus:ring ring-base-foreground'
),
optionsDisabled,
onCancel: () => {
closeDialog(confirmDialog)
},
onConfirm: async () => {
try {
promptText.value = t('assetBrowser.deletion.inProgress', {
assetName
})
await assetService.deleteAsset(asset.id)
promptText.value = t('assetBrowser.deletion.complete', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 1_000))
deletedLocal.value = true
} catch (err: unknown) {
console.error(err)
promptText.value = t('assetBrowser.deletion.failed', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 3_000))
} finally {
closeDialog(confirmDialog)
}
}
}
})
}
const cardClasses = computed(() => { function startAssetRename() {
const base = cn( dropdownMenuButton.value?.hide()
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background' isEditing.value = true
) }
if (!props.interactive) { async function assetRename(newName?: string) {
return base isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
newNameRef.value = undefined
}
} }
}
return cn(
base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
)
})
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
</script> </script>

View File

@@ -1,7 +1,9 @@
<template> <template>
<div <div
data-component-id="AssetGrid" data-component-id="AssetGrid"
:style="gridStyle" :class="
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
"
role="grid" role="grid"
:aria-label="$t('assetBrowser.assetCollection')" :aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1" :aria-rowcount="-1"
@@ -34,7 +36,6 @@
:key="asset.id" :key="asset.id"
:asset="asset" :asset="asset"
:interactive="true" :interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)" @select="$emit('assetSelect', $event)"
/> />
</template> </template>
@@ -42,11 +43,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue' import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil' import { cn } from '@/utils/tailwindUtil'
defineProps<{ defineProps<{
assets: AssetDisplayItem[] assets: AssetDisplayItem[]
@@ -56,7 +55,4 @@ defineProps<{
defineEmits<{ defineEmits<{
assetSelect: [asset: AssetDisplayItem] assetSelect: [asset: AssetDisplayItem]
}>() }>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default" class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
> >
<!-- Step 1: Enter URL --> <!-- Step 1: Enter URL -->
<UploadModelUrlInput <UploadModelUrlInput

View File

@@ -1,7 +1,10 @@
import { fromZodError } from 'zod-validation-error' import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n' import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema' import {
assetItemSchema,
assetResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type { import type {
AssetItem, AssetItem,
AssetMetadata, AssetMetadata,
@@ -262,12 +265,10 @@ function createAssetService() {
} }
/** /**
* Deletes an asset by ID * Delete the asset identified by `id` (cloud environments only).
* Only available in cloud environment
* *
* @param id - The asset ID (UUID) * @param id - The asset ID (UUID)
* @returns Promise<void> * @throws Error if the server responds with a non-ok status; message includes the HTTP status
* @throws Error if deletion fails
*/ */
async function deleteAsset(id: string): Promise<void> { async function deleteAsset(id: string): Promise<void> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, { const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
@@ -281,6 +282,44 @@ function createAssetService() {
} }
} }
/**
* Update an asset's metadata by its ID.
*
* Only available in cloud environment.
*
* @param id - The asset ID (UUID)
* @param newData - Partial metadata fields to apply to the asset
* @returns The updated AssetItem
* @throws Error if the server responds with a non-OK status or the response cannot be validated as an AssetItem
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newData)
})
if (!res.ok) {
throw new Error(
`Unable to update asset ${id}: Server returned ${res.status}`
)
}
const newAsset = assetItemSchema.safeParse(await res.json())
if (newAsset.success) {
return newAsset.data
}
throw new Error(
`Unable to update asset ${id}: Invalid response - ${newAsset.error}`
)
}
/** /**
* Retrieves metadata from a download URL without downloading the file * Retrieves metadata from a download URL without downloading the file
* *
@@ -360,6 +399,7 @@ function createAssetService() {
getAssetDetails, getAssetDetails,
getAssetsByTag, getAssetsByTag,
deleteAsset, deleteAsset,
updateAsset,
getAssetMetadata, getAssetMetadata,
uploadAssetFromUrl uploadAssetFromUrl
} }

View File

@@ -34,7 +34,7 @@ import NodeConflictDialogContent from '@/workbench/extensions/manager/components
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue' import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue' import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes' import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ComponentProps } from 'vue-component-type-helpers' import type { ComponentAttrs } from 'vue-component-type-helpers'
export type ConfirmationDialogType = export type ConfirmationDialogType =
| 'default' | 'default'
@@ -47,8 +47,13 @@ export type ConfirmationDialogType =
export const useDialogService = () => { export const useDialogService = () => {
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
/**
* Open the global missing-nodes dialog and forward the provided props to its content component.
*
* @param props - Props passed through to the MissingNodesContent component
*/
function showLoadWorkflowWarning( function showLoadWorkflowWarning(
props: ComponentProps<typeof MissingNodesContent> props: ComponentAttrs<typeof MissingNodesContent>
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-missing-nodes', key: 'global-missing-nodes',
@@ -73,8 +78,13 @@ export const useDialogService = () => {
}) })
} }
/**
* Show the global missing-models warning dialog.
*
* @param props - Props forwarded to the MissingModelsWarning component
*/
function showMissingModelsWarning( function showMissingModelsWarning(
props: InstanceType<typeof MissingModelsWarning>['$props'] props: ComponentAttrs<typeof MissingModelsWarning>
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-missing-models-warning', key: 'global-missing-models-warning',
@@ -103,6 +113,11 @@ export const useDialogService = () => {
}) })
} }
/**
* Opens the global settings dialog with the About panel selected.
*
* Displays the settings dialog and sets its default inner panel to "about".
*/
function showAboutDialog() { function showAboutDialog() {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-settings', key: 'global-settings',
@@ -114,8 +129,15 @@ export const useDialogService = () => {
}) })
} }
/**
* Shows the global execution error dialog populated from a websocket execution error message.
*
* Displays a dialog containing the error details from `executionError` and records a telemetry event when the dialog is closed.
*
* @param executionError - Websocket execution error message containing `exception_type`, `exception_message`, `node_id`, `node_type`, and `traceback`
*/
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) { function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
const props: InstanceType<typeof ErrorDialogContent>['$props'] = { const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: { error: {
exceptionType: executionError.exception_type, exceptionType: executionError.exception_type,
exceptionMessage: executionError.exception_message, exceptionMessage: executionError.exception_message,
@@ -140,8 +162,13 @@ export const useDialogService = () => {
}) })
} }
/**
* Opens the global manager dialog using the default manager layout and styling and forwards props to the dialog content.
*
* @param props - Props to pass through to ManagerDialogContent (defaults to an empty object)
*/
function showManagerDialog( function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {} props: ComponentAttrs<typeof ManagerDialogContent> = {}
) { ) {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'global-manager', key: 'global-manager',
@@ -184,9 +211,12 @@ export const useDialogService = () => {
} }
/** /**
* Show a error dialog to the user when an error occurs. * Displays a global error dialog for the given error and tracks the dialog close event for telemetry.
* @param error The error to show *
* @param options The options for the dialog * @param error - An Error or any value to display; if an Error is provided it will be parsed for message, stack trace, and extension file.
* @param options - Optional configuration for the dialog
* @param options.title - Title used as the exception type shown in the dialog
* @param options.reportType - Optional report type forwarded to the dialog for reporting purposes
*/ */
function showErrorDialog( function showErrorDialog(
error: unknown, error: unknown,
@@ -206,7 +236,7 @@ export const useDialogService = () => {
errorMessage: String(error) errorMessage: String(error)
} }
const props: InstanceType<typeof ErrorDialogContent>['$props'] = { const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: { error: {
exceptionType: options.title ?? 'Unknown Error', exceptionType: options.title ?? 'Unknown Error',
exceptionMessage: errorProps.errorMessage, exceptionMessage: errorProps.errorMessage,
@@ -412,15 +442,10 @@ export const useDialogService = () => {
} }
/** /**
* Shows a dialog from a third party extension. * Show a dialog provided by a third-party extension.
* @param options - The dialog options. *
* @param options.key - The dialog key. * @param options - Dialog configuration including `key`, optional `title`, header/footer components, dialog `component`, and `props` passed to the component.
* @param options.title - The dialog title. * @returns An object with `dialog`, the dialog instance returned by the dialog store, and `closeDialog`, a function that closes the dialog using the provided `key`.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
*/ */
function showExtensionDialog(options: ShowDialogOptions & { key: string }) { function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return { return {
@@ -429,8 +454,15 @@ export const useDialogService = () => {
} }
} }
/**
* Toggles the global manager dialog's visibility.
*
* If the global manager dialog is open, it will be closed; otherwise it will be shown.
*
* @param props - Optional props to pass to the ManagerDialogContent when opening the dialog
*/
function toggleManagerDialog( function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props'] props?: ComponentAttrs<typeof ManagerDialogContent>
) { ) {
if (dialogStore.isDialogOpen('global-manager')) { if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' }) dialogStore.closeDialog({ key: 'global-manager' })
@@ -439,8 +471,13 @@ export const useDialogService = () => {
} }
} }
/**
* Toggles the global manager progress dialog: closes it if open, otherwise opens it.
*
* @param props - Optional props to pass to the ManagerProgressDialogContent when opening the dialog
*/
function toggleManagerProgressDialog( function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props'] props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) { ) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) { if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' }) dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })

View File

@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue' import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { ComponentAttrs } from 'vue-component-type-helpers'
type DialogPosition = type DialogPosition =
| 'center' | 'center'
@@ -33,30 +34,40 @@ interface CustomDialogComponentProps {
headless?: boolean headless?: boolean
} }
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] & export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps CustomDialogComponentProps
interface DialogInstance { interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string key: string
visible: boolean visible: boolean
title?: string title?: string
headerComponent?: Component headerComponent?: H
component: Component headerProps?: ComponentAttrs<H>
contentProps: Record<string, any> component: B
footerComponent?: Component contentProps: ComponentAttrs<B>
footerProps?: Record<string, any> footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps dialogComponentProps: DialogComponentProps
priority: number priority: number
} }
export interface ShowDialogOptions { export interface ShowDialogOptions<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key?: string key?: string
title?: string title?: string
headerComponent?: Component headerComponent?: H
footerComponent?: Component footerComponent?: F
component: Component component: B
props?: Record<string, any> props?: ComponentAttrs<B>
footerProps?: Record<string, any> headerProps?: ComponentAttrs<H>
footerProps?: ComponentAttrs<F>
dialogComponentProps?: DialogComponentProps dialogComponentProps?: DialogComponentProps
/** /**
* Optional priority for dialog stacking. * Optional priority for dialog stacking.
@@ -105,6 +116,13 @@ export const useDialogStore = defineStore('dialog', () => {
} }
} }
/**
* Closes the dialog identified by the given key or the currently active dialog when no key is provided.
*
* Invokes the dialog's `onClose` callback if present, removes the dialog from the stack, updates the active dialog key, and adjusts close-on-Escape handling. If no matching dialog is found this function is a no-op.
*
* @param options - Optional object with a `key` specifying which dialog to close; when omitted the active dialog is closed.
*/
function closeDialog(options?: { key: string }) { function closeDialog(options?: { key: string }) {
const targetDialog = options const targetDialog = options
? dialogStack.value.find((d) => d.key === options.key) ? dialogStack.value.find((d) => d.key === options.key)
@@ -123,17 +141,19 @@ export const useDialogStore = defineStore('dialog', () => {
updateCloseOnEscapeStates() updateCloseOnEscapeStates()
} }
function createDialog(options: { /**
key: string * Create and register a dialog instance from the given options and push it into the dialog stack.
title?: string *
headerComponent?: Component * @param options - Configuration for the dialog. Must include a unique `key`. Other fields configure the component to render (`component`), optional `title`, optional `headerComponent`/`footerComponent` and their props, additional `props` for the content component, `dialogComponentProps` for dialog behavior, and an optional numeric `priority`.
footerComponent?: Component * @returns The created dialog instance that was inserted into the store's stack.
component: Component *
props?: Record<string, any> * Side effects: enforces a maximum stack size of 10 by removing the oldest dialog when necessary, inserts the new dialog according to its priority, sets the dialog as the active one, and updates close-on-escape handling for the stack.
footerProps?: Record<string, any> */
dialogComponentProps?: DialogComponentProps function createDialog<
priority?: number H extends Component = Component,
}) { B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
if (dialogStack.value.length >= 10) { if (dialogStack.value.length >= 10) {
dialogStack.value.shift() dialogStack.value.shift()
} }
@@ -149,6 +169,7 @@ export const useDialogStore = defineStore('dialog', () => {
? markRaw(options.footerComponent) ? markRaw(options.footerComponent)
: undefined, : undefined,
component: markRaw(options.component), component: markRaw(options.component),
headerProps: { ...options.headerProps },
contentProps: { ...options.props }, contentProps: { ...options.props },
footerProps: { ...options.footerProps }, footerProps: { ...options.footerProps },
priority: options.priority ?? 1, priority: options.priority ?? 1,
@@ -203,7 +224,17 @@ export const useDialogStore = defineStore('dialog', () => {
}) })
} }
function showDialog(options: ShowDialogOptions) { /**
* Opens the dialog described by `options` and ensures it is the active (top-most) dialog, creating a new dialog if one with the same key does not exist.
*
* @param options - Configuration for the dialog to show; may include a `key` to target an existing dialog or omit it to generate a new key
* @returns The dialog instance that was shown or created
*/
function showDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey() const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey) let dialog = dialogStack.value.find((d) => d.key === dialogKey)

View File

@@ -25,8 +25,7 @@ export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
export const getButtonTypeClasses = (type: ButtonType = 'primary') => { export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = { const baseByType = {
primary: primary: 'bg-base-foreground border-none text-base-background',
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary: cn( secondary: cn(
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover' 'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
), ),
@@ -42,10 +41,8 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => { export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = { const baseByType = {
primary: primary: 'bg-base-background text-base-foreground',
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900', secondary: 'bg-secondary-background text-base-foreground',
secondary:
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
transparent: cn( transparent: cn(
'bg-transparent text-base-foreground hover:bg-secondary-background-hover' 'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
), ),
@@ -54,10 +51,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
} as const } as const
const borderByType = { const borderByType = {
primary: 'border border-solid border-white dark-theme:border-neutral-900', primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-neutral-950 dark-theme:border-white', secondary: 'border border-solid border-base-foreground',
transparent: transparent: 'border border-solid border-base-foreground',
'border border-solid border-neutral-950 dark-theme:border-white',
accent: 'border border-solid border-primary-background' accent: 'border border-solid border-primary-background'
} as const } as const

View File

@@ -14,6 +14,8 @@ interface GridOptions {
} }
/** /**
* @deprecated Just use tailwind utilities directly.
* TODO: Create a common grid layout component if needed.
* Creates CSS grid styles for responsive grid layouts * Creates CSS grid styles for responsive grid layouts
* @param options Grid configuration options * @param options Grid configuration options
* @returns CSS properties object for grid styling * @returns CSS properties object for grid styling