mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04: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,13 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_settings', (table) => {
|
||||
table.json('translation_strings');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_settings', (table) => {
|
||||
table.dropColumn('translation_strings');
|
||||
});
|
||||
}
|
||||
@@ -378,3 +378,6 @@ fields:
|
||||
interface: input
|
||||
options:
|
||||
placeholder: $t:fields.directus_settings.attribution_placeholder
|
||||
|
||||
- field: translation_strings
|
||||
hidden: true
|
||||
|
||||
151
app/src/composables/use-translation-strings.ts
Normal file
151
app/src/composables/use-translation-strings.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
import api from '@/api';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { Language, i18n } from '@/lang';
|
||||
import { useUserStore } from '@/stores';
|
||||
|
||||
export type Translation = {
|
||||
language: string;
|
||||
translation: string;
|
||||
};
|
||||
|
||||
export type TranslationStringRaw = {
|
||||
key?: string | null;
|
||||
translations?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type TranslationString = {
|
||||
key?: string | null;
|
||||
translations?: Translation[] | null;
|
||||
};
|
||||
|
||||
type UsableTranslationStrings = {
|
||||
loading: Ref<boolean>;
|
||||
error: Ref<any>;
|
||||
translationStrings: Ref<TranslationString[] | null>;
|
||||
refresh: () => Promise<void>;
|
||||
updating: Ref<boolean>;
|
||||
update: (newTranslationStrings: TranslationString[]) => Promise<void>;
|
||||
mergeTranslationStringsForLanguage: (lang: Language) => void;
|
||||
};
|
||||
|
||||
let loading: Ref<boolean> | null = null;
|
||||
let translationStrings: Ref<TranslationString[] | null> | null = null;
|
||||
let error: Ref<any> | null = null;
|
||||
|
||||
export function useTranslationStrings(): UsableTranslationStrings {
|
||||
if (loading === null) loading = ref(false);
|
||||
if (error === null) error = ref(null);
|
||||
if (translationStrings === null) translationStrings = ref<TranslationString[] | null>(null);
|
||||
const updating = ref(false);
|
||||
|
||||
return { loading, error, translationStrings, refresh, updating, update, mergeTranslationStringsForLanguage };
|
||||
|
||||
async function fetchTranslationStrings() {
|
||||
if (loading === null) return;
|
||||
if (translationStrings === null) return;
|
||||
if (error === null) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/settings', {
|
||||
params: {
|
||||
fields: ['translation_strings'],
|
||||
},
|
||||
});
|
||||
|
||||
const { translation_strings } = response.data.data;
|
||||
|
||||
if (translation_strings) {
|
||||
translationStrings.value = translation_strings.map((p: TranslationStringRaw) => ({
|
||||
key: p.key,
|
||||
translations: getTranslationsFromKeyValues(p.translations ?? null),
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (loading === null) return;
|
||||
if (translationStrings === null) return;
|
||||
if (error === null) return;
|
||||
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
|
||||
await fetchTranslationStrings();
|
||||
}
|
||||
|
||||
async function update(newTranslationStrings: TranslationString[]) {
|
||||
if (loading === null) return;
|
||||
if (translationStrings === null) return;
|
||||
if (error === null) return;
|
||||
|
||||
updating.value = true;
|
||||
|
||||
const resultingTranslationStrings = getUniqueTranslationStrings([...newTranslationStrings]);
|
||||
|
||||
const payload = resultingTranslationStrings.map((p: TranslationString) => ({
|
||||
key: p.key,
|
||||
translations: getKeyValuesFromTranslations(p.translations),
|
||||
}));
|
||||
|
||||
try {
|
||||
const settingsResponse = await api.patch('/settings', {
|
||||
translation_strings: payload,
|
||||
});
|
||||
if (settingsResponse.data.data.translation_strings) {
|
||||
translationStrings.value = settingsResponse.data.data.translation_strings.map((p: TranslationStringRaw) => ({
|
||||
key: p.key,
|
||||
translations: getTranslationsFromKeyValues(p.translations ?? null),
|
||||
}));
|
||||
|
||||
const { currentUser } = useUserStore();
|
||||
if (currentUser && 'language' in currentUser && currentUser.language) {
|
||||
mergeTranslationStringsForLanguage(currentUser.language);
|
||||
} else {
|
||||
mergeTranslationStringsForLanguage('en-US');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeTranslationStringsForLanguage(lang: Language) {
|
||||
if (!translationStrings?.value) return;
|
||||
const localeMessages: Record<string, any> = translationStrings.value.reduce((acc, cur) => {
|
||||
if (!cur.key || !cur.translations) return acc;
|
||||
const translationForCurrentLang = cur.translations.find((t) => t.language === lang);
|
||||
if (!translationForCurrentLang || !translationForCurrentLang.translation) return acc;
|
||||
return { ...acc, [cur.key]: translationForCurrentLang.translation };
|
||||
}, {});
|
||||
i18n.global.mergeLocaleMessage(lang, localeMessages);
|
||||
}
|
||||
|
||||
function getUniqueTranslationStrings(arr: TranslationString[]): TranslationString[] {
|
||||
return [...new Map(arr.map((item: TranslationString) => [item.key, item])).values()];
|
||||
}
|
||||
|
||||
function getKeyValuesFromTranslations(val: TranslationString['translations'] | null): Record<string, string> {
|
||||
if (!val || (val && val.length === 0)) return {};
|
||||
|
||||
return val.reduce((acc, cur) => {
|
||||
return { ...acc, [cur.language]: cur.translation };
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getTranslationsFromKeyValues(val: Record<string, string> | null): TranslationString['translations'] {
|
||||
if (!val || Object.keys(val).length === 0) return [];
|
||||
|
||||
return Object.entries(val).map(([k, v]) => ({ language: k, translation: v }));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default defineDisplay({
|
||||
name: '$t:displays.boolean.label_on',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:displays.boolean.label_on_placeholder',
|
||||
@@ -26,7 +26,7 @@ export default defineDisplay({
|
||||
name: '$t:displays.boolean.label_off',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:displays.boolean.label_off_placeholder',
|
||||
|
||||
@@ -84,7 +84,7 @@ export default defineDisplay({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
label: '$t:displays.formatted-value.prefix_label',
|
||||
trim: false,
|
||||
@@ -97,7 +97,7 @@ export default defineDisplay({
|
||||
type: 'string',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
options: {
|
||||
label: '$t:displays.formatted-value.suffix_label',
|
||||
trim: false,
|
||||
@@ -209,7 +209,7 @@ export default defineDisplay({
|
||||
name: '$t:displays.formatted-value.text',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:displays.formatted-value.text_placeholder',
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineDisplay({
|
||||
name: '$t:text',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
interface: 'system-input-translated-string',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:displays.labels.choices_text_placeholder',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useUserStore,
|
||||
useNotificationsStore,
|
||||
} from '@/stores';
|
||||
import { useTranslationStrings } from '@/composables/use-translation-strings';
|
||||
|
||||
type GenericStore = {
|
||||
$id: string;
|
||||
@@ -50,6 +51,7 @@ export async function hydrate(): Promise<void> {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
const { refresh: hydrateTranslationStrings } = useTranslationStrings();
|
||||
|
||||
if (appStore.hydrated) return;
|
||||
if (appStore.hydrating) return;
|
||||
@@ -71,6 +73,7 @@ export async function hydrate(): Promise<void> {
|
||||
|
||||
await Promise.all(stores.filter(({ $id }) => !hydratedStores.includes($id)).map((store) => store.hydrate?.()));
|
||||
await registerModules();
|
||||
await hydrateTranslationStrings();
|
||||
|
||||
await setLanguage(userStore.currentUser?.language ?? 'en-US');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCollectionsStore, useFieldsStore } from '@/stores';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import availableLanguages from './available-languages.yaml';
|
||||
import { i18n, Language, loadedLanguages } from './index';
|
||||
import { useTranslationStrings } from '@/composables/use-translation-strings';
|
||||
|
||||
const { modules, modulesRaw } = getModules();
|
||||
const { layouts, layoutsRaw } = getLayouts();
|
||||
@@ -17,6 +18,7 @@ const { displays, displaysRaw } = getDisplays();
|
||||
export async function setLanguage(lang: Language): Promise<boolean> {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const fieldsStore = useFieldsStore();
|
||||
const { mergeTranslationStringsForLanguage } = useTranslationStrings();
|
||||
|
||||
if (Object.keys(availableLanguages).includes(lang) === false) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -46,6 +48,7 @@ export async function setLanguage(lang: Language): Promise<boolean> {
|
||||
|
||||
collectionsStore.translateCollections();
|
||||
fieldsStore.translateFields();
|
||||
mergeTranslationStringsForLanguage(lang);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ create_role: Create Role
|
||||
create_user: Create User
|
||||
delete_panel: Delete Panel
|
||||
create_webhook: Create Webhook
|
||||
create_translation_string: Create Translation String
|
||||
translation_string_key_placeholder: Translation string key...
|
||||
translation_string_translations_placeholder: Add a new translation
|
||||
edit_translation_string: Edit Translation String
|
||||
delete_translation_string_copy: >-
|
||||
Are you sure you want to delete the translation string "{key}"? This action can not be undone.
|
||||
invite_users: Invite Users
|
||||
invite: Invite
|
||||
email_already_invited: Email "{email}" has already been invited
|
||||
@@ -180,6 +186,7 @@ field_create_success: 'Created Field: "{field}"'
|
||||
field_update_success: 'Updated Field: "{field}"'
|
||||
duplicate_where_to: Where would you like to duplicate this field to?
|
||||
language: Language
|
||||
language_placeholder: Select a language...
|
||||
aggregate_function: Aggregate Function
|
||||
aggregate_precision: Aggregate Precision
|
||||
group_aggregation: Group Aggregation
|
||||
@@ -828,6 +835,7 @@ settings_permissions: Roles & Permissions
|
||||
settings_project: Project Settings
|
||||
settings_webhooks: Webhooks
|
||||
settings_presets: Presets & Bookmarks
|
||||
settings_translation_strings: Translation Strings
|
||||
one_or_more_options_are_missing: One or more options are missing
|
||||
scope: Scope
|
||||
select: Select...
|
||||
@@ -878,6 +886,7 @@ page_help_settings_presets_item: >-
|
||||
**Preset Detail** — A form for managing bookmarks and default collection presets.
|
||||
page_help_settings_webhooks_collection: '**Browse Webhooks** — Lists all webhooks within the project.'
|
||||
page_help_settings_webhooks_item: '**Webhook Detail** — A form for creating and managing project webhooks.'
|
||||
page_help_settings_translation_strings_collection: '**Browse Translation Strings** — Lists all translation strings within the project.'
|
||||
page_help_users_collection: '**User Directory** — Lists all system users within this project.'
|
||||
page_help_users_item: >-
|
||||
**User Detail** — Manage your account information, or view the details of other users.
|
||||
@@ -1534,6 +1543,11 @@ interfaces:
|
||||
input-multiline:
|
||||
textarea: Textarea
|
||||
description: Enter multiline plain-text
|
||||
input-translated-string:
|
||||
input-translated-string: Translated Strings
|
||||
description: Select or add translation string
|
||||
search_placeholder: Search...
|
||||
new_translation_string: New Translation String
|
||||
boolean:
|
||||
toggle: Toggle
|
||||
description: Switch between on and off
|
||||
|
||||
@@ -58,6 +58,11 @@ export default defineComponent({
|
||||
name: t('settings_presets'),
|
||||
to: `/settings/presets`,
|
||||
},
|
||||
{
|
||||
icon: 'translate',
|
||||
name: t('settings_translation_strings'),
|
||||
to: `/settings/translation-strings`,
|
||||
},
|
||||
{
|
||||
icon: 'anchor',
|
||||
name: t('settings_webhooks'),
|
||||
|
||||
@@ -17,6 +17,7 @@ import RolesPermissionsDetail from './routes/roles/permissions-detail/permission
|
||||
import RolesPublicItem from './routes/roles/public-item.vue';
|
||||
import WebhooksCollection from './routes/webhooks/collection.vue';
|
||||
import WebhooksItem from './routes/webhooks/item.vue';
|
||||
import TranslationStringsCollection from './routes/translation-strings/collection.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'settings',
|
||||
@@ -175,6 +176,17 @@ export default defineModule({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'translation-strings',
|
||||
component: RouterPass,
|
||||
children: [
|
||||
{
|
||||
name: 'settings-translation-strings-collection',
|
||||
path: '',
|
||||
component: TranslationStringsCollection,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'settings-not-found',
|
||||
path: ':_(.+)+',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<div v-if="type !== 'group'" class="field full">
|
||||
<div class="label type-label">{{ t('note') }}</div>
|
||||
<v-input v-model="note" :placeholder="t('add_note')" />
|
||||
<interface-system-input-translated-string :value="note" :placeholder="t('add_note')" @input="note = $event" />
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
@@ -72,20 +72,15 @@ import { storeToRefs } from 'pinia';
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const fieldDetailStore = useFieldDetailStore();
|
||||
|
||||
const readonly = syncFieldDetailStoreProperty('field.meta.readonly', false);
|
||||
const hidden = syncFieldDetailStoreProperty('field.meta.hidden', false);
|
||||
const required = syncFieldDetailStoreProperty('field.meta.required', false);
|
||||
const note = syncFieldDetailStoreProperty('field.meta.note');
|
||||
const translations = syncFieldDetailStoreProperty('field.meta.translations');
|
||||
|
||||
const { field } = storeToRefs(fieldDetailStore);
|
||||
|
||||
const type = computed(() => field.value.type);
|
||||
const isGenerated = computed(() => field.value.schema?.is_generated);
|
||||
|
||||
return { t, readonly, hidden, required, note, translations, type, isGenerated };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<private-view :title="t('settings_translation_strings')">
|
||||
<template #headline><v-breadcrumb :items="[{ name: t('settings'), to: '/settings' }]" /></template>
|
||||
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded disabled icon secondary>
|
||||
<v-icon name="translate" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('create_translation_string')" rounded icon @click="openTranslationStringDialog">
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<settings-navigation />
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_settings_translation_strings_collection')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
</template>
|
||||
|
||||
<div class="translation-strings">
|
||||
<v-table
|
||||
:headers="tableHeaders"
|
||||
fixed-header
|
||||
item-key="key"
|
||||
:items="tableItems"
|
||||
:loading="loading"
|
||||
@click:row="openTranslationStringDialog"
|
||||
>
|
||||
<template #[`item.key`]="{ item }">
|
||||
<span class="key">
|
||||
{{ item.key }}
|
||||
</span>
|
||||
</template>
|
||||
<template #[`item.translations`]="{ item }">
|
||||
<TranslationStringsTooltip :translations="item.translations" />
|
||||
</template>
|
||||
</v-table>
|
||||
</div>
|
||||
|
||||
<TranslationStringsDrawer
|
||||
:model-value="isTranslationStringDialogOpen"
|
||||
:translation-string="editingTranslationString"
|
||||
@update:model-value="updateTranslationStringsDialog"
|
||||
/>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HeaderRaw as TableHeader } from '@/components/v-table/types';
|
||||
import SettingsNavigation from '../../components/navigation.vue';
|
||||
import { TranslationString, useTranslationStrings } from '@/composables/use-translation-strings';
|
||||
import TranslationStringsDrawer from './translation-strings-drawer.vue';
|
||||
import TranslationStringsTooltip from './translation-strings-tooltip.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tableHeaders: TableHeader[] = [
|
||||
{
|
||||
text: t('key'),
|
||||
value: 'key',
|
||||
sortable: false,
|
||||
width: 250,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
text: t('translations'),
|
||||
value: 'translations',
|
||||
sortable: false,
|
||||
width: 800,
|
||||
align: 'left',
|
||||
},
|
||||
];
|
||||
|
||||
const isTranslationStringDialogOpen = ref<boolean>(false);
|
||||
|
||||
const editingTranslationString = ref<TranslationString | null>(null);
|
||||
|
||||
const { loading, translationStrings } = useTranslationStrings();
|
||||
|
||||
const tableItems = computed(() => (translationStrings.value ? translationStrings.value : []));
|
||||
|
||||
function openTranslationStringDialog({ item }: { item?: TranslationString }) {
|
||||
editingTranslationString.value = item ? item : null;
|
||||
isTranslationStringDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function updateTranslationStringsDialog(val: boolean) {
|
||||
if (val) return;
|
||||
|
||||
editingTranslationString.value = null;
|
||||
isTranslationStringDialogOpen.value = val;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--primary);
|
||||
--v-button-background-color-disabled: var(--primary-10);
|
||||
}
|
||||
|
||||
.translation-strings {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
--input-height: 44px;
|
||||
|
||||
:deep(.input) {
|
||||
border-radius: 22px !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
width: 300px;
|
||||
border-color: var(--border-normal);
|
||||
|
||||
.icon-empty {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.key {
|
||||
font-family: var(--family-monospace);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
:title="translationString ? t('edit_translation_string') : t('create_translation_string')"
|
||||
icon="translate"
|
||||
:model-value="modelValue"
|
||||
@update:modelValue="closeDialog"
|
||||
@cancel="closeDialog"
|
||||
>
|
||||
<template #actions>
|
||||
<v-dialog v-if="translationString" v-model="confirmDelete" @esc="confirmDelete = false">
|
||||
<template #activator="{ on }">
|
||||
<v-button class="delete-action" rounded icon secondary @click="on">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ t('delete_translation_string_copy', { key: values.key }) }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="confirmDelete = false">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button kind="danger" :loading="updating" @click="deleteCurrentTranslationString">
|
||||
{{ t('delete_label') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-button
|
||||
v-tooltip.bottom="t('save')"
|
||||
:loading="updating"
|
||||
:disabled="!values.key || !values.translations || isEqual(values, initialValues)"
|
||||
icon
|
||||
rounded
|
||||
@click="saveNewTranslationString"
|
||||
>
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<div class="drawer-content">
|
||||
<v-form v-model="formValues" :autofocus="!translationString" :initial-values="initialValues" :fields="fields" />
|
||||
</div>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Field, DeepPartial } from '@directus/shared/types';
|
||||
import { useTranslationStrings, TranslationString } from '@/composables/use-translation-strings';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
translationString?: TranslationString | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { modelValue: false, translationString: () => null });
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'savedKey']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const confirmDelete = ref<boolean>(false);
|
||||
|
||||
const values = ref<TranslationString>({
|
||||
key: null,
|
||||
translations: null,
|
||||
});
|
||||
|
||||
const formValues = computed<TranslationString>({
|
||||
get() {
|
||||
return values.value;
|
||||
},
|
||||
set(val) {
|
||||
values.value.key = val.key;
|
||||
|
||||
if (!val.translations) {
|
||||
values.value.translations = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure translations are unique, and new ones will rewrite existing ones
|
||||
values.value.translations = [...new Map(val.translations.map((item) => [item.language, item])).values()];
|
||||
},
|
||||
});
|
||||
|
||||
const initialValues = ref<TranslationString>({
|
||||
key: null,
|
||||
translations: null,
|
||||
});
|
||||
|
||||
const fields = computed<DeepPartial<Field>[]>(() => {
|
||||
return [
|
||||
{
|
||||
field: 'key',
|
||||
name: '$t:key',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
width: 'full',
|
||||
required: true,
|
||||
options: {
|
||||
placeholder: '$t:translation_string_key_placeholder',
|
||||
font: 'monospace',
|
||||
dbSafe: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'translations',
|
||||
name: '$t:translations',
|
||||
type: 'json',
|
||||
meta: {
|
||||
interface: 'list',
|
||||
width: 'full',
|
||||
required: true,
|
||||
options: {
|
||||
placeholder: '$t:translation_string_translations_placeholder',
|
||||
template: '{{ language }} {{ translation }}',
|
||||
fields: [
|
||||
{
|
||||
field: 'language',
|
||||
name: '$t:language',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'system-language',
|
||||
width: 'half',
|
||||
display: 'formatted-value',
|
||||
display_options: {
|
||||
font: 'monospace',
|
||||
color: 'var(--primary)',
|
||||
background: 'var(--primary-alt)',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'translation',
|
||||
name: '$t:translation',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
width: 'half',
|
||||
options: {
|
||||
placeholder: '$t:field_options.directus_collections.translation_placeholder',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const { translationStrings, updating, update } = useTranslationStrings();
|
||||
|
||||
watch(
|
||||
() => props.translationString,
|
||||
(newVal: TranslationString) => {
|
||||
values.value.key = newVal?.key ?? null;
|
||||
values.value.translations = newVal?.translations ?? null;
|
||||
initialValues.value.key = newVal?.key ?? null;
|
||||
initialValues.value.translations = newVal?.translations ?? null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function closeDialog() {
|
||||
values.value.key = null;
|
||||
values.value.translations = null;
|
||||
initialValues.value.key = null;
|
||||
initialValues.value.translations = null;
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
async function saveNewTranslationString() {
|
||||
const newTranslationStrings = translationStrings.value ? [...translationStrings.value, values.value] : [values.value];
|
||||
try {
|
||||
await update(newTranslationStrings);
|
||||
emit('savedKey', values.value.key);
|
||||
closeDialog();
|
||||
} catch {
|
||||
// Update shows unexpected error dialog
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentTranslationString() {
|
||||
const newTranslationStrings = translationStrings.value
|
||||
? translationStrings.value.filter((val) => val.key !== values.value.key)
|
||||
: [];
|
||||
try {
|
||||
await update(newTranslationStrings);
|
||||
confirmDelete.value = false;
|
||||
closeDialog();
|
||||
} catch {
|
||||
// Update shows unexpected error dialog
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-content {
|
||||
padding: var(--content-padding);
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
.v-button.delete-action {
|
||||
--v-button-background-color-hover: var(--danger);
|
||||
--v-button-color-hover: var(--foreground-inverted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<ValueNull v-if="!displayedTranslation && !hideDisplayText" />
|
||||
<div v-else class="translation-strings-display">
|
||||
<span v-if="!hideDisplayText" class="translation-display-text">{{ displayedTranslation.translation }}</span>
|
||||
<v-menu class="menu" show-arrow :disabled="!translations || translations.length === 0">
|
||||
<template #activator="{ toggle, deactivate, active }">
|
||||
<v-icon
|
||||
v-tooltip.bottom="translations && translations.length === 0 && t('no_translations')"
|
||||
:small="!hideDisplayText"
|
||||
class="icon"
|
||||
:class="{ active }"
|
||||
name="info"
|
||||
tabindex="-1"
|
||||
@click.stop="toggle"
|
||||
@blur="deactivate"
|
||||
></v-icon>
|
||||
</template>
|
||||
|
||||
<v-list class="translations">
|
||||
<v-list-item v-for="item in translations" :key="item.language">
|
||||
<v-list-item-content>
|
||||
<div class="header">
|
||||
<div class="lang">
|
||||
<v-icon name="translate" small />
|
||||
{{ item.language }}
|
||||
</div>
|
||||
</div>
|
||||
<ValueNull v-if="!item.translation" />
|
||||
<div v-else class="translation-item-text">{{ item.translation }}</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUserStore } from '@/stores';
|
||||
import ValueNull from '@/views/private/components/value-null';
|
||||
import { TranslationString } from '@/composables/use-translation-strings';
|
||||
|
||||
interface Props {
|
||||
translations?: TranslationString['translations'];
|
||||
hideDisplayText?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { translations: () => null, hideDisplayText: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { currentUser } = useUserStore();
|
||||
|
||||
const displayedTranslation = computed(() => {
|
||||
if (!props.translations || props.translations.length === 0) return null;
|
||||
|
||||
if (currentUser && 'id' in currentUser) {
|
||||
return props.translations.find((val) => val.language === currentUser.language) ?? props.translations[0];
|
||||
}
|
||||
|
||||
return props.translations[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list {
|
||||
width: 300px;
|
||||
}
|
||||
.translation-strings-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
color: var(--foreground-subdued);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
&:hover .icon,
|
||||
.icon.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.translation-display-text {
|
||||
margin-right: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.translation-item-text {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.translation-display-text,
|
||||
.translation-item-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--foreground-subdued);
|
||||
font-size: 12px;
|
||||
|
||||
.lang {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.v-progress-linear {
|
||||
flex: 1;
|
||||
width: unset;
|
||||
max-width: 100px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list-item-content {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.v-list-item:not(:first-child) {
|
||||
.header {
|
||||
padding-top: 8px;
|
||||
border-top: var(--border-width) solid var(--border-subdued);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -44,4 +44,5 @@ export type Settings = {
|
||||
basemaps: any[] | null;
|
||||
mapbox_key: string | null;
|
||||
module_bar: (SettingsModuleBarLink | SettingsModuleBarModule)[];
|
||||
translation_strings: Record<string, any>[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user