mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
use the configuration languages for all Title Models, Suggested Prompts and Banners. So this is now ready for translations globally
This commit is contained in:
parent
cb86c01fb9
commit
fa33c94944
7 changed files with 380 additions and 161 deletions
|
|
@ -233,6 +233,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||||
if "filterIds" in meta:
|
if "filterIds" in meta:
|
||||||
filter_ids.extend(meta["filterIds"])
|
filter_ids.extend(meta["filterIds"])
|
||||||
|
|
||||||
|
custom_model.name = translate_model_title(custom_model.name, request.headers.get("X-Language"))
|
||||||
models.append(
|
models.append(
|
||||||
{
|
{
|
||||||
"id": f"{custom_model.id}",
|
"id": f"{custom_model.id}",
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: '',
|
type: '',
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: JSON.stringify({ de: '', en: '', fr: '', it: '' }),
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,58 +4,148 @@
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
||||||
import XMark from '$lib/components/icons/XMark.svelte';
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
|
import { config } from '$lib/stores';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
export let banners = [];
|
|
||||||
|
|
||||||
let sortable = null;
|
export let banners: any[] = [];
|
||||||
let bannerListElement = null;
|
|
||||||
|
|
||||||
const positionChangeHandler = () => {
|
let sortable: any = null;
|
||||||
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
|
let bannerListElement: HTMLDivElement | null = null;
|
||||||
child.id.replace('banner-item-', '')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort the banners array based on the new order
|
// dynamic languages from config
|
||||||
banners = bannerIdOrder.map((id) => {
|
$: LANGS = Array.isArray($config.features.translation_languages)
|
||||||
const index = banners.findIndex((banner) => banner.id === id);
|
? $config.features.translation_languages
|
||||||
return banners[index];
|
: ['de']; // fallback if missing
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const classNames: Record<string, string> = {
|
// reactive UI language code
|
||||||
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
|
$: langCode = $i18n?.language?.split('-')[0] || 'de';
|
||||||
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
|
|
||||||
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
|
|
||||||
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// contentObjs stores parsed content objects keyed by banner.id
|
||||||
|
let contentObjs: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
// Initialize/Sync contentObjs with banners
|
||||||
$: if (banners) {
|
$: if (banners) {
|
||||||
init();
|
for (const b of banners) {
|
||||||
|
if (!b) continue;
|
||||||
|
const id = b.id ?? (b.id = crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
||||||
|
if (!contentObjs[id]) {
|
||||||
|
contentObjs[id] = parseContentToObj(b.content);
|
||||||
|
} else {
|
||||||
|
const currentString = safeStringify(contentObjs[id]);
|
||||||
|
const incomingString = typeof b.content === 'string'
|
||||||
|
? b.content
|
||||||
|
: safeStringify(parseContentToObj(b.content));
|
||||||
|
if (incomingString !== currentString) {
|
||||||
|
contentObjs[id] = parseContentToObj(b.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = () => {
|
// remove entries for banners that no longer exist
|
||||||
if (sortable) {
|
const ids = new Set(banners.map((b) => b.id));
|
||||||
sortable.destroy();
|
for (const key of Object.keys(contentObjs)) {
|
||||||
|
if (!ids.has(key)) delete contentObjs[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initSortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentToObj(content: any) {
|
||||||
|
let parsed: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
|
||||||
|
} catch {
|
||||||
|
parsed = { de: content || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure all languages from config exist
|
||||||
|
for (const lang of LANGS) {
|
||||||
|
if (parsed[lang] == null) parsed[lang] = '';
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStringify(obj: any) {
|
||||||
|
try { return JSON.stringify(obj); }
|
||||||
|
catch { return '{}'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortable() {
|
||||||
|
if (sortable) { try { sortable.destroy(); } catch {} sortable = null; }
|
||||||
if (bannerListElement) {
|
if (bannerListElement) {
|
||||||
sortable = new Sortable(bannerListElement, {
|
sortable = Sortable.create(bannerListElement, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
handle: '.item-handle',
|
handle: '.item-handle',
|
||||||
onUpdate: async (event) => {
|
onUpdate: () => {
|
||||||
positionChangeHandler();
|
const order = Array.from(bannerListElement!.children)
|
||||||
|
.map(ch => (ch as HTMLElement).id.replace('banner-item-', ''));
|
||||||
|
banners = order.map(id => banners.find(b => b.id === id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function handleInlineChange(e: any, idx: number, id: string) {
|
||||||
|
const val = e?.detail ?? e?.target?.value ?? '';
|
||||||
|
if (!contentObjs[id]) contentObjs[id] = Object.fromEntries(LANGS.map(l => [l, '']));
|
||||||
|
contentObjs[id][langCode] = val;
|
||||||
|
contentObjs = { ...contentObjs };
|
||||||
|
banners[idx].content = safeStringify(contentObjs[id]);
|
||||||
|
banners = [...banners];
|
||||||
|
}
|
||||||
|
|
||||||
|
let showBannerModal = false;
|
||||||
|
let editingBannerIndex: number | null = null;
|
||||||
|
let newBanner: { id: string; content: Record<string, string> } = { id: '', content: {} };
|
||||||
|
|
||||||
|
function openEditModal(idx: number) {
|
||||||
|
editingBannerIndex = idx;
|
||||||
|
const b = banners[idx];
|
||||||
|
if (!b) return;
|
||||||
|
const id = b.id;
|
||||||
|
if (!contentObjs[id]) contentObjs[id] = parseContentToObj(b.content);
|
||||||
|
newBanner = { id, content: { ...contentObjs[id] } };
|
||||||
|
showBannerModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showBannerModal = false;
|
||||||
|
editingBannerIndex = null;
|
||||||
|
newBanner = { id: '', content: Object.fromEntries(LANGS.map(l => [l, ''])) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModal() {
|
||||||
|
const any = Object.values(newBanner.content).some(v => v && v.trim() !== '');
|
||||||
|
if (!any) {
|
||||||
|
alert('At least one translation is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newBanner.content.de?.trim()) {
|
||||||
|
const first = Object.values(newBanner.content).find(v => v.trim() !== '');
|
||||||
|
if (first) newBanner.content.de = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingBannerIndex != null) {
|
||||||
|
banners[editingBannerIndex].content = safeStringify(newBanner.content);
|
||||||
|
contentObjs[newBanner.id] = { ...newBanner.content };
|
||||||
|
contentObjs = { ...contentObjs };
|
||||||
|
banners = [...banners];
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Draggable banners -->
|
||||||
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
|
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
|
||||||
{#each banners as banner, bannerIdx (banner.id)}
|
{#each banners as banner, bannerIdx (banner.id)}
|
||||||
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
|
<div class="flex justify-between items-start -ml-1" id={"banner-item-" + banner.id}>
|
||||||
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
||||||
|
|
||||||
<div class="flex flex-row flex-1 gap-2 items-start">
|
<div class="flex flex-row flex-1 gap-2 items-start">
|
||||||
|
|
@ -76,27 +166,63 @@
|
||||||
<Textarea
|
<Textarea
|
||||||
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
|
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
|
||||||
placeholder={$i18n.t('Content')}
|
placeholder={$i18n.t('Content')}
|
||||||
bind:value={banner.content}
|
value={(contentObjs[banner.id]?.[langCode]) ?? ''}
|
||||||
|
on:input={(e) => handleInlineChange(e, bannerIdx, banner.id)}
|
||||||
maxSize={100}
|
maxSize={100}
|
||||||
|
readonly
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative -left-2">
|
<div class="relative -left-2">
|
||||||
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
|
<Tooltip content={$i18n.t('Remember Dismissal')} class="flex h-fit items-center">
|
||||||
<Switch bind:state={banner.dismissible} />
|
<Switch bind:state={banner.dismissible} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button class="p-1 text-gray-500 hover:text-yellow-600" type="button" on:click={() => openEditModal(bannerIdx)} title={$i18n.t('Edit')}>
|
||||||
class="pr-3"
|
<PencilSolid />
|
||||||
type="button"
|
</button>
|
||||||
on:click={() => {
|
|
||||||
banners.splice(bannerIdx, 1);
|
<button class="pr-3" type="button" on:click={() => { banners.splice(bannerIdx, 1); banners = banners; }}>
|
||||||
banners = banners;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XMark className={'size-4'} />
|
<XMark className={'size-4'} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
{#if showBannerModal}
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
||||||
|
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
|
||||||
|
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Translations')}</h2>
|
||||||
|
<button class="text-xs px-2 py-1" on:click={closeModal}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each LANGS as lang}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
||||||
|
<Textarea
|
||||||
|
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
|
||||||
|
bind:value={newBanner.content[lang]}
|
||||||
|
placeholder={`Enter ${lang.toUpperCase()} content`}
|
||||||
|
maxSize={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2 mt-3">
|
||||||
|
<button
|
||||||
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
|
on:click={saveModal}
|
||||||
|
>
|
||||||
|
{$i18n.t('Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
import { getLangCode, getTranslatedLabel } from '$lib/i18n';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
|
|
||||||
console.log('Banner mounted:', banner);
|
console.log('Banner mounted:', banner);
|
||||||
});
|
});
|
||||||
|
$: langCode = getLangCode($i18n.language, 'en');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !dismissed}
|
{#if !dismissed}
|
||||||
|
|
@ -100,7 +102,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
|
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
|
||||||
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
|
{@html marked.parse(DOMPurify.sanitize((getTranslatedLabel(banner?.content, langCode) ?? '').replace(/\n/g, '<br>')))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, getContext, tick } from 'svelte';
|
import { onMount, getContext, tick } from 'svelte';
|
||||||
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores';
|
import { models, tools, functions, knowledge as knowledgeCollections, user, config } from '$lib/stores';
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
||||||
|
|
@ -306,27 +306,72 @@
|
||||||
it: ''
|
it: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dynamic languages from config
|
||||||
|
$: LANGS = Array.isArray($config.features.translation_languages)
|
||||||
|
? $config.features.translation_languages
|
||||||
|
: ['de']; // fallback if missing
|
||||||
|
|
||||||
|
// Utility function to create empty translation object
|
||||||
|
function createEmptyTranslations() {
|
||||||
|
const translations = {};
|
||||||
|
LANGS.forEach(lang => {
|
||||||
|
translations[lang] = '';
|
||||||
|
});
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse content to object with dynamic languages
|
||||||
|
function parseContentToObj(content) {
|
||||||
|
let parsed = {};
|
||||||
|
try {
|
||||||
|
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
|
||||||
|
} catch {
|
||||||
|
parsed = { [LANGS[0] || 'de']: content || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure all languages from config exist
|
||||||
|
for (const lang of LANGS) {
|
||||||
|
if (parsed[lang] == null) parsed[lang] = '';
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
// Call this when the component loads with existing data
|
// Call this when the component loads with existing data
|
||||||
function initializeTitleTranslations(existingName) {
|
function initializeTitleTranslations(existingName) {
|
||||||
if (existingName) {
|
if (existingName) {
|
||||||
try {
|
titleTranslations = parseContentToObj(existingName);
|
||||||
// If existingName is already an object, use it directly
|
|
||||||
if (typeof existingName === 'object') {
|
|
||||||
titleTranslations = { de: '', en: '', fr: '', it: '', ...existingName };
|
|
||||||
} else {
|
} else {
|
||||||
// If it's a JSON string, parse it
|
titleTranslations = createEmptyTranslations();
|
||||||
const parsed = JSON.parse(existingName);
|
|
||||||
titleTranslations = { de: '', en: '', fr: '', it: '', ...parsed };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, treat as a simple string and put it in the default language
|
|
||||||
titleTranslations = { de: existingName, en: '', fr: '', it: '' };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
titleTranslations = { de: '', en: '', fr: '', it: '' };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get translated text with fallback logic
|
||||||
|
function getTranslatedText(translationObj, targetLang = langCode) {
|
||||||
|
if (!translationObj) return '';
|
||||||
|
|
||||||
|
const parsed = parseContentToObj(translationObj);
|
||||||
|
|
||||||
|
// Try target language first
|
||||||
|
if (parsed[targetLang] && parsed[targetLang].trim()) {
|
||||||
|
return parsed[targetLang];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try German as fallback (if it exists in LANGS)
|
||||||
|
if (LANGS.includes('de') && parsed['de'] && parsed['de'].trim()) {
|
||||||
|
return parsed['de'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try first available language from LANGS
|
||||||
|
for (const lang of LANGS) {
|
||||||
|
if (parsed[lang] && parsed[lang].trim()) {
|
||||||
|
return parsed[lang];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when info.name changes
|
||||||
$: if (info?.name) {
|
$: if (info?.name) {
|
||||||
initializeTitleTranslations(info.name);
|
initializeTitleTranslations(info.name);
|
||||||
}
|
}
|
||||||
|
|
@ -339,9 +384,12 @@
|
||||||
return JSON.stringify(titleTranslations);
|
return JSON.stringify(titleTranslations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to get the display value for the current language
|
// Initialize translation objects dynamically
|
||||||
function getTitleDisplayValue(langCode) {
|
$: if (LANGS.length > 0) {
|
||||||
return titleTranslations[langCode] || titleTranslations.de || '';
|
// Initialize newPrompt if not already done
|
||||||
|
if (!newPrompt || Object.keys(newPrompt).length !== LANGS.length) {
|
||||||
|
newPrompt = createEmptyTranslations();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -677,27 +725,18 @@
|
||||||
|
|
||||||
<!-- MODAL: Full translation editor -->
|
<!-- MODAL: Full translation editor -->
|
||||||
{#if showTitleModal}
|
{#if showTitleModal}
|
||||||
<div
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
|
|
||||||
>
|
|
||||||
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
||||||
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1 s--7fsHGLC475o">
|
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1 s--7fsHGLC475o">
|
||||||
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Title Translations')}</h2>
|
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Title Translations')}</h2>
|
||||||
<button class="text-xs px-2 py-1" on:click={() => (showTitleModal = false)}>
|
<button class="text-xs px-2 py-1" on:click={() => (showTitleModal = false)}>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each Object.keys(titleTranslations) as lang}
|
{#each LANGS as lang}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -890,19 +929,12 @@
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
editingIndex = null;
|
editingIndex = null;
|
||||||
newPrompt = { de: '', en: '', fr: '', it: '' };
|
newPrompt = createEmptyTranslations(); // Changed: use dynamic function
|
||||||
showPromptModal = true;
|
showPromptModal = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -916,7 +948,7 @@
|
||||||
<input
|
<input
|
||||||
class="text-sm w-full bg-transparent outline-none border border-gray-200 dark:border-gray-800 rounded px-2 py-1"
|
class="text-sm w-full bg-transparent outline-none border border-gray-200 dark:border-gray-800 rounded px-2 py-1"
|
||||||
readonly
|
readonly
|
||||||
value={prompt.content?.[langCode] || prompt.content?.de || ''}
|
value={prompt.content?.[langCode] || prompt.content?.[LANGS[0]] || ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -924,15 +956,15 @@
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
editingIndex = promptIdx;
|
editingIndex = promptIdx;
|
||||||
newPrompt = { ...prompt.content };
|
newPrompt = parseContentToObj(prompt.content); // Changed: use parseContentToObj
|
||||||
showPromptModal = true;
|
showPromptModal = true;
|
||||||
}}
|
}}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<div class=" self-center mr-2">
|
<div class=" self-center mr-2">
|
||||||
<PencilSolid />
|
<PencilSolid />
|
||||||
</div></button
|
</div>
|
||||||
>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="text-xs px-2 py-1 bg-red-200 hover:bg-red-300 rounded"
|
class="text-xs px-2 py-1 bg-red-200 hover:bg-red-300 rounded"
|
||||||
|
|
@ -955,9 +987,7 @@
|
||||||
|
|
||||||
<!-- MODAL for adding/editing prompt translations -->
|
<!-- MODAL for adding/editing prompt translations -->
|
||||||
{#if showPromptModal}
|
{#if showPromptModal}
|
||||||
<div
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
|
|
||||||
>
|
|
||||||
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
||||||
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1 s--7fsHGLC475o">
|
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1 s--7fsHGLC475o">
|
||||||
<h2 class="text-sm font-bold mb-2">
|
<h2 class="text-sm font-bold mb-2">
|
||||||
|
|
@ -970,28 +1000,21 @@
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
showPromptModal = false;
|
showPromptModal = false;
|
||||||
editingIndex = null;
|
editingIndex = null;
|
||||||
newPrompt = { de: '', en: '', fr: '', it: '' };
|
newPrompt = createEmptyTranslations(); // Changed: use dynamic function
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each Object.keys(newPrompt) as lang}
|
{#each LANGS as lang}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
||||||
<input
|
<input
|
||||||
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
|
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
|
||||||
value={newPrompt[lang]}
|
value={newPrompt[lang] || ''}
|
||||||
on:input={(e) => (newPrompt[lang] = e.target.value)}
|
on:input={(e) => (newPrompt[lang] = e.target.value)}
|
||||||
placeholder={`Enter ${lang.toUpperCase()} translation`}
|
placeholder={`Enter ${lang.toUpperCase()} translation`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1002,7 +1025,8 @@
|
||||||
<button
|
<button
|
||||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (newPrompt.de.trim()) {
|
const firstLang = LANGS[0] || 'de';
|
||||||
|
if (newPrompt[firstLang]?.trim()) {
|
||||||
if (editingIndex === null) {
|
if (editingIndex === null) {
|
||||||
info.meta.suggestion_prompts = [
|
info.meta.suggestion_prompts = [
|
||||||
...(info.meta.suggestion_prompts ?? []),
|
...(info.meta.suggestion_prompts ?? []),
|
||||||
|
|
@ -1013,13 +1037,12 @@
|
||||||
info.meta.suggestion_prompts = [...info.meta.suggestion_prompts];
|
info.meta.suggestion_prompts = [...info.meta.suggestion_prompts];
|
||||||
}
|
}
|
||||||
showPromptModal = false;
|
showPromptModal = false;
|
||||||
newPrompt = { de: '', en: '', fr: '', it: '' };
|
newPrompt = createEmptyTranslations(); // Changed: use dynamic function
|
||||||
editingIndex = null;
|
editingIndex = null;
|
||||||
} else {
|
} else {
|
||||||
alert('German translation is required.');
|
alert(`${firstLang.toUpperCase()} translation is required.`); // Changed: dynamic language
|
||||||
}
|
}
|
||||||
}}>{editingIndex === null ? 'Add' : 'Save'}</button
|
}}>{editingIndex === null ? 'Add' : 'Save'}</button>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -114,3 +114,69 @@ export function initI18nContext() {
|
||||||
// ---- exports ----
|
// ---- exports ----
|
||||||
export const isLoading = createIsLoadingStore(i18next);
|
export const isLoading = createIsLoadingStore(i18next);
|
||||||
export default getI18nStore();
|
export default getI18nStore();
|
||||||
|
|
||||||
|
// Utils for translations
|
||||||
|
export interface Translations {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the language code from a locale string (e.g., 'en-US' -> 'en')
|
||||||
|
* @param language - Full language code (e.g., 'en-US', 'es-ES')
|
||||||
|
* @param fallback - Default language code if parsing fails
|
||||||
|
* @returns Language code (e.g., 'en', 'es', 'de')
|
||||||
|
*/
|
||||||
|
export function getLangCode(language?: string, fallback: string = 'de'): string {
|
||||||
|
return language?.split('-')[0] || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets translated label from translation object or JSON string
|
||||||
|
* @param label - Translation object or JSON string containing translations
|
||||||
|
* @param langCode - Target language code (e.g., 'en', 'es', 'de')
|
||||||
|
* @returns Translated string or empty string if not found
|
||||||
|
*/
|
||||||
|
export function getTranslatedLabel(
|
||||||
|
label: string | Translations | null | undefined,
|
||||||
|
langCode: string
|
||||||
|
): string {
|
||||||
|
if (!label) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If it's already an object, use it directly
|
||||||
|
const translations: Translations = typeof label === 'object' ? label : JSON.parse(label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
translations[langCode] ||
|
||||||
|
translations.en ||
|
||||||
|
translations.de ||
|
||||||
|
translations.fr ||
|
||||||
|
translations.it ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log parsing errors for debugging in development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Failed to parse translation label:', label, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parsing fails, return the original value if it's a string
|
||||||
|
return typeof label === 'string' ? label : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function that combines getLangCode and getTranslatedLabel
|
||||||
|
* @param label - Translation object or JSON string
|
||||||
|
* @param language - Full language code (e.g., 'en-US')
|
||||||
|
* @param fallback - Fallback language code
|
||||||
|
* @returns Translated string
|
||||||
|
*/
|
||||||
|
export function translate(
|
||||||
|
label: string | Translations | null | undefined,
|
||||||
|
language?: string,
|
||||||
|
fallback: string = 'de'
|
||||||
|
): string {
|
||||||
|
const langCode = getLangCode(language, fallback);
|
||||||
|
return getTranslatedLabel(label, langCode);
|
||||||
|
}
|
||||||
|
|
@ -260,6 +260,7 @@ type Config = {
|
||||||
enable_autocomplete_generation: boolean;
|
enable_autocomplete_generation: boolean;
|
||||||
enable_direct_connections: boolean;
|
enable_direct_connections: boolean;
|
||||||
enable_version_update_check: boolean;
|
enable_version_update_check: boolean;
|
||||||
|
translation_languages: string[];
|
||||||
};
|
};
|
||||||
oauth: {
|
oauth: {
|
||||||
providers: {
|
providers: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue