refactor: setting dialog

This commit is contained in:
Jin Yi
2026-01-23 16:53:54 +09:00
parent f647c8f9ee
commit 607760fc0c
36 changed files with 565 additions and 714 deletions

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="subscription-container h-full">
<div class="flex h-full flex-col gap-6">
<div class="flex items-center gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
@@ -63,11 +63,10 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'

View File

@@ -1,31 +1,29 @@
<template>
<PanelTemplate value="Extension" class="extension-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchExtensions') + '...'"
/>
<Message
v-if="hasChanges"
severity="info"
pt:text="w-full"
class="max-h-96 overflow-y-auto"
>
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button variant="destructive" @click="applyChanges">
{{ $t('g.reloadToApplyChanges') }}
</Button>
</div>
</Message>
</template>
<div class="extension-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchExtensions') + '...'"
/>
<Message
v-if="hasChanges"
severity="info"
pt:text="w-full"
class="max-h-96 overflow-y-auto"
>
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button variant="destructive" @click="applyChanges">
{{ $t('g.reloadToApplyChanges') }}
</Button>
</div>
</Message>
<div class="mb-3 flex gap-2">
<SelectButton
v-model="filterType"
@@ -79,7 +77,7 @@
</template>
</Column>
</DataTable>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -95,7 +93,6 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'

View File

@@ -1,46 +1,38 @@
<template>
<PanelTemplate value="Server-Config" class="server-config-panel">
<template #header>
<div class="flex flex-col gap-2">
<Message
v-if="modifiedConfigs.length > 0"
severity="info"
pt:text="w-full"
>
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="revertChanges">
{{ $t('serverConfig.revertChanges') }}
</Button>
<Button variant="destructive" @click="restartApp">
{{ $t('serverConfig.restart') }}
</Button>
</div>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i class="icon-[lucide--terminal] text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
size="icon"
variant="muted-textonly"
@click="copyCommandLineArgs"
>
<i class="pi pi-clipboard" />
</Button>
</div>
</Message>
<div class="server-config-panel flex flex-col gap-2">
<Message v-if="modifiedConfigs.length > 0" severity="info" pt:text="w-full">
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button variant="secondary" @click="revertChanges">
{{ $t('serverConfig.revertChanges') }}
</Button>
<Button variant="destructive" @click="restartApp">
{{ $t('serverConfig.restart') }}
</Button>
</div>
</template>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i class="icon-[lucide--terminal] text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
size="icon"
variant="muted-textonly"
@click="copyCommandLineArgs"
>
<i class="pi pi-clipboard" />
</Button>
</div>
</Message>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
@@ -58,7 +50,7 @@
/>
</div>
</div>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -69,7 +61,6 @@ import { onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ServerConfig } from '@/constants/serverConfig'

View File

