mirror of
https://github.com/directus/directus.git
synced 2026-02-16 03:34:56 -05:00
Add App Translation Strings in Settings (#12170)
* add migration for translation strings * add to navigation * WIP * fix dialog overflow * update translation keys * Update logic * add placeholder to system-language * fix translation * remove unused import * reset dialog on create new * ensure search input is visible when searching * merge translation strings on set language * merge translation strings on update * hydrate * make sure null translation do not get merged * change dialog to drawer * update placeholder text * fix form value * revert dialog style change * rename drawer component * Force safe key name * Move interface to system interfaces The saved values are Directus app proprietary, so to prevent confusion in what it's supposed to do, we'll move it to system. * Move composable to root composables * Use new languages input in interface/display options * hide translation strings field in project settings * set system true to system-input-translated-string * use this in field detail notes * use in list options Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { defineInterface } from '@directus/shared/utils';
|
||||
import InterfaceInputTranslatedString from './input-translated-string.vue';
|
||||
import PreviewSVG from './preview.svg?raw';
|
||||
|
||||
export default defineInterface({
|
||||
id: 'system-input-translated-string',
|
||||
name: '$t:interfaces.input-translated-string.input-translated-string',
|
||||
description: '$t:interfaces.input-translated-string.description',
|
||||
icon: 'translate',
|
||||
component: InterfaceInputTranslatedString,
|
||||
system: true,
|
||||
types: ['string', 'text'],
|
||||
group: 'standard',
|
||||
preview: PreviewSVG,
|
||||
options: [
|
||||
{
|
||||
field: 'placeholder',
|
||||
name: '$t:placeholder',
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="input-translated-string">
|
||||
<v-menu ref="menuEl" :disabled="disabled" :close-on-content-click="false" attached>
|
||||
<template #activator="{ toggle, active }">
|
||||
<v-input
|
||||
class="translation-input"
|
||||
:model-value="localValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:active="active"
|
||||
@update:model-value="localValue = $event"
|
||||
>
|
||||
<template v-if="hasValidKey" #input>
|
||||
<button :disabled="disabled" @click.stop="setValue(null)">{{ value && getKeyWithoutPrefix(value) }}</button>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-icon
|
||||
name="translate"
|
||||
class="translate-icon"
|
||||
:class="{ active }"
|
||||
clickable
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<div v-if="searchValue !== null || translations.length >= 25" class="search">
|
||||
<v-input
|
||||
class="search-input"
|
||||
type="text"
|
||||
:model-value="searchValue"
|
||||
autofocus
|
||||
:placeholder="t('interfaces.input-translated-string.search_placeholder')"
|
||||
@update:model-value="searchValue = $event"
|
||||
>
|
||||
<template #append>
|
||||
<v-icon name="search" class="search-icon" />
|
||||
</template>
|
||||
</v-input>
|
||||
</div>
|
||||
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="translation in translations"
|
||||
:key="translation.key"
|
||||
class="translation-key"
|
||||
:class="{ selected: localValue && translation.key === localValueWithoutPrefix }"
|
||||
clickable
|
||||
@click="selectKey(translation.key!)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="translate" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content><v-highlight :text="translation.key" :query="searchValue" /></v-list-item-content>
|
||||
<v-list-item-icon class="info">
|
||||
<TranslationStringsTooltip :translations="translation.translations" hide-display-text />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
<v-list-item class="new-translation-string" clickable @click="openNewTranslationStringDialog">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="add" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('interfaces.input-translated-string.new_translation_string') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<TranslationStringsDrawer
|
||||
:model-value="isTranslationStringDialogOpen"
|
||||
:translation-string="editingTranslationString"
|
||||
@update:model-value="updateTranslationStringsDialog"
|
||||
@saved-key="setValue(`${translationPrefix}${$event}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTranslationStrings, TranslationString } from '@/composables/use-translation-strings';
|
||||
import TranslationStringsDrawer from '@/modules/settings/routes/translation-strings/translation-strings-drawer.vue';
|
||||
import TranslationStringsTooltip from '@/modules/settings/routes/translation-strings/translation-strings-tooltip.vue';
|
||||
|
||||
const translationPrefix = '$t:';
|
||||
|
||||
interface Props {
|
||||
value?: string | null;
|
||||
disabled?: boolean;
|
||||
placeholder?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { value: () => null, disabled: false, placeholder: () => null });
|
||||
|
||||
const emit = defineEmits(['input']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuEl = ref();
|
||||
const hasValidKey = ref<boolean>(false);
|
||||
const searchValue = ref<string | null>(null);
|
||||
|
||||
const { translationStrings } = useTranslationStrings();
|
||||
|
||||
const isTranslationStringDialogOpen = ref<boolean>(false);
|
||||
|
||||
const editingTranslationString = ref<TranslationString | null>(null);
|
||||
|
||||
const translations = computed(() => {
|
||||
const keys = translationStrings.value ?? [];
|
||||
|
||||
return !searchValue.value ? keys : keys.filter((key) => key.key?.includes(searchValue.value!));
|
||||
});
|
||||
|
||||
const localValue = computed<string | null>({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val) {
|
||||
if (props.value === val) return;
|
||||
emit('input', val);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newVal) => setValue(newVal),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const localValueWithoutPrefix = computed(() => (localValue.value ? getKeyWithoutPrefix(localValue.value) : null));
|
||||
|
||||
function getKeyWithoutPrefix(val: string) {
|
||||
return val.substring(translationPrefix.length);
|
||||
}
|
||||
|
||||
function selectKey(key: string) {
|
||||
setValue(`${translationPrefix}${key}`);
|
||||
menuEl.value.deactivate();
|
||||
searchValue.value = null;
|
||||
}
|
||||
|
||||
function setValue(newValue: any) {
|
||||
hasValidKey.value = false;
|
||||
|
||||
if (
|
||||
newValue &&
|
||||
newValue.startsWith(translationPrefix) &&
|
||||
translations.value.find((t) => t.key?.includes(getKeyWithoutPrefix(newValue)))
|
||||
) {
|
||||
hasValidKey.value = true;
|
||||
}
|
||||
|
||||
localValue.value = newValue;
|
||||
}
|
||||
|
||||
function openNewTranslationStringDialog() {
|
||||
menuEl.value.deactivate();
|
||||
isTranslationStringDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function updateTranslationStringsDialog(val: boolean) {
|
||||
if (val) return;
|
||||
|
||||
editingTranslationString.value = null;
|
||||
isTranslationStringDialogOpen.value = val;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.translation-input {
|
||||
:deep(button) {
|
||||
margin-right: auto;
|
||||
padding: 2px 8px 0;
|
||||
color: var(--primary);
|
||||
background-color: var(--primary-alt);
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: background-color, color;
|
||||
user-select: none;
|
||||
font-family: var(--family-monospace);
|
||||
}
|
||||
|
||||
:deep(button:not(:disabled):hover) {
|
||||
color: var(--white);
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
.translate-icon {
|
||||
&:hover,
|
||||
&.active {
|
||||
--v-icon-color-hover: var(--primary);
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 12px 8px 6px 8px;
|
||||
|
||||
.search-input {
|
||||
--input-height: 48px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-key {
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
.info :deep(.icon) {
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .info :deep(.icon) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(mark) {
|
||||
flex-basis: auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
--v-list-item-color-active: var(--foreground-inverted);
|
||||
--v-list-item-background-color-active: var(--primary);
|
||||
--v-list-item-color-hover: var(--foreground-inverted);
|
||||
--v-list-item-background-color-hover: var(--primary);
|
||||
|
||||
background-color: var(--primary);
|
||||
color: var(--foreground-inverted);
|
||||
|
||||
.v-list-item-icon {
|
||||
--v-icon-color: var(--foreground-inverted);
|
||||
}
|
||||
|
||||
.info :deep(.icon) {
|
||||
color: var(--foreground-inverted);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-translation-string {
|
||||
--v-list-item-color-hover: var(--primary-125);
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
.v-list-item-icon {
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="156" height="96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="18" y="17.037" width="120" height="62" rx="6" fill="var(--background-page)" />
|
||||
<rect x="19" y="18.037" width="118" height="60" rx="5" stroke="var(--primary)" stroke-opacity=".25"
|
||||
stroke-width="2" />
|
||||
<rect x="28" y="51" width="40" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
|
||||
<rect x="28" y="63" width="60" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
|
||||
<rect x="18" y="17" width="120" height="26" rx="6" fill="var(--background-page)" class="glow" />
|
||||
<rect x="19" y="18" width="118" height="24" rx="5" stroke="var(--primary)" stroke-width="2" />
|
||||
<rect x="28" y="27" width="70" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
|
||||
<path d="m124.52 31.78-1.5-1.45.02-.03a10 10 0 0 0 2.16-3.8h1.73v-1.18h-4.1v-1.14h-1.15v1.14h-4.1v1.18h6.53a9.34 9.34 0 0 1-1.86 3.12 9.14 9.14 0 0 1-1.34-1.94h-1.18a10.89 10.89 0 0 0 1.75 2.65l-2.98 2.92.82.82 2.93-2.9 1.8 1.81.47-1.2zm3.28-2.96h-1.17l-2.63 7h1.18l.65-1.75h2.76l.66 1.75h1.18l-2.63-7zm-1.53 4.1.93-2.54.96 2.55h-1.89z" fill="var(--primary)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,9 +1,16 @@
|
||||
<template>
|
||||
<v-select :model-value="value" :items="languages" :disabled="disabled" @update:model-value="$emit('input', $event)" />
|
||||
<v-select
|
||||
:model-value="value"
|
||||
:items="languages"
|
||||
:disabled="disabled"
|
||||
:placeholder="t('language_placeholder')"
|
||||
@update:model-value="$emit('input', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import availableLanguages from '@/lang/available-languages.yaml';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -19,12 +26,14 @@ export default defineComponent({
|
||||
},
|
||||
emits: ['input'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const languages = Object.entries(availableLanguages).map(([key, value]) => ({
|
||||
text: value,
|
||||
value: key,
|
||||
}));
|
||||
|
||||
return { languages };
|
||||
return { t, languages };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.boolean.label_placeholder',
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ export default defineInterface({
|
||||
name: '$t:placeholder',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input-multiline',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -92,7 +92,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineInterface({
|
||||
name: '$t:placeholder',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
@@ -178,7 +178,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
<div class="grid-element half">
|
||||
<p class="type-label">{{ t('interfaces.list.add_label') }}</p>
|
||||
<v-input v-model="addLabel" class="input" :placeholder="t('create_new')" />
|
||||
<interface-system-input-translated-string
|
||||
:value="addLabel"
|
||||
class="input"
|
||||
:placeholder="t('create_new')"
|
||||
@input="addLabel = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid-element full">
|
||||
@@ -135,7 +140,7 @@ export default defineComponent({
|
||||
field: 'note',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'full',
|
||||
sort: 6,
|
||||
options: {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.presentation-divider.title_placeholder',
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineInterface({
|
||||
name: '$t:label',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:label',
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input-multiline',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.presentation-notice.text',
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
name: '$t:name',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.select-color.name_placeholder',
|
||||
|
||||
@@ -97,7 +97,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const repeaterFields: DeepPartial<Field>[] = [
|
||||
name: '$t:text',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineInterface({
|
||||
name: '$t:text',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineInterface({
|
||||
name: '$t:text',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:interfaces.select-dropdown.choices_name_placeholder',
|
||||
},
|
||||
@@ -88,7 +88,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineInterface({
|
||||
name: '$t:text',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineInterface({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'full',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
placeholder: '$t:enter_a_placeholder',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user