ENH - refine configurator editing experience

This commit is contained in:
Leon.Schmidt
2026-08-21 03:59:19 +02:00
parent 20faedc1a2
commit 219d523edf
10 changed files with 745 additions and 126 deletions
@@ -21,6 +21,7 @@ export type AdminConfigEditorLabels = {
emptyList: string
emptyTable: string
entry: string
general: string
keyPlaceholder: string
list: string
remove: string
@@ -59,6 +60,7 @@ type SerializedMapEntry = {
keyType: MapKeyKind
value: unknown
}
type RootTableTab = { count: number; id: string; key?: string; label: string }
const newArrayKind = ref<ValueKind>('string')
const newObjectKey = ref('')
@@ -67,6 +69,7 @@ const newMapKey = ref('')
const newMapKeyKind = ref<MapKeyKind>('string')
const newMapValueKind = ref<ValueKind>('string')
const expandedEntry = ref<string | null>(null)
const selectedTableTab = ref('')
const isList = computed(
() =>
@@ -112,12 +115,19 @@ const mapEntries = computed<SerializedMapEntry[]>(() => {
const listStructure = computed(() =>
props.structure?.kind === 'list' ? props.structure : null,
)
const listTemplate = computed(
() => listStructure.value?.template ?? listStructure.value?.items[0],
)
const tableStructure = computed(() =>
props.structure?.kind === 'table' ? props.structure : null,
)
const mapStructure = computed(() =>
props.structure?.kind === 'map' ? props.structure : null,
)
const mapTemplate = computed(() => mapStructure.value?.template)
const newMapEntryKeyKind = computed<MapKeyKind>(
() => mapStructure.value?.keyType ?? newMapKeyKind.value,
)
const canAddTableField = computed(() => {
const key = newObjectKey.value.trim()
if (
@@ -138,11 +148,49 @@ const tableEntries = computed(() =>
([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'),
),
)
const rootTableTabs = computed<RootTableTab[]>(() => {
if (props.depth !== 0 || !tableStructure.value || mapType.value) return []
const structuredEntries = tableEntries.value.filter(([key, value]) =>
isStructuredValue(value, tableFieldStructure(key)),
)
if (!structuredEntries.length) return []
const scalarCount = tableEntries.value.length - structuredEntries.length
return [
...(scalarCount
? [{ count: scalarCount, id: 'general', label: props.labels.general }]
: []),
...structuredEntries.map(([key]) => ({
count: configuratorStructureSize(tableFieldStructure(key)),
id: `field:${key}`,
key,
label: key,
})),
]
})
const activeRootTableTab = computed(
() =>
rootTableTabs.value.find((tab) => tab.id === selectedTableTab.value) ??
rootTableTabs.value[0] ??
null,
)
const activeRootTableField = computed(() => {
const key = activeRootTableTab.value?.key
if (!key) return null
const entry = tableEntries.value.find(([candidate]) => candidate === key)
return entry ? { key: entry[0], value: entry[1] } : null
})
const visibleTableEntries = computed(() => {
if (!rootTableTabs.value.length) return tableEntries.value
if (activeRootTableField.value) return []
return tableEntries.value.filter(
([key, value]) => !isStructuredValue(value, tableFieldStructure(key)),
)
})
const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***')
const parsedNewMapKey = computed(() => {
const key = newMapKey.value.trim()
if (!key) return null
if (newMapKeyKind.value === 'string') return key
if (newMapEntryKeyKind.value === 'string') return key
const numeric = Number(key)
return Number.isFinite(numeric) ? numeric : null
})
@@ -150,7 +198,7 @@ const canAddMapEntry = computed(() => {
const key = parsedNewMapKey.value
if (key === null) return false
return !mapEntries.value.some(
(entry) => entry.keyType === newMapKeyKind.value && entry.key === key,
(entry) => entry.keyType === newMapEntryKeyKind.value && entry.key === key,
)
})
@@ -216,6 +264,56 @@ function structuredValueLabel(
return props.labels.table
}
function structureTypeLabel(structure: AdminConfiguratorStructure): string {
if (structure.kind === 'optionalString') return props.labels.types.string
if (structure.kind === 'value') return props.labels.types[structure.valueType]
if (structure.kind === 'list') return props.labels.types.list
if (structure.kind === 'vector') {
return `${props.labels.vector} ${structure.vectorType.slice(-1)}`
}
return props.labels.types.table
}
function configuratorStructureSize(
structure: AdminConfiguratorStructure | undefined,
): number {
if (
!structure ||
structure.kind === 'optionalString' ||
structure.kind === 'value'
) {
return 1
}
if (structure.kind === 'vector') {
return Number(structure.vectorType.slice(-1))
}
if (structure.kind === 'list') {
return Math.max(
1,
structure.items.reduce(
(total, item) => total + configuratorStructureSize(item),
0,
),
)
}
if (structure.kind === 'map') {
return Math.max(
1,
structure.entries.reduce(
(total, entry) => total + configuratorStructureSize(entry.structure),
0,
),
)
}
return Math.max(
1,
Object.values(structure.fields).reduce(
(total, field) => total + configuratorStructureSize(field),
0,
),
)
}
function toggleStructuredEntry(entry: string): void {
expandedEntry.value = expandedEntry.value === entry ? null : entry
}
@@ -228,6 +326,12 @@ function listEntryPath(index: number): string {
return `${props.path || props.ariaLabel}[${index + 1}]`
}
function listItemStructure(
index: number,
): AdminConfiguratorStructure | undefined {
return listStructure.value?.items[index] ?? listTemplate.value
}
function mapValuePath(entry: SerializedMapEntry): string {
return props.path ? `${props.path}.${entry.key}` : String(entry.key)
}
@@ -302,10 +406,14 @@ function addListRow(): void {
const index = rows.length
const value = rows.length
? blankLike(rows[0])
: blankValue(newArrayKind.value)
: listTemplate.value
? blankFromStructure(listTemplate.value)
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
if (isStructuredValue(value)) expandedEntry.value = `list:${index}`
if (isStructuredValue(value, listTemplate.value)) {
expandedEntry.value = `list:${index}`
}
}
function updateListRow(index: number, value: unknown): void {
@@ -374,6 +482,7 @@ function addTableField(): void {
})
if (isStructuredValue(value, template)) {
expandedEntry.value = `table:${key}`
if (props.depth === 0) selectedTableTab.value = `field:${key}`
}
newObjectKey.value = ''
}
@@ -395,7 +504,7 @@ function updateMapKey(index: number, event: Event): void {
if (!(target instanceof HTMLInputElement)) return
const current = mapEntries.value[index]
if (!current) return
if (mapEntryStructure(current)) {
if (fixedMapEntryStructure(current)) {
target.value = String(current.key)
return
}
@@ -430,7 +539,7 @@ function updateMapValue(index: number, value: unknown): void {
function removeMapEntry(index: number): void {
const entry = mapEntries.value[index]
if (!entry || mapEntryStructure(entry)) return
if (!entry || fixedMapEntryStructure(entry)) return
emitMap(
mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index),
)
@@ -440,25 +549,27 @@ function removeMapEntry(index: number): void {
function addMapEntry(): void {
if (!canAddMapEntry.value || parsedNewMapKey.value === null) return
const key = parsedNewMapKey.value
const value = blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
)
const value = mapTemplate.value
? blankFromStructure(mapTemplate.value)
: blankCollectionValue(
newMapValueKind.value,
mapEntries.value.slice(0, 50).map((entry) => entry.value),
)
emitMap([
...mapEntries.value,
{
key,
keyType: newMapKeyKind.value,
keyType: newMapEntryKeyKind.value,
value,
},
])
if (isStructuredValue(value)) {
expandedEntry.value = `map:${newMapKeyKind.value}:${key}`
if (isStructuredValue(value, mapTemplate.value)) {
expandedEntry.value = `map:${newMapEntryKeyKind.value}:${key}`
}
newMapKey.value = ''
}
function mapEntryStructure(
function fixedMapEntryStructure(
entry: SerializedMapEntry,
): AdminConfiguratorStructure | undefined {
return mapStructure.value?.entries.find(
@@ -466,6 +577,12 @@ function mapEntryStructure(
candidate.keyType === entry.keyType && candidate.key === entry.key,
)?.structure
}
function mapEntryStructure(
entry: SerializedMapEntry,
): AdminConfiguratorStructure | undefined {
return fixedMapEntryStructure(entry) ?? mapTemplate.value
}
</script>
<template>
@@ -500,14 +617,14 @@ function mapEntryStructure(
:class="{
'has-structured-value': isStructuredValue(
row,
listStructure?.items[index],
listItemStructure(index),
),
'is-expanded': expandedEntry === `list:${index}`,
}"
>
<span class="config-structured-editor__index">{{ index + 1 }}</span>
<button
v-if="isStructuredValue(row, listStructure?.items[index])"
v-if="isStructuredValue(row, listItemStructure(index))"
type="button"
class="config-structured-editor__section-toggle"
:aria-label="`${ariaLabel} ${index + 1}`"
@@ -518,36 +635,34 @@ function mapEntryStructure(
<strong>{{ labels.entry }} {{ index + 1 }}</strong>
<small
:title="
describe(listEntryPath(index), row, listStructure?.items[index])
describe(listEntryPath(index), row, listItemStructure(index))
"
>
{{
describe(listEntryPath(index), row, listStructure?.items[index])
describe(listEntryPath(index), row, listItemStructure(index))
}}
</small>
</span>
<em>{{ structuredValueLabel(row, listStructure?.items[index]) }}</em>
<em>{{ structuredValueLabel(row, listItemStructure(index)) }}</em>
<ChevronDown :size="14" />
</button>
<span v-else class="config-structured-editor__field-copy is-list-entry">
<strong>{{ labels.entry }} {{ index + 1 }}</strong>
<small
:title="
describe(listEntryPath(index), row, listStructure?.items[index])
describe(listEntryPath(index), row, listItemStructure(index))
"
>
{{
describe(listEntryPath(index), row, listStructure?.items[index])
}}
{{ describe(listEntryPath(index), row, listItemStructure(index)) }}
</small>
</span>
<AdminConfigValueEditor
v-if="
!isStructuredValue(row, listStructure?.items[index]) ||
!isStructuredValue(row, listItemStructure(index)) ||
expandedEntry === `list:${index}`
"
:model-value="row"
:structure="listStructure?.items[index]"
:structure="listItemStructure(index)"
:aria-label="`${ariaLabel} ${index + 1}`"
:describe="describe"
:labels="labels"
@@ -577,7 +692,7 @@ function mapEntryStructure(
@submit.prevent="addListRow"
>
<select
v-if="!listValue.length"
v-if="!listValue.length && !listTemplate"
v-model="newArrayKind"
:disabled="disabled"
>
@@ -586,6 +701,12 @@ function mapEntryStructure(
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span
v-else-if="listTemplate"
class="config-structured-editor__fixed-type"
>
{{ structureTypeLabel(listTemplate) }}
</span>
<button type="submit" :disabled="disabled">
<Plus :size="13" />{{ labels.addRow }}
</button>
@@ -631,7 +752,62 @@ function mapEntryStructure(
</div>
</header>
<div v-if="mapType" class="config-structured-editor__properties is-map">
<nav
v-if="rootTableTabs.length"
class="config-structured-editor__tabs"
role="tablist"
:aria-label="ariaLabel"
>
<button
v-for="tableTab in rootTableTabs"
:key="tableTab.id"
type="button"
role="tab"
:class="{ 'is-active': activeRootTableTab?.id === tableTab.id }"
:aria-selected="activeRootTableTab?.id === tableTab.id"
@click="selectedTableTab = tableTab.id"
>
<span>{{ tableTab.label }}</span>
<em>{{ tableTab.count }}</em>
</button>
</nav>
<div
v-if="activeRootTableField"
class="config-structured-editor__tab-panel"
role="tabpanel"
>
<div
v-if="!isFixedTableField(activeRootTableField.key)"
class="config-structured-editor__tab-panel-actions"
>
<strong>{{ activeRootTableField.key }}</strong>
<button
type="button"
:disabled="disabled"
:title="labels.remove"
@click="removeTableField(activeRootTableField.key)"
>
<Trash2 :size="13" />{{ labels.remove }}
</button>
</div>
<AdminConfigValueEditor
:model-value="activeRootTableField.value"
:structure="tableFieldStructure(activeRootTableField.key)"
:aria-label="`${ariaLabel} ${activeRootTableField.key}`"
:describe="describe"
:labels="labels"
:disabled="disabled"
:depth="depth + 1"
:path="tableEntryPath(activeRootTableField.key)"
@update:model-value="updateTableField(activeRootTableField.key, $event)"
/>
</div>
<div
v-else-if="mapType"
class="config-structured-editor__properties is-map"
>
<div
v-for="(entry, index) in mapEntries"
:key="`${entry.keyType}:${entry.key}`"
@@ -651,7 +827,7 @@ function mapEntryStructure(
:type="entry.keyType === 'number' ? 'number' : 'text'"
:value="entry.key"
:aria-label="`${ariaLabel} ${labels.types[entry.keyType]} ${index + 1}`"
:disabled="disabled || Boolean(mapEntryStructure(entry))"
:disabled="disabled || Boolean(fixedMapEntryStructure(entry))"
@change="updateMapKey(index, $event)"
/>
<em
@@ -701,7 +877,7 @@ function mapEntryStructure(
@update:model-value="updateMapValue(index, $event)"
/>
<button
v-if="!mapEntryStructure(entry)"
v-if="!fixedMapEntryStructure(entry)"
type="button"
class="config-structured-editor__remove"
:disabled="disabled"
@@ -717,11 +893,12 @@ function mapEntryStructure(
</div>
<div
v-else-if="tableEntries.length"
v-else-if="visibleTableEntries.length"
class="config-structured-editor__properties"
:role="rootTableTabs.length ? 'tabpanel' : undefined"
>
<div
v-for="([key, value], index) in tableEntries"
v-for="([key, value], index) in visibleTableEntries"
:key="key"
class="config-structured-editor__property"
:class="{
@@ -793,7 +970,10 @@ function mapEntryStructure(
</button>
</div>
</div>
<div v-else class="config-structured-editor__empty">
<div
v-else-if="!rootTableTabs.length"
class="config-structured-editor__empty"
>
{{ labels.emptyTable }}
</div>
@@ -802,24 +982,38 @@ function mapEntryStructure(
class="config-structured-editor__add-field is-map"
@submit.prevent="addMapEntry"
>
<select v-model="newMapKeyKind" :disabled="disabled">
<select
v-if="!mapStructure?.keyType"
v-model="newMapKeyKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
</select>
<span v-else class="config-structured-editor__fixed-type is-key-type">
{{ labels.types[mapStructure.keyType] }}
</span>
<input
v-model="newMapKey"
:type="newMapKeyKind === 'number' ? 'number' : 'text'"
:type="newMapEntryKeyKind === 'number' ? 'number' : 'text'"
:disabled="disabled"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select v-model="newMapValueKind" :disabled="disabled">
<select
v-if="!mapTemplate"
v-model="newMapValueKind"
:disabled="disabled"
>
<option value="string">{{ labels.types.string }}</option>
<option value="number">{{ labels.types.number }}</option>
<option value="boolean">{{ labels.types.boolean }}</option>
<option value="list">{{ labels.types.list }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span v-else class="config-structured-editor__fixed-type">
{{ structureTypeLabel(mapTemplate) }}
</span>
<button type="submit" :disabled="disabled || !canAddMapEntry">
<Plus :size="13" />{{ labels.addField }}
</button>
@@ -838,7 +1032,7 @@ function mapEntryStructure(
:aria-label="labels.keyPlaceholder"
/>
<select
v-if="!tableStructure?.mutableKeys"
v-if="!tableStructure"
v-model="newObjectKind"
:disabled="disabled"
>
@@ -848,6 +1042,12 @@ function mapEntryStructure(
<option value="list">{{ labels.types.list }}</option>
<option value="table">{{ labels.types.table }}</option>
</select>
<span
v-else-if="tableStructure.template"
class="config-structured-editor__fixed-type"
>
{{ structureTypeLabel(tableStructure.template) }}
</span>
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ labels.addField }}
</button>
@@ -917,12 +1117,12 @@ function mapEntryStructure(
min-width: 0;
overflow: hidden;
border-radius: 4px;
background: #181b18;
background: #121413;
outline: 1px solid rgba(255, 255, 255, 0.065);
}
.config-structured-editor.is-nested {
background: #151715;
background: #0f1110;
}
.config-structured-editor.is-nested.has-structure
@@ -959,6 +1159,74 @@ function mapEntryStructure(
text-transform: uppercase;
}
.config-structured-editor__tabs {
display: flex;
gap: 3px;
overflow-x: auto;
padding: 6px 7px 0;
background: #0c0e0d;
scrollbar-width: thin;
}
.config-structured-editor .config-structured-editor__tabs > button {
min-width: max-content;
min-height: 29px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 10px;
border-radius: 3px 3px 0 0;
outline: 0;
color: var(--admin-muted);
background: transparent;
font-size: 8px;
}
.config-structured-editor .config-structured-editor__tabs > button:hover,
.config-structured-editor .config-structured-editor__tabs > button.is-active {
color: var(--admin-text);
background: #171918;
}
.config-structured-editor .config-structured-editor__tabs > button.is-active {
box-shadow: inset 0 -1px var(--admin-green);
}
.config-structured-editor__tabs em {
color: var(--admin-dim);
font-size: 7px;
font-style: normal;
}
.config-structured-editor__tab-panel {
min-width: 0;
background: #0f1110;
}
.config-structured-editor__tab-panel > .config-structured-editor {
border-radius: 0;
outline: 0;
}
.config-structured-editor__tab-panel-actions {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 7px;
background: #141615;
}
.config-structured-editor__tab-panel-actions strong {
color: var(--admin-text);
font-size: 9px;
}
.config-structured-editor .config-structured-editor__tab-panel-actions button {
color: #b66a6a;
}
.config-structured-editor button,
.config-structured-editor select,
.config-structured-editor input,
@@ -967,7 +1235,7 @@ function mapEntryStructure(
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.075);
color: var(--admin-text);
background: #212421;
background: #1a1c1b;
font: inherit;
font-size: 9px;
}
@@ -984,7 +1252,7 @@ function mapEntryStructure(
}
.config-structured-editor button:hover:not(:disabled) {
background: color-mix(in srgb, var(--admin-green) 12%, #212421);
background: #222423;
}
.config-structured-editor button:disabled,
@@ -1010,19 +1278,19 @@ function mapEntryStructure(
.config-structured-editor__row,
.config-structured-editor__property {
display: grid;
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(140px, 1fr) 27px;
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(140px, 1.18fr) 27px;
align-items: center;
gap: 6px;
padding: 5px 6px;
background: #1a1d1a;
background: #151716;
}
.config-structured-editor__property {
grid-template-columns: 24px minmax(80px, 0.35fr) minmax(150px, 1fr) 27px;
grid-template-columns: 24px minmax(200px, 0.9fr) minmax(150px, 1.1fr) 27px;
}
.config-structured-editor__property.is-map {
grid-template-columns: 24px minmax(105px, 0.42fr) minmax(150px, 1fr) 27px;
grid-template-columns: 24px minmax(180px, 0.82fr) minmax(150px, 1.18fr) 27px;
}
.config-structured-editor__row.has-structured-value,
@@ -1267,6 +1535,25 @@ function mapEntryStructure(
flex: 0 0 90px;
}
.config-structured-editor__fixed-type {
width: 90px;
height: 23px;
flex: 0 0 90px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.055);
color: var(--admin-muted);
background: rgba(255, 255, 255, 0.025);
font-size: 8px;
}
.config-structured-editor__fixed-type.is-key-type {
width: 76px;
flex-basis: 76px;
}
.config-structured-editor__add-field button {
min-width: 74px;
flex: 0 0 auto;
@@ -1282,7 +1569,9 @@ function mapEntryStructure(
flex-basis: 82px;
}
.config-structured-editor__add-field.is-list select {
.config-structured-editor__add-field.is-list select,
.config-structured-editor__add-field.is-list
.config-structured-editor__fixed-type {
margin-left: auto;
}
@@ -265,6 +265,10 @@ describe('standalone admin panel contracts', () => {
expect(configuratorServer).toContain('path == "Companies.Definitions"')
expect(configuratorServer).toContain('flatten_company_fields')
expect(configuratorServer).toContain('structure.mutableKeys')
expect(configuratorServer).toContain('local function empty_structure(')
expect(configuratorServer).toContain('template = items[1]')
expect(configuratorServer).toContain('structure.template')
expect(configuratorServer).toContain('if not structure.fields[key] then')
expect(configuratorServer).toContain('field.type == "stringOrFalse"')
expect(configuratorServer).not.toContain('Config.Media = client_payload')
expect(configuratorClient).toContain(
@@ -275,6 +279,10 @@ describe('standalone admin panel contracts', () => {
'.admin-panel-rail .admin-panel-rail__configurator',
)
expect(source).toContain('<AdminConfigValueEditor')
expect(source).toContain('class="admin-panel-config-scopes"')
expect(source).toContain("selectConfiguratorScope('config')")
expect(source).toContain("selectConfiguratorScope('media')")
expect(source).not.toContain('class="admin-panel-config-meta"')
expect(configuratorValueEditor).toContain('function addListRow()')
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain(
@@ -288,8 +296,23 @@ describe('standalone admin panel contracts', () => {
expect(configuratorValueEditor).toContain('function removeTableField(')
expect(configuratorValueEditor).toContain('function addMapEntry()')
expect(configuratorValueEditor).toContain('function updateMapKey(')
expect(configuratorValueEditor).toContain('mapEntryStructure(current)')
expect(configuratorValueEditor).toContain('fixedMapEntryStructure(current)')
expect(configuratorValueEditor).toContain('listStructure?.items[index]')
expect(configuratorValueEditor).toContain('function listItemStructure(')
expect(configuratorValueEditor).toContain('const listTemplate = computed(')
expect(configuratorValueEditor).toContain('function structureTypeLabel(')
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__fixed-type"',
)
expect(configuratorValueEditor).toContain(
'const rootTableTabs = computed<RootTableTab[]>(',
)
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__tabs"',
)
expect(configuratorValueEditor).toContain(
'class="config-structured-editor__tab-panel"',
)
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
expect(configuratorValueEditor).toContain('function isFixedTableField(')
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
+115 -46
View File
@@ -73,6 +73,7 @@ type AdminTab =
| 'moderation'
| 'audit'
| 'configurator'
type ConfiguratorScope = 'config' | 'media'
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
@@ -83,6 +84,7 @@ const tab = ref<AdminTab>('overview')
const playerQuery = ref('')
const appQuery = ref('')
const configuratorQuery = ref('')
const configuratorScope = ref<ConfiguratorScope>('config')
const selectedConfiguratorSection = ref('')
const selectedImei = ref('')
const drafts = ref<Record<string, Record<string, boolean>>>({})
@@ -263,7 +265,9 @@ const selectedCalls = computed(() =>
: [],
)
const filteredConfiguratorSections = computed(() => {
const sections = admin.configurator?.sections ?? []
const sections = (admin.configurator?.sections ?? []).filter(
(section) => section.scope === configuratorScope.value,
)
const needle = configuratorQuery.value.trim().toLocaleLowerCase(phone.lang)
if (!needle) return sections
return sections.filter(
@@ -278,6 +282,13 @@ const filteredConfiguratorSections = computed(() => {
),
)
})
const configuratorScopeCounts = computed(() => {
const sections = admin.configurator?.sections ?? []
return {
config: sections.filter((section) => section.scope === 'config').length,
media: sections.filter((section) => section.scope === 'media').length,
}
})
const filteredConfiguratorFieldCount = computed(() =>
filteredConfiguratorSections.value.reduce(
(total, section) => total + configuratorSectionCount(section.fields),
@@ -305,14 +316,17 @@ watch(
)
watch(
() => admin.configurator?.sections,
(sections) => {
[() => admin.configurator?.sections, configuratorScope],
([sections, scope]) => {
if (
!sections?.some(
(section) => section.id === selectedConfiguratorSection.value,
(section) =>
section.id === selectedConfiguratorSection.value &&
section.scope === scope,
)
) {
selectedConfiguratorSection.value = sections?.[0]?.id ?? ''
selectedConfiguratorSection.value =
sections?.find((section) => section.scope === scope)?.id ?? ''
}
},
)
@@ -337,6 +351,11 @@ function t(key: string, params?: Record<string, string>): string {
return phone.t('AdminPanel.' + key, params)
}
function selectConfiguratorScope(scope: ConfiguratorScope): void {
configuratorScope.value = scope
configuratorQuery.value = ''
}
function configuratorDescription(
path: string,
value: unknown,
@@ -356,6 +375,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
emptyList: t('configurator.table.emptyList'),
emptyTable: t('configurator.table.emptyTable'),
entry: t('configurator.table.entry'),
general: t('configurator.table.general'),
keyPlaceholder: t('configurator.table.keyPlaceholder'),
list: t('configurator.table.list'),
remove: t('configurator.table.remove'),
@@ -1038,6 +1058,32 @@ onBeforeUnmount(() => {
:aria-label="t('configurator.search')"
/>
</label>
<div
class="admin-panel-config-scopes"
role="tablist"
:aria-label="t('configurator.sections')"
>
<button
type="button"
role="tab"
:aria-selected="configuratorScope === 'config'"
:class="{ 'is-active': configuratorScope === 'config' }"
@click="selectConfiguratorScope('config')"
>
<span>{{ t('configurator.configScope') }}</span>
<em>{{ configuratorScopeCounts.config }}</em>
</button>
<button
type="button"
role="tab"
:aria-selected="configuratorScope === 'media'"
:class="{ 'is-active': configuratorScope === 'media' }"
@click="selectConfiguratorScope('media')"
>
<span>{{ t('configurator.mediaScope') }}</span>
<em>{{ configuratorScopeCounts.media }}</em>
</button>
</div>
<div class="admin-panel-config-sections">
<button
v-for="section in filteredConfiguratorSections"
@@ -1274,10 +1320,6 @@ onBeforeUnmount(() => {
<h1>{{ t('configurator.title') }}</h1>
<p>{{ t('configurator.body') }}</p>
</div>
<div class="admin-panel-config-meta">
<span>SQL</span>
<strong>R{{ admin.configurator.revision }}</strong>
</div>
</div>
<article
@@ -2178,28 +2220,28 @@ onBeforeUnmount(() => {
<style scoped>
.admin-panel-overlay {
--admin-bg: #090b0a;
--admin-panel: #111311;
--admin-panel-raised: #171917;
--admin-panel-hover: #202320;
--admin-nav-active: #292c29;
--admin-bg: #070908;
--admin-panel: #0d0f0e;
--admin-panel-raised: #131514;
--admin-panel-hover: #191b1a;
--admin-nav-active: #202220;
--admin-border: rgba(255, 255, 255, 0.045);
--admin-border-strong: rgba(255, 255, 255, 0.085);
--admin-text: #f0f3f0;
--admin-muted: #818781;
--admin-dim: #555b55;
--admin-green: var(--admin-accent, #74d66f);
--admin-green-soft: color-mix(in srgb, var(--admin-green) 14%, transparent);
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
--admin-row-hover: linear-gradient(
90deg,
color-mix(in srgb, var(--admin-green) 10%, #1b1e1b) 0%,
rgba(27, 30, 27, 0.52) 48%,
#1a1c1b 0%,
rgba(24, 26, 25, 0.52) 48%,
transparent 100%
);
--admin-row-active: linear-gradient(
90deg,
color-mix(in srgb, var(--admin-green) 19%, #1d201d) 0%,
color-mix(in srgb, var(--admin-green) 7%, #171917) 45%,
#212321 0%,
#171918 45%,
transparent 100%
);
--admin-red: #ef6969;
@@ -2219,12 +2261,12 @@ onBeforeUnmount(() => {
width: min(76vw, 1220px);
height: min(74vh, 700px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: 4px;
background: var(--admin-bg);
box-shadow:
0 18px 52px rgba(0, 0, 0, 0.62),
inset 0 1px rgba(255, 255, 255, 0.025);
0 20px 56px rgba(0, 0, 0, 0.72),
inset 0 1px rgba(255, 255, 255, 0.018);
}
.admin-panel-header {
@@ -2347,14 +2389,15 @@ onBeforeUnmount(() => {
}
.admin-panel-save.is-ready {
border-color: color-mix(in srgb, var(--admin-green) 50%, transparent);
color: #091009;
background: var(--admin-green);
box-shadow: 0 0 18px color-mix(in srgb, var(--admin-green) 18%, transparent);
border-color: var(--admin-border-strong);
color: #74d66f;
background: #121413;
box-shadow: none;
}
.admin-panel-save.is-ready:hover:not(:disabled) {
filter: brightness(1.08);
color: color-mix(in srgb, #74d66f 82%, white);
background: #191b1a;
}
.admin-panel-close:hover {
@@ -2559,6 +2602,49 @@ button:disabled {
color: #5f655f;
}
.admin-panel-config-scopes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
margin: 0 12px 8px;
padding: 3px;
border-radius: 4px;
background: #171917;
}
.admin-panel-config-scopes button {
min-width: 0;
height: 27px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 8px;
border: 0;
border-radius: 3px;
color: var(--admin-muted);
background: transparent;
font: inherit;
font-size: 8px;
cursor: pointer;
}
.admin-panel-config-scopes button:hover,
.admin-panel-config-scopes button.is-active {
color: var(--admin-text);
background: var(--admin-row-active);
}
.admin-panel-config-scopes button.is-active {
box-shadow: inset 0 -1px var(--admin-green);
}
.admin-panel-config-scopes em {
color: var(--admin-dim);
font-size: 7px;
font-style: normal;
}
.admin-panel-player-list,
.admin-panel-audit-mini-list,
.admin-panel-config-sections {
@@ -2570,7 +2656,7 @@ button:disabled {
}
.admin-panel-config-sections {
height: calc(100% - 111px);
height: calc(100% - 149px);
overflow-y: auto;
padding: 0 8px 16px;
scrollbar-color: #343834 transparent;
@@ -3481,23 +3567,6 @@ button:disabled {
gap: 2px;
}
.admin-panel-config-meta {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
padding: 5px 8px;
border-radius: 4px;
color: var(--admin-muted);
background: #171917;
font-size: 9px;
}
.admin-panel-config-meta strong {
color: var(--admin-green);
font-size: 9px;
}
.admin-panel-config-disabled,
.admin-panel-config-notice {
display: grid;
@@ -3601,7 +3670,7 @@ button:disabled {
.admin-panel-config-field {
min-height: 51px;
display: grid;
grid-template-columns: minmax(170px, 0.72fr) minmax(230px, 1.28fr);
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1.1fr);
align-items: center;
gap: 14px;
padding: 8px 12px;
+1
View File
@@ -918,6 +918,7 @@ const adminPanelFallbackLocales = {
table: 'Key table',
vector: 'Vector',
entry: 'Entry',
general: 'General',
addRow: 'Add row',
addField: 'Add field',
remove: 'Remove',
+3
View File
@@ -127,6 +127,7 @@ export type AdminConfiguratorStructure =
| {
kind: 'list'
items: AdminConfiguratorStructure[]
template?: AdminConfiguratorStructure
}
| {
fields: Record<string, AdminConfiguratorStructure>
@@ -140,7 +141,9 @@ export type AdminConfiguratorStructure =
keyType: 'number' | 'string'
structure: AdminConfiguratorStructure
}>
keyType?: 'number' | 'string'
kind: 'map'
template?: AdminConfiguratorStructure
}
| {
kind: 'value'
+96 -15
View File
@@ -400,6 +400,82 @@ function maskValue(value, path) {
: value
}
function emptyStructure(scope, path) {
if (scope !== 'config') return undefined
if (path === 'Garage.VehicleImages.ModelNames') {
return {
entries: [],
keyType: 'number',
kind: 'map',
template: { kind: 'value', valueType: 'string' },
}
}
if (
path === 'CrewLink.ExternalPingResources' ||
path === 'CustomApps.TrustedAdapters'
) {
return {
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
}
if (path === 'FlipTok.MusicTracks') {
return {
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
Url: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
if (path === 'Music.Tracks') {
return {
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
if (path === 'Payphones.CustomLocations') {
return {
items: [],
kind: 'list',
template: { kind: 'vector', vectorType: 'vector4' },
}
}
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
return {
items: [],
kind: 'list',
template: {
fields: {
Description: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Price: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
}
}
return undefined
}
function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
@@ -425,30 +501,35 @@ function buildStructure(value, scope, path) {
template: keys[0] ? fields[keys[0]] : undefined,
}
}
if (
scope === 'config' &&
path === 'Garage.VehicleImages.ModelNames' &&
Array.isArray(value) &&
value.length === 0
) {
return { kind: 'map', entries: [] }
const configuredEmptyStructure =
Array.isArray(value) && value.length === 0
? emptyStructure(scope, path)
: undefined
if (configuredEmptyStructure) {
return configuredEmptyStructure
}
if (Array.isArray(value)) {
const items = value.map((child, index) =>
buildStructure(child, scope, `${path}.${index + 1}`),
)
return {
kind: 'list',
items: value.map((child, index) =>
buildStructure(child, scope, `${path}.${index + 1}`),
),
items,
template: items[0],
}
}
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
const entries = value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
}))
const keyTypes = new Set(entries.map((entry) => entry.keyType))
return {
kind: 'map',
entries: value.entries.map((entry) => ({
key: entry.key,
keyType: entry.keyType,
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
})),
entries,
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
template: entries[0]?.structure,
}
}
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
@@ -15,6 +15,7 @@ type ConfiguratorStructure = {
entries?: Array<{ structure: ConfiguratorStructure }>
fields?: Record<string, ConfiguratorStructure>
items?: ConfiguratorStructure[]
keyType?: 'number' | 'string'
kind: string
mutableKeys?: boolean
template?: ConfiguratorStructure
@@ -128,4 +129,66 @@ describe('admin configurator fixture', () => {
garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind,
).toBe('map')
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
)
const root = (path: string) =>
fields.find((field) => field.path === path)?.structure
expect(root('Music')?.fields?.Tracks).toMatchObject({
items: [],
kind: 'list',
template: {
fields: {
Artist: { kind: 'value', valueType: 'string' },
Id: { kind: 'value', valueType: 'string' },
Title: { kind: 'value', valueType: 'string' },
},
kind: 'table',
},
})
expect(root('FlipTok')?.fields?.MusicTracks.template).toMatchObject({
fields: { Url: { kind: 'value', valueType: 'string' } },
kind: 'table',
})
expect(root('Payphones')?.fields?.CustomLocations.template).toEqual({
kind: 'vector',
vectorType: 'vector4',
})
expect(root('CrewLink')?.fields?.ExternalPingResources).toMatchObject({
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(root('CustomApps')?.fields?.TrustedAdapters).toMatchObject({
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(
root('Garage')?.fields?.VehicleImages.fields?.ModelNames,
).toMatchObject({
entries: [],
keyType: 'number',
kind: 'map',
template: { kind: 'value', valueType: 'string' },
})
expect(
root('Companies.Definitions')?.fields?.police.fields?.Services,
).toMatchObject({
items: [],
kind: 'list',
template: {
fields: {
Id: { kind: 'value', valueType: 'string' },
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
},
})
})
})
+1 -1
View File
@@ -206,7 +206,7 @@ Locales["de"] = {
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", restartNotice = "Nichts wird automatisch gespeichert. Der grüne Haken schreibt alle vorgemerkten Werte. Startgebundene Einstellungen werden nach einem Neustart von sky_phone vollständig aktiv.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
detail = { character = "Charakterprofil", data = "Spielerdatenübersicht", cash = "Bargeld", bank = "Bank", job = "Job", duty = "Dienst", onDuty = "Im Dienst", offDuty = "Außer Dienst", identity = "Identität", playerData = "Spielerdaten", identifier = "Charakter-Identifier", birthdate = "Geburtsdatum", grade = "Job-Rang", unknown = "Unbekannt" },
+1 -1
View File
@@ -206,7 +206,7 @@ Locales["en"] = {
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", restartNotice = "Nothing is written automatically. Press the green check to persist all staged values. Startup-bound settings take full effect after restarting sky_phone.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
detail = { character = "Character profile", data = "Player data overview", cash = "Cash", bank = "Bank", job = "Job", duty = "Duty", onDuty = "On duty", offDuty = "Off duty", identity = "Identity", playerData = "Player data", identifier = "Character identifier", birthdate = "Birthdate", grade = "Job grade", unknown = "Unknown" },
+107 -17
View File
@@ -431,15 +431,77 @@ local function restore_redacted_values(value, current, path)
return restored
end
local function empty_structure_kind(scope, path)
local function empty_structure(scope, path)
if scope ~= "config" then
return nil
end
if path == "Garage.VehicleImages.ModelNames" then
return "map"
return {
entries = {},
keyType = "number",
kind = "map",
template = { kind = "value", valueType = "string" },
}
end
if path == "FlipTok.MusicTracks" or path:match("^Companies%.Definitions%.[^.]+%.Services$") then
return "list"
if path == "CrewLink.ExternalPingResources" or path == "CustomApps.TrustedAdapters" then
return {
fields = {},
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = "boolean" },
}
end
if path == "FlipTok.MusicTracks" then
return {
items = {},
kind = "list",
template = {
fields = {
Artist = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Title = { kind = "value", valueType = "string" },
Url = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
if path == "Music.Tracks" then
return {
items = {},
kind = "list",
template = {
fields = {
Artist = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Title = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
if path == "Payphones.CustomLocations" then
return {
items = {},
kind = "list",
template = { kind = "vector", vectorType = "vector4" },
}
end
if path:match("^Companies%.Definitions%.[^.]+%.Services$") then
return {
items = {},
kind = "list",
template = {
fields = {
Description = { kind = "value", valueType = "string" },
Id = { kind = "value", valueType = "string" },
Price = { kind = "value", valueType = "string" },
RequestsEnabled = { kind = "value", valueType = "boolean" },
Title = { kind = "value", valueType = "string" },
},
kind = "table",
},
}
end
return nil
end
@@ -483,30 +545,29 @@ local function build_structure(value, scope, path)
end
if value.__skyType == "map" then
local entries = {}
local key_type
for index, entry in ipairs(value.entries or {}) do
entries[index] = {
key = entry.key,
keyType = entry.keyType,
structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)),
}
if index == 1 then
key_type = entry.keyType
elseif key_type ~= entry.keyType then
key_type = nil
end
end
return {
entries = entries,
keyType = key_type,
kind = "map",
template = entries[1] and entries[1].structure or nil,
}
end
local empty_kind = next(value) == nil and empty_structure_kind(scope, path) or nil
if empty_kind == "map" then
return {
entries = {},
kind = "map",
}
end
if empty_kind == "list" then
return {
items = {},
kind = "list",
}
local configured_empty_structure = next(value) == nil and empty_structure(scope, path) or nil
if configured_empty_structure then
return configured_empty_structure
end
if is_sequence(value) then
local items = {}
@@ -516,6 +577,7 @@ local function build_structure(value, scope, path)
return {
items = items,
kind = "list",
template = items[1],
}
end
@@ -794,11 +856,19 @@ local function validate_locked_structure(structure, value)
if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then
return false
end
for index, item_structure in ipairs(structure.items or {}) do
local fixed_items = structure.items or {}
for index, item_structure in ipairs(fixed_items) do
if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then
return false
end
end
if structure.template then
for index = #fixed_items + 1, #value do
if not validate_locked_structure(structure.template, value[index]) then
return false
end
end
end
return true
end
if structure.kind == "table" then
@@ -806,6 +876,11 @@ local function validate_locked_structure(structure, value)
return false
end
if not structure.mutableKeys then
for key in pairs(value) do
if not structure.fields[key] then
return false
end
end
for key, field_structure in pairs(structure.fields or {}) do
if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then
return false
@@ -842,6 +917,21 @@ local function validate_locked_structure(structure, value)
return false
end
end
if structure.template or structure.keyType then
local fixed_entries = {}
for _, entry in ipairs(structure.entries or {}) do
fixed_entries[entry.keyType .. ":" .. tostring(entry.key)] = true
end
for _, entry in ipairs(value.entries or {}) do
local identity = entry.keyType .. ":" .. tostring(entry.key)
local invalid_key_type = structure.keyType and entry.keyType ~= structure.keyType
local invalid_value = structure.template
and not validate_locked_structure(structure.template, entry.value)
if not fixed_entries[identity] and (invalid_key_type or invalid_value) then
return false
end
end
end
return true
end