@@ -0,0 +1,195 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog">
<template #leftPanel>
<div class="flex h-full w-full flex-col bg-modal-panel-background">
<PanelHeader>
<template #icon>
<WorkspaceProfilePic
v-if="teamWorkspacesEnabled"
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<i v-else class="pi pi-cog" />
</template>
<span class="text-neutral text-base">
{{ teamWorkspacesEnabled ? workspaceName : $t('g.settings') }}
</span>
<Tag
v-if="isStaging"
value="staging"
severity="warn"
class="ml-2 text-xs"
/>
</PanelHeader>
<div class="px-3">
<SearchBox
v-model:model-value="searchQuery"
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
@search="handleSearch"
/>
</div>
<nav
class="scrollbar-hide flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
>
<div
v-for="(group, index) in navGroups"
:key="index"
class="flex flex-col gap-2"
>
<NavTitle :title="group.title" />
<NavItem
v-for="item in group.items"
:key="item.id"
:icon="item.icon"
:badge="item.badge"
:active="activeCategoryKey === item.id"
@click="activeCategoryKey = item.id"
>
{{ item.label }}
</NavItem>
</div>
</nav>
</div>
</template>
<template #header />
<template #content>
<template v-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
<CurrentUserMessage v-if="activeSettingCategory.label === 'Comfy'" />
<ColorPaletteMessage
v-if="activeSettingCategory.label === 'Appearance'"
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
<template v-else-if="activePanel">
<Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback>
<div>
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
</div>
</template>
</Suspense>
</template>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { computed, provide, ref, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import PanelHeader from '@/components/widget/panel/PanelHeader.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { isStaging } from '@/config/staging'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
ISettingGroup,
SettingPanelType,
SettingParams
} from '@/platform/settings/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { OnCloseKey } from '@/types/widgetTypes'
import { flattenTree } from '@/utils/treeUtil'
const { onClose, defaultPanel } = defineProps<{
onClose: () => void
defaultPanel?: SettingPanelType
}>()
provide(OnCloseKey, onClose)
const {
defaultCategory,
settingCategories,
navGroups,
teamWorkspacesEnabled,
findCategoryByKey,
findPanelByKey
} = useSettingUI(defaultPanel)
const workspaceStore = useTeamWorkspaceStore()
const workspaceName = computed(() => workspaceStore.workspaceName)
const {
searchQuery,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
const activeSettingCategory = computed<SettingTreeNode | null>(() => {
if (!activeCategoryKey.value) return null
return (
settingCategories.value.find((c) => c.key === activeCategoryKey.value) ??
null
)
})
const activePanel = computed(() => {
if (!activeCategoryKey.value) return null
return findPanelByKey(activeCategoryKey.value)
})
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
return [...(category.children ?? [])]
.sort((a, b) => {
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
})
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => {
const sortOrderA = a.sortOrder ?? 0
const sortOrderB = b.sortOrder ?? 0
return sortOrderB - sortOrderA
})
}))
}
function handleSearch(query: string) {
handleSearchBase(query.trim())
activeCategoryKey.value = query ? null : (defaultCategory.value?.key ?? null)
}
const searchResults = computed<ISettingGroup[]>(() => {
const category = activeCategoryKey.value
? findCategoryByKey(activeCategoryKey.value)
: null
return getSearchResults(category)
})
watch(activeCategoryKey, (newKey, oldKey) => {
if (!newKey && !inSearch.value) {
activeCategoryKey.value = oldKey
}
if (newKey === 'credits') {
void authActions.fetchBalance()
}
})
</script>

View File

@@ -1,244 +0,0 @@
<template>
<div
:class="
teamWorkspacesEnabled
? 'flex h-[80vh] w-full overflow-hidden'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-48 shrink-0 p-2 2xl:w-64'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
<Listbox
v-model="activeCategory"
:options="groupedMenuTreeNodes"
option-label="translatedLabel"
option-group-label="label"
option-group-children="children"
scroll-height="100%"
:option-disabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
:class="
teamWorkspacesEnabled
? 'w-full border-none bg-transparent'
: 'w-full border-none'
"
>
<!-- Workspace mode: custom group headers -->
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.translatedLabel ?? option.label }}
</h3>
</template>
<!-- Legacy mode: divider between groups -->
<template v-else #optiongroup>
<Divider class="my-0" />
</template>
<!-- Workspace mode: custom workspace item -->
<template v-if="teamWorkspacesEnabled" #option="{ option }">
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
<span v-else>{{ option.translatedLabel }}</span>
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs
:value="tabValue"
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-x-auto'
: 'settings-content h-full w-full'
"
>
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
</PanelTemplate>
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label ?? ''"
>
<template #header>
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
</template>
<SettingsPanel :setting-groups="sortedGroups(category)" />
</PanelTemplate>
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" v-bind="panel.props" />
<template #fallback>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
</Suspense>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { flattenTree } from '@/utils/treeUtil'
const { defaultPanel } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const {
activeCategory,
defaultCategory,
settingCategories,
groupedMenuTreeNodes,
panels
} = useSettingUI(defaultPanel)
const {
searchQuery,
searchResultsCategories,
queryIsEmpty,
inSearch,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
// Get max sortOrder from settings in a group
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => {
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
})
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => {
const sortOrderA = a.sortOrder ?? 0
const sortOrderB = b.sortOrder ?? 0
return sortOrderB - sortOrderA
})
}))
}
const handleSearch = (query: string) => {
handleSearchBase(query.trim())
activeCategory.value = query ? null : defaultCategory.value
}
// Get search results
const searchResults = computed<ISettingGroup[]>(() =>
getSearchResults(activeCategory.value)
)
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
if (!tabValue.value) {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authActions.fetchBalance()
}
})
</script>
<style>
.settings-tab-panels {
padding-top: 0 !important;
}
</style>
<style scoped>
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
.settings-container {
display: flex;
height: 70vh;
width: 60vw;
max-width: 64rem;
overflow: hidden;
}
.settings-content {
overflow-x: auto;
}
@media (max-width: 768px) {
.settings-container {
flex-direction: column;
height: auto;
width: 80vw;
}
.settings-sidebar {
width: 100%;
}
.settings-content {
height: 350px;
}
}
/* Hide the first group separator in legacy mode */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}
</style>

