mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 23:01:37 +00:00
ENH - refine admin configurator controls
This commit is contained in:
@@ -92,6 +92,23 @@ as project instructions, not as optional documentation.
|
||||
proof.
|
||||
- Do not hand-edit generated frontend output.
|
||||
|
||||
## Phone Configurator parity (mandatory)
|
||||
|
||||
- Every new, changed, renamed, moved, or removed configurable value in
|
||||
`sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be
|
||||
reflected in the in-game Phone Configurator in the same change. Config-only
|
||||
changes without matching Configurator support are incomplete and must not be
|
||||
committed or merged.
|
||||
- Keep the Configurator's runtime schema, defaults, value types, fixed versus
|
||||
extensible collection rules, labels, descriptions, English and German
|
||||
locales, SQL persistence, validation, and save/load roundtrip aligned with
|
||||
the Lua configuration.
|
||||
- The Configurator must remain complete when file-based configuration is
|
||||
disabled. Do not introduce a setting that can only be managed by editing Lua
|
||||
after `Config.PhoneConfigurator.Enabled` is enabled.
|
||||
- Add or update contract/fixture coverage so configuration parity regressions
|
||||
fail automated checks.
|
||||
|
||||
## Working method and verification
|
||||
|
||||
1. Trace the relevant client, server, NUI, configuration, persistence, and event
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { vConfigInputWidth } from '@/directives/configInputWidth'
|
||||
import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription'
|
||||
|
||||
@@ -48,6 +49,7 @@ const props = withDefaults(
|
||||
modelValue: unknown
|
||||
path?: string
|
||||
structure?: AdminConfiguratorStructure
|
||||
tabLabel?: (key: string, value: unknown) => string
|
||||
}>(),
|
||||
{ ariaLabel: '', depth: 0, disabled: false, path: '' },
|
||||
)
|
||||
@@ -163,7 +165,7 @@ const rootTableTabs = computed<RootTableTab[]>(() => {
|
||||
count: configuratorStructureSize(tableFieldStructure(key)),
|
||||
id: `field:${key}`,
|
||||
key,
|
||||
label: key,
|
||||
label: props.tabLabel?.(key, tableValue.value[key]) ?? key,
|
||||
})),
|
||||
]
|
||||
})
|
||||
@@ -666,6 +668,7 @@ function mapEntryStructure(
|
||||
:aria-label="`${ariaLabel} ${index + 1}`"
|
||||
:describe="describe"
|
||||
:labels="labels"
|
||||
:tab-label="tabLabel"
|
||||
:disabled="disabled"
|
||||
:depth="depth + 1"
|
||||
:path="listEntryPath(index)"
|
||||
@@ -797,6 +800,7 @@ function mapEntryStructure(
|
||||
:aria-label="`${ariaLabel} ${activeRootTableField.key}`"
|
||||
:describe="describe"
|
||||
:labels="labels"
|
||||
:tab-label="tabLabel"
|
||||
:disabled="disabled"
|
||||
:depth="depth + 1"
|
||||
:path="tableEntryPath(activeRootTableField.key)"
|
||||
@@ -871,6 +875,7 @@ function mapEntryStructure(
|
||||
:aria-label="`${ariaLabel} ${entry.key}`"
|
||||
:describe="describe"
|
||||
:labels="labels"
|
||||
:tab-label="tabLabel"
|
||||
:disabled="disabled"
|
||||
:depth="depth + 1"
|
||||
:path="mapValuePath(entry)"
|
||||
@@ -953,6 +958,7 @@ function mapEntryStructure(
|
||||
:aria-label="`${ariaLabel} ${key}`"
|
||||
:describe="describe"
|
||||
:labels="labels"
|
||||
:tab-label="tabLabel"
|
||||
:disabled="disabled"
|
||||
:depth="depth + 1"
|
||||
:path="tableEntryPath(key)"
|
||||
@@ -1059,6 +1065,7 @@ function mapEntryStructure(
|
||||
class="config-value-optional"
|
||||
>
|
||||
<input
|
||||
v-config-input-width
|
||||
class="config-value-input"
|
||||
type="text"
|
||||
:aria-label="ariaLabel"
|
||||
@@ -1095,6 +1102,7 @@ function mapEntryStructure(
|
||||
|
||||
<input
|
||||
v-else
|
||||
v-config-input-width
|
||||
class="config-value-input"
|
||||
:aria-label="ariaLabel"
|
||||
:type="
|
||||
@@ -1496,9 +1504,10 @@ function mapEntryStructure(
|
||||
|
||||
.config-value-optional {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@@ -1576,12 +1585,23 @@ function mapEntryStructure(
|
||||
}
|
||||
|
||||
.config-value-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: 29px;
|
||||
justify-self: start;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.config-value-input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.config-structured-editor input[type='number']::-webkit-inner-spin-button,
|
||||
.config-structured-editor input[type='number']::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.config-value-input:focus,
|
||||
.config-structured-editor input:focus,
|
||||
.config-structured-editor select:focus {
|
||||
@@ -1590,7 +1610,7 @@ function mapEntryStructure(
|
||||
|
||||
.config-value-toggle {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
justify-self: start;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const source = readFileSync(
|
||||
new URL('./AdminPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const agentInstructions = readFileSync(
|
||||
new URL('../../../AGENTS.md', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const app = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
|
||||
const apps = readFileSync(new URL('../config/apps.ts', import.meta.url), 'utf8')
|
||||
const store = readFileSync(
|
||||
@@ -76,6 +80,10 @@ const configuratorValueEditor = readFileSync(
|
||||
new URL('./AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configInputWidth = readFileSync(
|
||||
new URL('../directives/configInputWidth.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('standalone admin panel contracts', () => {
|
||||
it('renders as a dedicated full-screen editor outside the phone shell', () => {
|
||||
@@ -102,6 +110,7 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(source).not.toContain('admin-panel-brand__mark')
|
||||
expect(source).not.toContain('admin-panel-profile-heading__status')
|
||||
expect(source).not.toContain('backdrop-filter: blur(2px)')
|
||||
expect(source).not.toContain("t('overview.recent')")
|
||||
|
||||
for (const tab of [
|
||||
'overview',
|
||||
@@ -124,7 +133,20 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(source).not.toContain('<RefreshCw')
|
||||
expect(source).not.toContain("kind: 'refresh'")
|
||||
expect(source).toContain("'sky-phone-admin-accent'")
|
||||
expect(source).toContain(':style="{ \'--admin-accent\': accentColor }"')
|
||||
expect(source).toContain("'sky-phone-admin-font-family'")
|
||||
expect(source).toContain("'sky-phone-admin-font-size'")
|
||||
expect(source).toContain("'--admin-accent': accentColor")
|
||||
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
|
||||
expect(source).toContain(
|
||||
"'--admin-font-scale': String(adminFontSize / 100)",
|
||||
)
|
||||
expect(source).toContain('class="admin-panel-color-value"')
|
||||
expect(source).toContain('class="admin-panel-rgb-fields"')
|
||||
expect(source).toContain('class="admin-panel-rgb-range"')
|
||||
expect(source).toContain('class="admin-panel-font-select"')
|
||||
expect(source).toContain('class="admin-panel-font-select__menu"')
|
||||
expect(source).toContain('class="admin-panel-font-size-control"')
|
||||
expect(source).not.toContain('type="color"')
|
||||
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
|
||||
})
|
||||
|
||||
@@ -230,6 +252,10 @@ describe('standalone admin panel contracts', () => {
|
||||
})
|
||||
|
||||
it('loads the SQL phone configurator before framework-owned configuration is read', () => {
|
||||
expect(agentInstructions).toContain('Phone Configurator parity (mandatory)')
|
||||
expect(agentInstructions).toContain(
|
||||
'changes without matching Configurator support are incomplete',
|
||||
)
|
||||
expect(config).toMatch(
|
||||
/Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*false[\s\S]*?Config\.Bridge\s*=/,
|
||||
)
|
||||
@@ -313,6 +339,21 @@ describe('standalone admin panel contracts', () => {
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'class="config-structured-editor__tab-panel"',
|
||||
)
|
||||
expect(source).toContain(':tab-label="configuratorSubtabLabel"')
|
||||
expect(source).toContain('configurator.table.subtabs.${key}')
|
||||
expect(configuratorValueEditor).toContain(
|
||||
'props.tabLabel?.(key, tableValue.value[key]) ?? key',
|
||||
)
|
||||
expect(source).toContain('v-config-input-width')
|
||||
expect(configuratorValueEditor).toContain('v-config-input-width')
|
||||
expect(configInputWidth).toContain("input.addEventListener('input'")
|
||||
expect(configInputWidth).toContain('input.scrollWidth')
|
||||
expect(source).toContain('filter: drop-shadow')
|
||||
expect(source).toContain("input[type='number']::-webkit-inner-spin-button")
|
||||
expect(configuratorValueEditor).toContain(
|
||||
"input[type='number']::-webkit-inner-spin-button",
|
||||
)
|
||||
expect(source).toContain('justify-self: start')
|
||||
expect(configuratorValueEditor).toContain('function tableFieldStructure(')
|
||||
expect(configuratorValueEditor).toContain('function isFixedTableField(')
|
||||
expect(configuratorValueEditor).toContain('function blankCollectionValue(')
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BadgeDollarSign,
|
||||
BriefcaseBusiness,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Clipboard,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
isPhoneAppRemovable,
|
||||
PHONE_APPS,
|
||||
} from '@/config/apps'
|
||||
import { vConfigInputWidth } from '@/directives/configInputWidth'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
@@ -53,7 +55,10 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||
import { SkyButton } from '@/ui'
|
||||
import { describeConfiguratorValue } from '@/utils/adminConfiguratorDescription'
|
||||
import {
|
||||
configuratorPathName,
|
||||
describeConfiguratorValue,
|
||||
} from '@/utils/adminConfiguratorDescription'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
import { parseDatabaseDate } from '@/utils/date'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
@@ -76,6 +81,16 @@ type AdminTab =
|
||||
type ConfiguratorScope = 'config' | 'media'
|
||||
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
|
||||
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
|
||||
type AccentChannel = 'blue' | 'green' | 'red'
|
||||
type AdminFontFamily =
|
||||
| 'classic'
|
||||
| 'georgia'
|
||||
| 'inter'
|
||||
| 'mono'
|
||||
| 'system'
|
||||
| 'tahoma'
|
||||
| 'trebuchet'
|
||||
| 'verdana'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const admin = useAdminStore()
|
||||
@@ -94,6 +109,7 @@ const revealDialogImei = ref('')
|
||||
const deviceAction = ref<DeviceAction | null>(null)
|
||||
const deviceActionInput = ref('')
|
||||
const discardDialog = ref(false)
|
||||
const fontMenuOpen = ref(false)
|
||||
const pendingAction = ref<PendingAction | null>(null)
|
||||
const toast = ref('')
|
||||
const toastTone = ref<'error' | 'success'>('success')
|
||||
@@ -106,7 +122,48 @@ const accentOptions = [
|
||||
{ color: '#f0a24b', key: 'orange' },
|
||||
{ color: '#ef6969', key: 'red' },
|
||||
] as const
|
||||
const accentColor = ref<(typeof accentOptions)[number]['color']>('#74d66f')
|
||||
const accentChannels = ['red', 'green', 'blue'] as const
|
||||
const fontFamilyOptions: Array<{
|
||||
key: AdminFontFamily
|
||||
value: string
|
||||
}> = [
|
||||
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
|
||||
{
|
||||
key: 'system',
|
||||
value:
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
},
|
||||
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
|
||||
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
|
||||
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
|
||||
{
|
||||
key: 'trebuchet',
|
||||
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
|
||||
},
|
||||
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
|
||||
{
|
||||
key: 'mono',
|
||||
value:
|
||||
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
]
|
||||
const accentColor = ref('#74d66f')
|
||||
const adminFontFamily = ref<AdminFontFamily>('inter')
|
||||
const adminFontSize = ref(100)
|
||||
const adminFontSizePreview = ref(100)
|
||||
const activeAdminFontFamily = computed(
|
||||
() =>
|
||||
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
|
||||
?.value ?? fontFamilyOptions[0].value,
|
||||
)
|
||||
const accentRgb = computed(() => {
|
||||
const color = accentColor.value.replace('#', '')
|
||||
return {
|
||||
red: Number.parseInt(color.slice(0, 2), 16),
|
||||
green: Number.parseInt(color.slice(2, 4), 16),
|
||||
blue: Number.parseInt(color.slice(4, 6), 16),
|
||||
}
|
||||
})
|
||||
|
||||
const filteredPlayers = computed(() => {
|
||||
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
|
||||
@@ -351,6 +408,32 @@ function t(key: string, params?: Record<string, string>): string {
|
||||
return phone.t('AdminPanel.' + key, params)
|
||||
}
|
||||
|
||||
function configuratorLocaleText(key: string, fallback: string): string {
|
||||
const translated = t(key)
|
||||
return translated === key || translated === `AdminPanel.${key}`
|
||||
? fallback
|
||||
: translated
|
||||
}
|
||||
|
||||
function configuratorSubtabLabel(key: string, value: unknown): string {
|
||||
const record =
|
||||
value !== null && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
if (
|
||||
/^[a-z0-9_-]+$/.test(key) &&
|
||||
typeof record?.Name === 'string' &&
|
||||
record.Name
|
||||
) {
|
||||
return record.Name
|
||||
}
|
||||
const fallback = configuratorPathName(key)
|
||||
return configuratorLocaleText(
|
||||
`configurator.table.subtabs.${key}`,
|
||||
fallback.charAt(0).toLocaleUpperCase(phone.lang) + fallback.slice(1),
|
||||
)
|
||||
}
|
||||
|
||||
function selectConfiguratorScope(scope: ConfiguratorScope): void {
|
||||
configuratorScope.value = scope
|
||||
configuratorQuery.value = ''
|
||||
@@ -375,7 +458,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'),
|
||||
general: configuratorLocaleText('configurator.table.general', 'General'),
|
||||
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
||||
list: t('configurator.table.list'),
|
||||
remove: t('configurator.table.remove'),
|
||||
@@ -502,6 +585,7 @@ function queueAction(action: PendingAction): void {
|
||||
|
||||
function selectTab(nextTab: AdminTab): void {
|
||||
tab.value = nextTab
|
||||
fontMenuOpen.value = false
|
||||
if (nextTab === 'configurator' && !admin.configurator) {
|
||||
void admin.loadConfigurator().then((loaded) => {
|
||||
if (!loaded) showToast(errorText(), 'error')
|
||||
@@ -509,9 +593,61 @@ function selectTab(nextTab: AdminTab): void {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAccent(color: (typeof accentOptions)[number]['color']): void {
|
||||
accentColor.value = color
|
||||
window.localStorage.setItem('sky-phone-admin-accent', color)
|
||||
function selectAccent(color: string): void {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return
|
||||
accentColor.value = color.toLowerCase()
|
||||
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
|
||||
}
|
||||
|
||||
function updateAccent(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
|
||||
else input.value = accentColor.value.toUpperCase()
|
||||
}
|
||||
|
||||
function updateAccentChannel(channel: AccentChannel, event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
|
||||
const channels = { ...accentRgb.value, [channel]: value }
|
||||
selectAccent(
|
||||
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
|
||||
.toString(16)
|
||||
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function accentChannelGradient(channel: AccentChannel): string {
|
||||
const { red, green, blue } = accentRgb.value
|
||||
if (channel === 'red') {
|
||||
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
|
||||
}
|
||||
if (channel === 'green') {
|
||||
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
|
||||
}
|
||||
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
|
||||
}
|
||||
|
||||
function selectAdminFontFamily(family: AdminFontFamily): void {
|
||||
adminFontFamily.value = family
|
||||
fontMenuOpen.value = false
|
||||
window.localStorage.setItem('sky-phone-admin-font-family', family)
|
||||
}
|
||||
|
||||
function closeFontMenu(event: FocusEvent): void {
|
||||
const menu = event.currentTarget as HTMLElement
|
||||
if (!menu.contains(event.relatedTarget as Node | null)) {
|
||||
fontMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminFontSize(event: Event): void {
|
||||
const size = Number((event.currentTarget as HTMLInputElement).value)
|
||||
adminFontSize.value = Math.min(115, Math.max(90, size))
|
||||
adminFontSizePreview.value = adminFontSize.value
|
||||
window.localStorage.setItem(
|
||||
'sky-phone-admin-font-size',
|
||||
String(adminFontSize.value),
|
||||
)
|
||||
}
|
||||
|
||||
async function runAction(action: PendingAction): Promise<void> {
|
||||
@@ -772,6 +908,10 @@ function auditDescription(entry: AdminAuditEntry): string {
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
if (fontMenuOpen.value) {
|
||||
fontMenuOpen.value = false
|
||||
return
|
||||
}
|
||||
if (revealDialogImei.value) {
|
||||
revealDialogImei.value = ''
|
||||
return
|
||||
@@ -792,10 +932,25 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
|
||||
const storedOption = accentOptions.find(
|
||||
(option) => option.color === storedAccent,
|
||||
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
|
||||
accentColor.value = storedAccent.toLowerCase()
|
||||
}
|
||||
const storedFontFamily = window.localStorage.getItem(
|
||||
'sky-phone-admin-font-family',
|
||||
)
|
||||
if (storedOption) accentColor.value = storedOption.color
|
||||
if (
|
||||
storedFontFamily &&
|
||||
fontFamilyOptions.some((option) => option.key === storedFontFamily)
|
||||
) {
|
||||
adminFontFamily.value = storedFontFamily as AdminFontFamily
|
||||
}
|
||||
const storedFontSize = Number(
|
||||
window.localStorage.getItem('sky-phone-admin-font-size'),
|
||||
)
|
||||
if (storedFontSize >= 90 && storedFontSize <= 115) {
|
||||
adminFontSize.value = storedFontSize
|
||||
adminFontSizePreview.value = storedFontSize
|
||||
}
|
||||
void refreshData()
|
||||
})
|
||||
|
||||
@@ -810,7 +965,11 @@ onBeforeUnmount(() => {
|
||||
class="admin-panel-overlay"
|
||||
role="dialog"
|
||||
:aria-label="t('name')"
|
||||
:style="{ '--admin-accent': accentColor }"
|
||||
:style="{
|
||||
'--admin-accent': accentColor,
|
||||
'--admin-font-family': activeAdminFontFamily,
|
||||
'--admin-font-scale': String(adminFontSize / 100),
|
||||
}"
|
||||
>
|
||||
<div class="admin-panel-window">
|
||||
<header class="admin-panel-header">
|
||||
@@ -1260,42 +1419,125 @@ onBeforeUnmount(() => {
|
||||
<Check v-if="accentColor === option.color" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-panel-appearance-controls">
|
||||
<section class="admin-panel-appearance-control is-color">
|
||||
<div>
|
||||
<strong>{{ t('appearance.controls.customColor') }}</strong>
|
||||
<small>{{
|
||||
t('appearance.controls.customColorBody')
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="admin-panel-rgb-picker">
|
||||
<label class="admin-panel-color-value">
|
||||
<span :style="{ backgroundColor: accentColor }"></span>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="text"
|
||||
maxlength="7"
|
||||
:value="accentColor.toUpperCase()"
|
||||
:aria-label="t('appearance.controls.hex')"
|
||||
@change="updateAccent"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-panel-rgb-fields">
|
||||
<label v-for="channel in accentChannels" :key="channel">
|
||||
<span>{{ t(`appearance.controls.${channel}`) }}</span>
|
||||
<input
|
||||
class="admin-panel-rgb-range"
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:style="{
|
||||
background: accentChannelGradient(channel),
|
||||
}"
|
||||
:aria-label="t(`appearance.controls.${channel}`)"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
:value="accentRgb[channel]"
|
||||
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
|
||||
@input="updateAccentChannel(channel, $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
|
||||
</span>
|
||||
<div
|
||||
class="admin-panel-font-select"
|
||||
@focusout="closeFontMenu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="fontMenuOpen"
|
||||
@click="fontMenuOpen = !fontMenuOpen"
|
||||
>
|
||||
<span :style="{ fontFamily: activeAdminFontFamily }">
|
||||
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
|
||||
</span>
|
||||
<ChevronDown :size="14" />
|
||||
</button>
|
||||
<div
|
||||
v-if="fontMenuOpen"
|
||||
class="admin-panel-font-select__menu"
|
||||
role="listbox"
|
||||
:aria-label="t('appearance.controls.fontFamily')"
|
||||
>
|
||||
<button
|
||||
v-for="option in fontFamilyOptions"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="adminFontFamily === option.key"
|
||||
:class="{ 'is-active': adminFontFamily === option.key }"
|
||||
:style="{ fontFamily: option.value }"
|
||||
@click="selectAdminFontFamily(option.key)"
|
||||
>
|
||||
<span>{{
|
||||
t(`appearance.controls.fonts.${option.key}`)
|
||||
}}</span>
|
||||
<Check
|
||||
v-if="adminFontFamily === option.key"
|
||||
:size="13"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="admin-panel-appearance-control">
|
||||
<span>
|
||||
<strong>{{ t('appearance.controls.fontSize') }}</strong>
|
||||
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
|
||||
</span>
|
||||
<div class="admin-panel-font-size-control">
|
||||
<input
|
||||
v-model.number="adminFontSizePreview"
|
||||
type="range"
|
||||
min="90"
|
||||
max="115"
|
||||
step="5"
|
||||
:aria-label="t('appearance.controls.fontSize')"
|
||||
@change="updateAdminFontSize"
|
||||
/>
|
||||
<strong>{{ adminFontSizePreview }}%</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="admin-panel-section-card admin-panel-section-card--compact"
|
||||
>
|
||||
<div class="admin-panel-section-card__heading">
|
||||
<div>
|
||||
<span>{{ t('audit.eyebrow') }}</span>
|
||||
<h2>{{ t('overview.recent') }}</h2>
|
||||
</div>
|
||||
<ScrollText :size="20" />
|
||||
</div>
|
||||
<div v-if="admin.audit.length" class="admin-panel-audit-grid">
|
||||
<article
|
||||
v-for="entry in admin.audit.slice(0, 4)"
|
||||
:key="entry.id"
|
||||
>
|
||||
<div class="admin-panel-audit-grid__topline">
|
||||
<span>{{ t('audit.actions.' + entry.action) }}</span>
|
||||
<time>{{ formatDate(entry.createdAt) }}</time>
|
||||
</div>
|
||||
<strong>{{ auditDescription(entry) }}</strong>
|
||||
<p>
|
||||
{{
|
||||
t('audit.by', {
|
||||
actor: entry.actorName,
|
||||
target: String(entry.targetSource ?? '—'),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="admin-panel-inline-empty">
|
||||
{{ t('audit.emptyBody') }}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -1426,6 +1668,7 @@ onBeforeUnmount(() => {
|
||||
:labels="configuratorEditorLabels"
|
||||
:disabled="!admin.configurator.enabled"
|
||||
:path="field.path"
|
||||
:tab-label="configuratorSubtabLabel"
|
||||
@update:model-value="
|
||||
updateConfiguratorField(field, $event)
|
||||
"
|
||||
@@ -1436,6 +1679,7 @@ onBeforeUnmount(() => {
|
||||
class="admin-panel-config-optional"
|
||||
>
|
||||
<input
|
||||
v-config-input-width
|
||||
type="text"
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
:value="
|
||||
@@ -1464,6 +1708,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<input
|
||||
v-else
|
||||
v-config-input-width
|
||||
:aria-label="`${field.label} ${field.path}`"
|
||||
:type="
|
||||
field.sensitive
|
||||
@@ -2253,7 +2498,10 @@ onBeforeUnmount(() => {
|
||||
padding: 2.5vh 2.5vw;
|
||||
color: var(--admin-text);
|
||||
background: transparent;
|
||||
font-family: var(--sky-font-family, Inter, sans-serif);
|
||||
font-family: var(
|
||||
--admin-font-family,
|
||||
var(--sky-font-family, Inter, sans-serif)
|
||||
);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -2267,6 +2515,7 @@ onBeforeUnmount(() => {
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.72),
|
||||
inset 0 1px rgba(255, 255, 255, 0.018);
|
||||
zoom: var(--admin-font-scale, 1);
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
@@ -2383,21 +2632,22 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.admin-panel-save {
|
||||
border: 1px solid var(--admin-border);
|
||||
border: 0;
|
||||
color: #555b55;
|
||||
background: #151715;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready {
|
||||
border-color: var(--admin-border-strong);
|
||||
color: #74d66f;
|
||||
background: #121413;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
|
||||
}
|
||||
|
||||
.admin-panel-save.is-ready:hover:not(:disabled) {
|
||||
color: color-mix(in srgb, #74d66f 82%, white);
|
||||
background: #191b1a;
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
@@ -3137,6 +3387,246 @@ button:disabled {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin-top: 8px;
|
||||
background: #0a0c0b;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #121412;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control.is-color {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control > div:first-child,
|
||||
.admin-panel-appearance-control > span:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control strong {
|
||||
color: #d9ddd9;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-panel-appearance-control small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-picker {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(230px, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-panel-color-value {
|
||||
min-width: 96px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
background: #1a1d1b;
|
||||
}
|
||||
|
||||
.admin-panel-color-value > span {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
|
||||
}
|
||||
|
||||
.admin-panel-color-value input {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #cbd0cb;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(90px, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 8px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number'] {
|
||||
width: 42px !important;
|
||||
max-width: 42px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 8px;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
|
||||
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-rgb-range::-webkit-slider-thumb {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid #f3f5f3;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-green);
|
||||
box-shadow: 0 0 0 2px #171a18;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-font-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
outline: 1px solid var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
background: #1a1d1b;
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button svg {
|
||||
color: var(--admin-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.admin-panel-font-select > button[aria-expanded='true'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.09);
|
||||
background: #101211;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
color: var(--admin-muted);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu button:hover,
|
||||
.admin-panel-font-select__menu button.is-active {
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-row-active);
|
||||
}
|
||||
|
||||
.admin-panel-font-select__menu svg {
|
||||
color: var(--admin-green);
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) 38px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control input {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
accent-color: var(--admin-green);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-panel-font-size-control > strong {
|
||||
color: var(--admin-green);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-panel-section-card__heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@@ -3725,7 +4215,7 @@ button:disabled {
|
||||
|
||||
.admin-panel-config-field > input,
|
||||
.admin-panel-config-optional > input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
@@ -3736,6 +4226,20 @@ button:disabled {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input[type='number']::-webkit-inner-spin-button,
|
||||
.admin-panel-config-field > input[type='number']::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.admin-panel-config-field > input,
|
||||
.admin-panel-config-optional > input {
|
||||
height: 31px;
|
||||
@@ -3754,15 +4258,16 @@ button:disabled {
|
||||
|
||||
.admin-panel-config-optional {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-panel-config-toggle {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
justify-self: start;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
const inputListeners = new WeakMap<HTMLInputElement, () => void>()
|
||||
const MINIMUM_INPUT_WIDTH = 42
|
||||
|
||||
function syncInputWidth(input: HTMLInputElement): void {
|
||||
input.style.width = '1px'
|
||||
input.style.width = `${Math.max(MINIMUM_INPUT_WIDTH, Math.ceil(input.scrollWidth) + 2)}px`
|
||||
}
|
||||
|
||||
export const vConfigInputWidth: Directive<HTMLInputElement> = {
|
||||
mounted(input) {
|
||||
const listener = () => syncInputWidth(input)
|
||||
inputListeners.set(input, listener)
|
||||
input.addEventListener('input', listener)
|
||||
syncInputWidth(input)
|
||||
},
|
||||
updated(input) {
|
||||
syncInputWidth(input)
|
||||
},
|
||||
beforeUnmount(input) {
|
||||
const listener = inputListeners.get(input)
|
||||
if (listener) input.removeEventListener('input', listener)
|
||||
inputListeners.delete(input)
|
||||
},
|
||||
}
|
||||
@@ -118,6 +118,29 @@ function collectLuaLocaleValues(source: string): Map<string, string> {
|
||||
}
|
||||
|
||||
parseTable([])
|
||||
while (position < tokens.length) {
|
||||
const assignment = tokens.findIndex(
|
||||
(token, index) => index >= position && token.kind === '=',
|
||||
)
|
||||
if (assignment < 0) break
|
||||
const nui = tokens.findIndex(
|
||||
(token, index) =>
|
||||
index >= position && index < assignment && token.value === 'Nui',
|
||||
)
|
||||
if (nui < 0) {
|
||||
position = assignment + 1
|
||||
continue
|
||||
}
|
||||
const path = [
|
||||
'Nui',
|
||||
...tokens
|
||||
.slice(nui + 1, assignment)
|
||||
.filter((token) => token.kind === 'word')
|
||||
.map((token) => token.value),
|
||||
]
|
||||
position = assignment + 1
|
||||
parseValue(path)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
@@ -848,8 +848,8 @@ const adminPanelFallbackLocales = {
|
||||
},
|
||||
appearance: {
|
||||
eyebrow: 'Appearance',
|
||||
title: 'Accent color',
|
||||
body: 'Change the accent across the complete admin workspace.',
|
||||
title: 'Interface',
|
||||
body: 'Personalize colors and typography across the admin workspace.',
|
||||
colors: {
|
||||
emerald: 'Emerald',
|
||||
blue: 'Blue',
|
||||
@@ -857,6 +857,29 @@ const adminPanelFallbackLocales = {
|
||||
orange: 'Orange',
|
||||
red: 'Red',
|
||||
},
|
||||
controls: {
|
||||
customColor: 'Custom color',
|
||||
customColorBody: 'Choose any RGB accent or enter its channel values.',
|
||||
red: 'R',
|
||||
green: 'G',
|
||||
blue: 'B',
|
||||
hex: 'HEX color',
|
||||
value: 'value',
|
||||
fontFamily: 'Font family',
|
||||
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
|
||||
fontSize: 'Font size',
|
||||
fontSizeBody: 'Scale text and controls for comfortable readability.',
|
||||
fonts: {
|
||||
inter: 'Inter',
|
||||
system: 'System',
|
||||
classic: 'Classic',
|
||||
verdana: 'Verdana',
|
||||
tahoma: 'Tahoma',
|
||||
trebuchet: 'Trebuchet',
|
||||
georgia: 'Georgia',
|
||||
mono: 'Monospace',
|
||||
},
|
||||
},
|
||||
},
|
||||
configurator: {
|
||||
context: 'Runtime configuration',
|
||||
@@ -919,6 +942,44 @@ const adminPanelFallbackLocales = {
|
||||
vector: 'Vector',
|
||||
entry: 'Entry',
|
||||
general: 'General',
|
||||
subtabs: {
|
||||
AdminGroups: 'Admin Groups',
|
||||
Dictionaries: 'Dictionaries',
|
||||
Clips: 'Clips',
|
||||
Transforms: 'Transforms',
|
||||
RateLimits: 'Rate Limits',
|
||||
Publishers: 'Publishers',
|
||||
ExternalPingResources: 'External Ping Resources',
|
||||
Markets: 'Markets',
|
||||
TrustedAdapters: 'Trusted Adapters',
|
||||
AllowedDisappearTimers: 'Allowed Disappear Timers',
|
||||
MusicTracks: 'Music Tracks',
|
||||
VehicleImages: 'Vehicle Images',
|
||||
Custom: 'Custom',
|
||||
Valet: 'Valet',
|
||||
AutoPriority: 'Automatic Priority',
|
||||
Camera: 'Camera',
|
||||
Categories: 'Categories',
|
||||
Districts: 'Districts',
|
||||
PhotoGradients: 'Photo Gradients',
|
||||
LbPhone: 'LB Phone',
|
||||
Tracks: 'Tracks',
|
||||
Props: 'Props',
|
||||
CustomLocations: 'Custom Locations',
|
||||
Animation: 'Animation',
|
||||
ReportReasons: 'Report Reasons',
|
||||
DisplayName: 'Display Name',
|
||||
Hud: 'HUD',
|
||||
Badge: 'Badge',
|
||||
LockedChannels: 'Locked Channels',
|
||||
NumberGroups: 'Number Groups',
|
||||
CustomFare: 'Custom Fare',
|
||||
DriverJobs: 'Driver Jobs',
|
||||
Services: 'Services',
|
||||
QuickLocations: 'Quick Locations',
|
||||
AllowedJobs: 'Allowed Jobs',
|
||||
Websites: 'Websites',
|
||||
},
|
||||
addRow: 'Add row',
|
||||
addField: 'Add field',
|
||||
remove: 'Remove',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AdminConfiguratorStructure } from '@/types/admin'
|
||||
|
||||
import {
|
||||
configuratorDescriptionKey,
|
||||
configuratorPathName,
|
||||
describeConfiguratorValue,
|
||||
} from './adminConfiguratorDescription'
|
||||
|
||||
@@ -52,4 +53,10 @@ describe('admin configurator descriptions', () => {
|
||||
describeConfiguratorValue(translate, 'Radio.AllowedJobs[2]', 'police'),
|
||||
).toBe('configurator.descriptions.access:Allowed Jobs #2')
|
||||
})
|
||||
|
||||
it('humanizes subtab keys', () => {
|
||||
expect(configuratorPathName('ExternalPingResources')).toBe(
|
||||
'External Ping Resources',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ const DESCRIPTION_RULES: Array<[RegExp, string]> = [
|
||||
],
|
||||
]
|
||||
|
||||
function pathName(path: string): string {
|
||||
export function configuratorPathName(path: string): string {
|
||||
const listEntry = path.match(/^(.*)\[(\d+)\]$/)
|
||||
const source = listEntry?.[1] ?? path
|
||||
const segment = source.split('.').at(-1) ?? source
|
||||
@@ -98,6 +98,6 @@ export function describeConfiguratorValue(
|
||||
): string {
|
||||
return translate(
|
||||
`configurator.descriptions.${configuratorDescriptionKey(path, value, structure)}`,
|
||||
{ name: label?.trim() || pathName(path) },
|
||||
{ name: label?.trim() || configuratorPathName(path) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2135,3 +2135,68 @@ Locales["de"] = {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin-Gruppen",
|
||||
Dictionaries = "Animationsdateien",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transformationen",
|
||||
RateLimits = "Ratenlimits",
|
||||
Publishers = "Herausgeber",
|
||||
ExternalPingResources = "Externe Ping-Ressourcen",
|
||||
Markets = "Märkte",
|
||||
TrustedAdapters = "Vertrauenswürdige Adapter",
|
||||
AllowedDisappearTimers = "Erlaubte Löschzeiten",
|
||||
MusicTracks = "Musiktitel",
|
||||
VehicleImages = "Fahrzeugbilder",
|
||||
Custom = "Benutzerdefiniert",
|
||||
Valet = "Parkservice",
|
||||
AutoPriority = "Automatische Priorität",
|
||||
Camera = "Kamera",
|
||||
Categories = "Kategorien",
|
||||
Districts = "Bezirke",
|
||||
PhotoGradients = "Fotoverläufe",
|
||||
LbPhone = "LB Phone",
|
||||
Tracks = "Titel",
|
||||
Props = "Props",
|
||||
CustomLocations = "Benutzerdefinierte Standorte",
|
||||
Animation = "Animation",
|
||||
ReportReasons = "Meldegründe",
|
||||
DisplayName = "Anzeigename",
|
||||
Hud = "HUD",
|
||||
Badge = "Abzeichen",
|
||||
LockedChannels = "Gesperrte Kanäle",
|
||||
NumberGroups = "Nummerngruppen",
|
||||
CustomFare = "Benutzerdefinierter Tarif",
|
||||
DriverJobs = "Fahrerjobs",
|
||||
Services = "Dienstleistungen",
|
||||
QuickLocations = "Schnellziele",
|
||||
AllowedJobs = "Erlaubte Jobs",
|
||||
Websites = "Webseiten",
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
|
||||
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
|
||||
Locales["de"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Eigene Farbe",
|
||||
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX-Farbe",
|
||||
value = "Wert",
|
||||
fontFamily = "Schriftart",
|
||||
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
|
||||
fontSize = "Schriftgröße",
|
||||
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Klassisch",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2135,3 +2135,68 @@ Locales["en"] = {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
AdminGroups = "Admin Groups",
|
||||
Dictionaries = "Dictionaries",
|
||||
Clips = "Clips",
|
||||
Transforms = "Transforms",
|
||||
RateLimits = "Rate Limits",
|
||||
Publishers = "Publishers",
|
||||
ExternalPingResources = "External Ping Resources",
|
||||
Markets = "Markets",
|
||||
TrustedAdapters = "Trusted Adapters",
|
||||
AllowedDisappearTimers = "Allowed Disappear Timers",
|
||||
MusicTracks = "Music Tracks",
|
||||
VehicleImages = "Vehicle Images",
|
||||
Custom = "Custom",
|
||||
Valet = "Valet",
|
||||
AutoPriority = "Automatic Priority",
|
||||
Camera = "Camera",
|
||||
Categories = "Categories",
|
||||
Districts = "Districts",
|
||||
PhotoGradients = "Photo Gradients",
|
||||
LbPhone = "LB Phone",
|
||||
Tracks = "Tracks",
|
||||
Props = "Props",
|
||||
CustomLocations = "Custom Locations",
|
||||
Animation = "Animation",
|
||||
ReportReasons = "Report Reasons",
|
||||
DisplayName = "Display Name",
|
||||
Hud = "HUD",
|
||||
Badge = "Badge",
|
||||
LockedChannels = "Locked Channels",
|
||||
NumberGroups = "Number Groups",
|
||||
CustomFare = "Custom Fare",
|
||||
DriverJobs = "Driver Jobs",
|
||||
Services = "Services",
|
||||
QuickLocations = "Quick Locations",
|
||||
AllowedJobs = "Allowed Jobs",
|
||||
Websites = "Websites",
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
|
||||
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
|
||||
Locales["en"].Nui.AdminPanel.appearance.controls = {
|
||||
customColor = "Custom color",
|
||||
customColorBody = "Choose any RGB accent or enter its channel values.",
|
||||
red = "R",
|
||||
green = "G",
|
||||
blue = "B",
|
||||
hex = "HEX color",
|
||||
value = "value",
|
||||
fontFamily = "Font family",
|
||||
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
|
||||
fontSize = "Font size",
|
||||
fontSizeBody = "Scale text and controls for comfortable readability.",
|
||||
fonts = {
|
||||
inter = "Inter",
|
||||
system = "System",
|
||||
classic = "Classic",
|
||||
verdana = "Verdana",
|
||||
tahoma = "Tahoma",
|
||||
trebuchet = "Trebuchet",
|
||||
georgia = "Georgia",
|
||||
mono = "Monospace",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user