mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 22:20:03 +00:00
[refactor] Replace PrimeVue ProgressSpinner with Lucide loader icon (#9372)
## Summary - Replace PrimeVue `ProgressSpinner` with Lucide `loader-circle` icon in App.vue and WorkspaceAuthGate.vue - Use white color for loading spinner for better visibility on dark backgrounds - Remove `primevue/progressspinner` imports and update related test ## Changes - **App.vue**: Replace `ProgressSpinner` with `icon-[lucide--loader-circle]` - **WorkspaceAuthGate.vue**: Same replacement - **WorkspaceAuthGate.test.ts**: Remove ProgressSpinner mock, use `.animate-spin` selector ## Review Focus - Visual consistency of white spinner on dark background during initial load <img width="1596" height="1189" alt="스크린샷 2026-03-04 오후 6 28 27" src="https://github.com/user-attachments/assets/d703db74-4123-4328-912a-45ac45cf6eeb" /> <img width="1680" height="1304" alt="스크린샷 2026-03-04 오후 6 28 24" src="https://github.com/user-attachments/assets/8026d10a-7e06-4f95-849c-bc891756823c" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9372-refactor-Replace-PrimeVue-ProgressSpinner-with-Lucide-loader-icon-3196d73d3650815bb1d1d4554f7f744e) by [Unito](https://www.unito.io)
This commit is contained in:
10
src/App.vue
10
src/App.vue
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Loader size="lg" class="text-white" />
|
||||
</div>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
</template>
|
||||
@@ -11,9 +13,9 @@
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
50
src/components/common/Loader.stories.ts
Normal file
50
src/components/common/Loader.stories.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Loader from './Loader.vue'
|
||||
|
||||
const meta: Meta<typeof Loader> = {
|
||||
title: 'Components/Common/Loader',
|
||||
component: Loader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
description: 'Spinner size: sm (16px), md (32px), lg (48px)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: { size: 'md' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const CustomColor: Story = {
|
||||
render: (args) => ({
|
||||
components: { Loader },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<div class="flex gap-4 items-center"><Loader size="lg" class="text-white" /><Loader size="md" class="text-muted-foreground" /><Loader size="sm" class="text-base-foreground" /></div>'
|
||||
}),
|
||||
parameters: {
|
||||
backgrounds: { default: 'dark' }
|
||||
}
|
||||
}
|
||||
29
src/components/common/Loader.vue
Normal file
29
src/components/common/Loader.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span role="status" class="inline-flex">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--loader-circle] animate-spin', sizeClass)"
|
||||
/>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { size } = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'size-4',
|
||||
md: 'size-8',
|
||||
lg: 'size-12'
|
||||
} as const
|
||||
|
||||
const sizeClass = size ? sizeMap[size] : ''
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -47,9 +48,7 @@ const isPending = computed(() => job.status === 'created')
|
||||
</template>
|
||||
|
||||
<template v-else-if="isRunning">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<Loader size="sm" class="text-base-foreground" />
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
|
||||
@@ -24,20 +24,14 @@
|
||||
class="absolute inset-0 flex items-center justify-center bg-modal-card-placeholder-background"
|
||||
>
|
||||
<!-- Spinner for queued/initialization states -->
|
||||
<i
|
||||
v-if="isQueued"
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<Loader v-if="isQueued" size="md" class="text-muted-foreground" />
|
||||
<!-- Error icon for failed state -->
|
||||
<i
|
||||
v-else-if="isFailed"
|
||||
class="icon-[lucide--circle-alert] size-8 text-red-500"
|
||||
/>
|
||||
<!-- Spinner for running without preview -->
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<Loader v-else size="md" class="text-muted-foreground" />
|
||||
</div>
|
||||
<!-- Cancel/Delete button overlay -->
|
||||
<Button
|
||||
@@ -80,6 +74,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetExport } from '@/stores/assetExportStore'
|
||||
@@ -146,9 +147,7 @@ function closeDialog() {
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="job.status === 'running'">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<Loader size="sm" class="text-base-foreground" />
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent(job) }}%
|
||||
</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -175,9 +176,7 @@ function closeDialog() {
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<template v-if="isInProgress">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 flex-shrink-0 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<Loader size="sm" class="flex-shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -49,10 +49,7 @@
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
>
|
||||
<i
|
||||
v-if="isFetchingMetadata"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
<Loader v-if="isFetchingMetadata" />
|
||||
<span>{{ $t('g.continue') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -63,7 +60,7 @@
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<i v-if="isUploading" class="icon-[lucide--loader-circle] animate-spin" />
|
||||
<Loader v-if="isUploading" />
|
||||
<span>{{ $t('assetBrowser.upload') }}</span>
|
||||
</Button>
|
||||
<template
|
||||
@@ -109,6 +106,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
|
||||
|
||||
@@ -50,10 +51,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/progressspinner', () => ({
|
||||
default: { template: '<div class="progress-spinner" />' }
|
||||
}))
|
||||
|
||||
describe('WorkspaceAuthGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -66,8 +63,11 @@ describe('WorkspaceAuthGate', () => {
|
||||
mockWorkspaceStoreInitialize.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const i18n = createI18n({ legacy: false })
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(WorkspaceAuthGate, {
|
||||
global: { plugins: [i18n] },
|
||||
slots: {
|
||||
default: '<div data-testid="slot-content">App Content</div>'
|
||||
}
|
||||
@@ -81,7 +81,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -92,7 +92,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
mockIsInitialized.value = false
|
||||
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
|
||||
mockIsInitialized.value = true
|
||||
mockCurrentUser.value = null
|
||||
@@ -180,7 +180,7 @@ describe('WorkspaceAuthGate', () => {
|
||||
await flushPromises()
|
||||
|
||||
// Still showing spinner before timeout
|
||||
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
|
||||
// Advance past the 10 second timeout
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-else
|
||||
class="fixed inset-0 z-[1100] flex items-center justify-center bg-[var(--p-mask-background)]"
|
||||
>
|
||||
<ProgressSpinner />
|
||||
<Loader size="lg" class="text-white" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
* instead of workspace tokens when the workspace feature is enabled.
|
||||
*/
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -266,7 +267,7 @@ defineExpose({ runButtonClick })
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<Loader size="sm" />
|
||||
<span v-text="t('queue.jobQueueing')" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -31,9 +32,7 @@ function clearQueue(close: () => void) {
|
||||
size="unset"
|
||||
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<Loader size="sm" class="text-muted-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
|
||||
Reference in New Issue
Block a user