View File

@@ -8,29 +8,37 @@ import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { NavGroupData } from '@/types/navTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
const CATEGORY_ICONS: Record<string, string> = {
Comfy: 'icon-[lucide--settings]',
LiteGraph: 'icon-[lucide--workflow]',
Appearance: 'icon-[lucide--palette]',
'3D': 'icon-[lucide--box]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
about: 'icon-[lucide--info]',
credits: 'icon-[lucide--coins]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]',
keybinding: 'icon-[lucide--keyboard]',
extension: 'icon-[lucide--puzzle]',
'server-config': 'icon-[lucide--server]',
PlanCredits: 'icon-[lucide--credit-card]'
}
interface SettingPanelItem {
node: SettingTreeNode
component: Component
props?: Record<string, unknown>
}
export function useSettingUI(
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
export function useSettingUI(defaultPanel?: SettingPanelType) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
const settingStore = useSettingStore()
@@ -321,6 +329,36 @@ export function useSettingUI(
: legacyMenuTreeNodes.value
)
const navGroups = computed<NavGroupData[]>(() =>
groupedMenuTreeNodes.value.map((group) => ({
title:
(group as SettingTreeNode & { translatedLabel?: string })
.translatedLabel ?? group.label,
items: (group.children ?? []).map((child) => ({
id: child.key,
label:
(child as SettingTreeNode & { translatedLabel?: string })
.translatedLabel ?? child.label,
icon:
CATEGORY_ICONS[child.key] ??
CATEGORY_ICONS[child.label] ??
'icon-[lucide--plug]'
}))
}))
)
function findCategoryByKey(key: string): SettingTreeNode | null {
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === key)
if (found) return found
}
return null
}
function findPanelByKey(key: string): SettingPanelItem | null {
return panels.value.find((p) => p.node.key === key) ?? null
}
onMounted(() => {
activeCategory.value = defaultCategory.value
})
@@ -330,6 +368,10 @@ export function useSettingUI(
activeCategory,
defaultCategory,
groupedMenuTreeNodes,
settingCategories
settingCategories,
navGroups,
teamWorkspacesEnabled,
findCategoryByKey,
findPanelByKey
}
}

View File

@@ -0,0 +1,33 @@
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import SettingDialog from '@/platform/settings/components/SettingDialog.vue'
import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
props: {
onClose: hide,
...(panel ? { defaultPanel: panel } : {})
}
})
}
function showAbout() {
show('about')
}
return { show, hide, showAbout }
}

View File

@@ -64,3 +64,13 @@ export interface ISettingGroup {
label: string
settings: SettingParams[]
}
export type SettingPanelType =
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'

View File

@@ -1,4 +1,4 @@
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
@@ -81,13 +81,6 @@ function getUIConfig(
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
// Tab management (shared UI state)
const activeTab = ref<string>('plan')
function setActiveTab(tab: string | number) {
activeTab.value = String(tab)
}
const workspaceType = computed<WorkspaceType>(
() => store.activeWorkspace?.type ?? 'personal'
)
@@ -105,10 +98,6 @@ function useWorkspaceUIInternal() {
)
return {
// Tab management
activeTab: computed(() => activeTab.value),
setActiveTab,
// Permissions and config
permissions,
uiConfig,