ENH - refine admin configurator controls

This commit is contained in:
Leon.Schmidt
2026-08-21 04:35:00 +02:00
parent 219d523edf
commit 1949a37006
11 changed files with 895 additions and 65 deletions
+17
View File
@@ -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(')
+560 -55
View File
@@ -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
}
+63 -2
View File
@@ -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) },
)
}
+65
View File
@@ -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",
},
}
+65
View File
@@ -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",
},
}