feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -0,0 +1,560 @@
<template>
<div class="relative h-screen w-full select-none max-h-[min(70vh,750px)]">
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4">
<Admonition
v-if="hasNoProperties"
type="warning"
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
/>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="m-0">
Edit the Minecraft server properties file here, or use the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab
</AutoLink>
to edit the full file. If you're unsure about a setting, the
<AutoLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
target="_blank"
>
Minecraft Wiki
</AutoLink>
has more details.
</div>
</div>
<div class="w-full text-sm">
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div class="flex flex-col gap-3 pb-2">
<div class="flex flex-col gap-6">
<!-- Basic Properties -->
<!-- [&:not(:has(*:not(:empty)))]:hidden is to hide parent if all children are empty -->
<div
class="rounded-2xl border border-solid border-surface-5 p-4 pb-2 [&:not(:has(*:not(:empty)))]:hidden"
>
<div class="flex w-full flex-col gap-1.5">
<div v-if="isPropertyVisible('gamemode')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Gamemode</span>
<Chips
v-model="combinedGamemode"
:items="gamemodeItems"
:format-label="capitalize"
/>
</div>
<div
v-if="combinedGamemode !== 'hardcore' && isPropertyVisible('difficulty')"
class="flex flex-col gap-2.5 my-1"
>
<span class="font-semibold text-contrast">Difficulty</span>
<Chips
v-model="selectedDifficulty"
:items="difficultyItems"
:format-label="capitalize"
/>
</div>
<div v-if="isPropertyVisible('max_players')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Max players</span>
<StyledInput
id="server-property-max-players"
:model-value="liveProperties.max_players"
type="number"
placeholder="20"
wrapper-class="w-full max-w-[450px]"
@update:model-value="liveProperties.max_players = String($event)"
/>
</div>
<div v-if="isPropertyVisible('motd')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">MOTD</span>
<StyledInput
id="server-property-motd"
v-model="liveProperties.motd"
placeholder="A Minecraft Server"
wrapper-class="w-full max-w-[450px]"
/>
</div>
<div
v-if="isPropertyVisible('allow_flight')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow flight</span>
<Toggle
id="server-property-allow-flight"
:model-value="liveProperties.allow_flight === 'true'"
@update:model-value="liveProperties.allow_flight = $event ? 'true' : 'false'"
/>
</div>
<div
v-if="isPropertyVisible('allow_cheats')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow cheats</span>
<Toggle
id="server-property-allow-cheats"
:model-value="liveProperties.allow_cheats === 'true'"
@update:model-value="liveProperties.allow_cheats = $event ? 'true' : 'false'"
/>
</div>
<div
v-if="isPropertyVisible('white_list')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable whitelist</span>
<Toggle id="server-property-whitelist" v-model="whitelistEnabled" />
</div>
<div
v-if="isPropertyVisible('spawn_protection')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable spawn protection</span>
<Toggle
id="server-property-spawn-protection-toggle"
v-model="spawnProtectionEnabled"
/>
</div>
<div
v-if="spawnProtectionEnabled && isPropertyVisible('spawn_protection')"
class="flex items-center justify-between h-10"
>
<span class="font-semibold text-contrast">Protection radius</span>
<StyledInput
id="server-property-spawn-protection-radius"
:model-value="liveProperties.spawn_protection"
type="number"
wrapper-class="w-full sm:w-[100px]"
input-class="text-right"
@update:model-value="liveProperties.spawn_protection = String($event)"
/>
</div>
</div>
</div>
</div>
<!-- Advanced Properties -->
<Accordion
v-if="hasVisibleAdvancedProperties"
overflow-visible
:force-open="isSearchActive"
button-class="flex w-full flex-col gap-2 bg-transparent m-0 p-0 border-none"
>
<template #title>
<span class="text-lg font-semibold text-contrast">Advanced properties</span>
</template>
<div class="flex flex-col gap-6 pt-4">
<template v-for="group in advancedGroupedProperties" :key="group.label">
<div v-if="hasVisibleProperties(group)" class="flex flex-col gap-2.5">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ group.label }}
</h3>
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 p-4"
>
<template v-for="key in group.properties" :key="key">
<div
v-if="isPropertyVisible(key)"
class="flex flex-row flex-wrap items-center justify-between h-10"
>
<span :id="`property-label-${key}`" class="font-semibold text-contrast">
{{ formatPropertyName(key) }}
</span>
<div
v-if="getPropertyDef(key).type === 'toggle'"
class="flex w-full justify-end sm:w-[320px]"
>
<Toggle
:id="`server-property-${key}`"
:model-value="liveProperties[key] === 'true'"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
/>
</div>
<div
v-else-if="getPropertyDef(key).type === 'number'"
class="w-full sm:w-[320px]"
>
<StyledInput
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
<div v-else class="flex w-full justify-end sm:w-[320px]">
<StyledInput
:id="`server-property-${key}`"
v-model="liveProperties[key]"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<div>
All other properties can be edited in server.properties via the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab </AutoLink
>.
</div>
</div>
</Accordion>
<div
v-if="hasNoResults"
class="flex flex-col items-center gap-2 py-8 text-center text-secondary"
>
<SearchIcon class="size-10" />
<span class="text-lg font-semibold text-contrast">No properties found</span>
<span>No properties match "{{ searchInput }}".</span>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full w-full items-center justify-center">
<SpinnerIcon class="animate-spin" />
</div>
<SaveBanner
:is-visible="hasUnsavedChanges || isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:save="
async () => {
await saveProperties()
}
"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const filesTabLink = computed(
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
)
const serverSettings = injectServerSettings(null)
const searchInput = ref('')
function onFilesTabLinkClick() {
serverSettings?.closeModal?.()
}
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' }
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
allow_cheats: { type: 'toggle' },
allow_flight: { type: 'toggle' },
difficulty: { type: 'text' },
enforce_whitelist: { type: 'toggle' },
force_gamemode: { type: 'toggle' },
gamemode: { type: 'text' },
generate_structures: { type: 'toggle' },
generator_settings: { type: 'text' },
hardcore: { type: 'toggle' },
level_seed: { type: 'text' },
level_type: { type: 'text' },
max_players: { type: 'number' },
max_tick_time: { type: 'number' },
motd: { type: 'text' },
pause_when_empty_seconds: { type: 'number' },
player_idle_timeout: { type: 'number' },
require_resource_pack: { type: 'toggle' },
resource_pack: { type: 'text' },
resource_pack_id: { type: 'text' },
resource_pack_sha1: { type: 'text' },
simulation_distance: { type: 'number' },
spawn_protection: { type: 'number' },
sync_chunk_writes: { type: 'toggle' },
view_distance: { type: 'number' },
white_list: { type: 'toggle' },
}
function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
const ADVANCED_GROUPS = [
{
label: 'Performance',
keys: [
'view_distance',
'simulation_distance',
'sync_chunk_writes',
'max_tick_time',
'player_idle_timeout',
'pause_when_empty_seconds',
],
},
{
label: 'Resource Pack',
keys: ['resource_pack', 'resource_pack_id', 'resource_pack_sha1', 'require_resource_pack'],
},
]
type CombinedGamemode = 'survival' | 'creative' | 'hardcore'
const gamemodeItems: CombinedGamemode[] = ['survival', 'creative', 'hardcore']
const difficultyItems = ['peaceful', 'easy', 'normal', 'hard']
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
const { data: propsData } = useQuery({
queryKey,
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
const result: Record<string, string> = {}
if (data.known) {
for (const [key, value] of Object.entries(data.known)) {
if (value != null) result[key] = value
}
}
if (data.custom) {
for (const [key, value] of Object.entries(data.custom)) {
if (value != null) result[key] = value
}
}
return result
}
const liveProperties = ref<Record<string, string>>({})
const originalProperties = ref<Record<string, string>>({})
let previousSpawnProtection = '16'
function syncFormFromData() {
if (!propsData.value) return
const flat = flattenProperties(propsData.value)
liveProperties.value = { ...flat }
originalProperties.value = { ...flat }
const sp = flat.spawn_protection
if (sp && sp !== '0') {
previousSpawnProtection = sp
}
}
const hasNoProperties = computed(() => Object.keys(liveProperties.value).length === 0)
const hasUnsavedChanges = computed(() =>
Object.keys(liveProperties.value).some(
(key) => liveProperties.value[key] !== originalProperties.value[key],
),
)
watch(
propsData,
(newData) => {
if (newData && !hasUnsavedChanges.value) {
syncFormFromData()
}
},
{ immediate: true },
)
watch(powerState, () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
})
const combinedGamemode = computed<CombinedGamemode>({
get() {
if (liveProperties.value.hardcore === 'true') return 'hardcore'
if (liveProperties.value.gamemode === 'creative') return 'creative'
return 'survival'
},
set(value) {
if (value === 'hardcore') {
liveProperties.value.gamemode = 'survival'
liveProperties.value.hardcore = 'true'
liveProperties.value.difficulty = 'hard'
} else {
liveProperties.value.gamemode = value
liveProperties.value.hardcore = 'false'
}
},
})
const selectedDifficulty = computed({
get: () => liveProperties.value.difficulty ?? 'normal',
set: (v: string) => {
liveProperties.value.difficulty = v
},
})
const whitelistEnabled = computed({
get: () => liveProperties.value.white_list === 'true',
set: (v: boolean) => {
liveProperties.value.white_list = v ? 'true' : 'false'
liveProperties.value.enforce_whitelist = v ? 'true' : 'false'
},
})
const spawnProtectionEnabled = computed({
get: () => {
const val = liveProperties.value.spawn_protection
return val !== undefined && val !== '0'
},
set: (enabled: boolean) => {
if (enabled) {
liveProperties.value.spawn_protection = previousSpawnProtection || '16'
} else {
previousSpawnProtection = liveProperties.value.spawn_protection || '16'
liveProperties.value.spawn_protection = '0'
}
},
})
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
const known: Record<string, string> = {}
const custom: Record<string, string> = {}
for (const key of Object.keys(liveProperties.value)) {
if (liveProperties.value[key] === originalProperties.value[key]) continue
if (key in KNOWN_PROPERTIES) {
known[key] = liveProperties.value[key]
} else {
custom[key] = liveProperties.value[key]
}
}
const patch: Archon.Content.v1.PatchPropertiesFields = {}
if (Object.keys(known).length > 0) {
patch.known = known as Archon.Content.v1.KnownPropertiesFields
}
if (Object.keys(custom).length > 0) {
patch.custom = custom
}
return patch
}
const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: error instanceof Error ? error.message : 'An error occurred.',
})
},
})
function resetProperties() {
syncFormFromData()
}
const advancedGroupedProperties = computed(() =>
ADVANCED_GROUPS.map((group) => ({
label: group.label,
properties: group.keys.filter((key) => key in liveProperties.value),
})).filter((g) => g.properties.length > 0),
)
const fuse = computed(() => {
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) return liveProperties.value
const results = fuse.value.search(searchInput.value)
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
const isSearchActive = computed(() => !!searchInput.value?.trim())
const hasNoResults = computed(
() => isSearchActive.value && Object.keys(filteredProperties.value).length === 0,
)
function isPropertyVisible(key: string): boolean {
if (!isSearchActive.value) return true
return key in filteredProperties.value
}
function hasVisibleProperties(group: { properties: string[] }): boolean {
return group.properties.some((key) => isPropertyVisible(key))
}
const hasVisibleAdvancedProperties = computed(() =>
advancedGroupedProperties.value.some((group) => hasVisibleProperties(group)),
)
function formatPropertyName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
</script>