diff --git a/README.md b/README.md index 88eff1f..8e14cfd 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,13 @@ modules initialize, and exposes the editor through `/phonepanel`. Nothing autosa in the Phone Configurator tool and press the green check. Restart `sky_phone` after changing startup-bound settings such as framework, inventory, command, item, or provider configuration. +Every `Config.*` value from `config.lua`, including server-only sections, and every value from +`Config.Media` is discovered automatically. The bootstrap switch above intentionally remains +file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors, +and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay +editable but cannot be renamed, converted, or removed. Every list and table still accepts any number +of additional rows; administrator-added rows remain removable. + Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are shown only as configured and are replaced only when an administrator enters a new value. @@ -553,7 +560,10 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop ### Companies -Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`. +Company jobs, public profiles, service numbers, services, permissions, locations, and default +availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the +shipped jobs: add any number of company IDs in the in-game configurator, choose `Table`, and fill the +freely configurable `Job` value in the generated full company template. ### Weazel News diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue index a4f4d5d..590583e 100644 --- a/frontend/src/components/AdminConfigValueEditor.vue +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -2,11 +2,14 @@ import { Plus, Rows3, TableProperties, Trash2 } from 'lucide-vue-next' import { computed, ref } from 'vue' +import type { AdminConfiguratorStructure } from '@/types/admin' + export type AdminConfigEditorLabels = { addField: string addRow: string configuredSecret: string convertToList: string + convertToMap: string convertToTable: string emptyList: string emptyTable: string @@ -33,38 +36,97 @@ const props = withDefaults( disabled?: boolean labels: AdminConfigEditorLabels modelValue: unknown + structure?: AdminConfiguratorStructure }>(), { ariaLabel: '', depth: 0, disabled: false }, ) const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>() type ValueKind = 'boolean' | 'list' | 'number' | 'string' | 'table' +type MapKeyKind = 'number' | 'string' +type SerializedMapEntry = { + key: number | string + keyType: MapKeyKind + value: unknown +} const newArrayKind = ref('string') const newObjectKey = ref('') const newObjectKind = ref('string') +const newMapKey = ref('') +const newMapKeyKind = ref('string') +const newMapValueKind = ref('string') -const isList = computed(() => Array.isArray(props.modelValue)) +const isList = computed( + () => + props.structure?.kind === 'list' || + (props.structure?.kind !== 'map' && + props.structure?.kind !== 'table' && + Array.isArray(props.modelValue)), +) const listValue = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [], ) const isTable = computed( () => - props.modelValue !== null && - typeof props.modelValue === 'object' && - !Array.isArray(props.modelValue), + props.structure?.kind === 'map' || + props.structure?.kind === 'table' || + props.structure?.kind === 'vector' || + (props.modelValue !== null && + typeof props.modelValue === 'object' && + !Array.isArray(props.modelValue)), ) const tableValue = computed>(() => - isTable.value ? (props.modelValue as Record) : {}, + isTable.value && !Array.isArray(props.modelValue) + ? (props.modelValue as Record) + : {}, ) const vectorType = computed(() => { const value = tableValue.value.__skyType return typeof value === 'string' && /^vector[234]$/.test(value) ? value : '' }) +const mapType = computed( + () => props.structure?.kind === 'map' || tableValue.value.__skyType === 'map', +) +const mapEntries = computed(() => { + const entries = tableValue.value.entries + if (!mapType.value || !Array.isArray(entries)) return [] + return entries.filter( + (entry): entry is SerializedMapEntry => + entry !== null && + typeof entry === 'object' && + (entry.keyType === 'number' || entry.keyType === 'string'), + ) +}) +const listStructure = computed(() => + props.structure?.kind === 'list' ? props.structure : null, +) +const tableStructure = computed(() => + props.structure?.kind === 'table' ? props.structure : null, +) +const mapStructure = computed(() => + props.structure?.kind === 'map' ? props.structure : null, +) const tableEntries = computed(() => - Object.entries(tableValue.value).filter(([key]) => key !== '__skyType'), + Object.entries(tableValue.value).filter( + ([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'), + ), ) const isMaskedSecret = computed(() => props.modelValue === '***REDACTED***') +const parsedNewMapKey = computed(() => { + const key = newMapKey.value.trim() + if (!key) return null + if (newMapKeyKind.value === 'string') return key + const numeric = Number(key) + return Number.isFinite(numeric) ? numeric : null +}) +const canAddMapEntry = computed(() => { + const key = parsedNewMapKey.value + if (key === null) return false + return !mapEntries.value.some( + (entry) => entry.keyType === newMapKeyKind.value && entry.key === key, + ) +}) function blankValue(kind: ValueKind): unknown { if (kind === 'boolean') return false @@ -88,6 +150,19 @@ function blankLike(value: unknown): unknown { return '' } +function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown { + const template = siblings.find((value) => { + if (kind === 'list') return Array.isArray(value) + if (kind === 'table') { + return ( + value !== null && typeof value === 'object' && !Array.isArray(value) + ) + } + return typeof value === kind + }) + return template === undefined ? blankValue(kind) : blankLike(template) +} + function updateScalar(event: Event): void { const target = event.target if (!(target instanceof HTMLInputElement)) return @@ -106,6 +181,19 @@ function updateBoolean(event: Event): void { } } +function updateOptionalString(event: Event): void { + const target = event.target + if (target instanceof HTMLInputElement) { + emit('update:modelValue', target.value) + } +} + +function toggleOptionalString(event: Event): void { + const target = event.target + if (!(target instanceof HTMLInputElement)) return + emit('update:modelValue', target.checked ? '' : false) +} + function addListRow(): void { const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : [] rows.push(rows.length ? blankLike(rows[0]) : blankValue(newArrayKind.value)) @@ -119,6 +207,7 @@ function updateListRow(index: number, value: unknown): void { } function removeListRow(index: number): void { + if (listStructure.value?.items[index]) return const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : [] rows.splice(index, 1) emit('update:modelValue', rows) @@ -129,6 +218,11 @@ function updateTableField(key: string, value: unknown): void { } function removeTableField(key: string): void { + if ( + tableStructure.value && + Object.prototype.hasOwnProperty.call(tableStructure.value.fields, key) + ) + return const next = { ...tableValue.value } delete next[key] emit('update:modelValue', next) @@ -136,14 +230,104 @@ function removeTableField(key: string): void { function addTableField(): void { const key = newObjectKey.value.trim() - if (!key || Object.prototype.hasOwnProperty.call(tableValue.value, key)) + if ( + !key || + key === '__skyType' || + Object.prototype.hasOwnProperty.call(tableValue.value, key) + ) return emit('update:modelValue', { ...tableValue.value, - [key]: blankValue(newObjectKind.value), + [key]: blankCollectionValue( + newObjectKind.value, + Object.values(tableValue.value).filter((_, index) => index < 50), + ), }) newObjectKey.value = '' } + +function emitMap(entries: SerializedMapEntry[]): void { + emit('update:modelValue', { __skyType: 'map', entries }) +} + +function convertTableToMap(): void { + emitMap( + Object.entries(tableValue.value) + .filter(([key]) => key !== '__skyType') + .map(([key, value]) => ({ key, keyType: 'string', value })), + ) +} + +function updateMapKey(index: number, event: Event): void { + const target = event.target + if (!(target instanceof HTMLInputElement)) return + const current = mapEntries.value[index] + if (!current) return + if (mapEntryStructure(current)) { + target.value = String(current.key) + return + } + const key = + current.keyType === 'number' ? Number(target.value) : target.value.trim() + if ( + key === '' || + (typeof key === 'number' && !Number.isFinite(key)) || + mapEntries.value.some( + (entry, candidateIndex) => + candidateIndex !== index && + entry.keyType === current.keyType && + entry.key === key, + ) + ) { + target.value = String(current.key) + return + } + const entries = mapEntries.value.map((entry, candidateIndex) => + candidateIndex === index ? { ...entry, key } : entry, + ) + emitMap(entries) +} + +function updateMapValue(index: number, value: unknown): void { + emitMap( + mapEntries.value.map((entry, candidateIndex) => + candidateIndex === index ? { ...entry, value } : entry, + ), + ) +} + +function removeMapEntry(index: number): void { + const entry = mapEntries.value[index] + if (!entry || mapEntryStructure(entry)) return + emitMap( + mapEntries.value.filter((_, candidateIndex) => candidateIndex !== index), + ) +} + +function addMapEntry(): void { + if (!canAddMapEntry.value || parsedNewMapKey.value === null) return + emitMap([ + ...mapEntries.value, + { + key: parsedNewMapKey.value, + keyType: newMapKeyKind.value, + value: blankCollectionValue( + newMapValueKind.value, + mapEntries.value.slice(0, 50).map((entry) => entry.value), + ), + }, + ]) + newMapKey.value = '' +} + +function mapEntryStructure( + entry: SerializedMapEntry, +): AdminConfiguratorStructure | undefined { + return mapStructure.value?.entries.find( + (candidate) => + candidate.keyType === entry.keyType && candidate.key === entry.key, + )?.structure +}