Make the content-language field a searchable combobox and reload on UI-language switch
Replace the plain content-language <Select> in the brand form with a searchable LanguagePicker combobox (Popover + Command), matching the FontPicker. i18n the combobox placeholder/search/empty strings across all 15 locales. Switch the UI language via a full page reload instead of client-side dir syncing, so the server-rendered <html dir> flips LTR<->RTL correctly without a manual refresh.
This commit is contained in:
parent
c59adb4871
commit
e17b86ac93
21 changed files with 204 additions and 47 deletions
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
namespace App\Http\Middleware\App;
|
||||
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource;
|
||||
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
|
||||
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
|
||||
|
|
@ -53,7 +52,6 @@ public function share(Request $request): array
|
|||
'applicationUrl' => config('app.url'),
|
||||
'env' => config('app.env'),
|
||||
'locale' => app()->getLocale(),
|
||||
'dir' => ContentLanguage::tryFrom(app()->getLocale())?->isRtl() ? 'rtl' : 'ltr',
|
||||
'languages' => collect(config('languages.available'))->map(fn ($name, $code) => [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'نموذج أوّلي',
|
||||
'content_language' => 'لغة المحتوى',
|
||||
'content_language_description' => 'اللغة المستخدمة في التسميات التوضيحية والوسوم المُنشأة بالذكاء الاصطناعي وأي نص داخل الصور أو مقاطع الفيديو المُنشأة.',
|
||||
'font_placeholder' => 'اختر خطًا…',
|
||||
'font_search' => 'ابحث عن خط…',
|
||||
'font_empty' => 'لا توجد خطوط مطابقة.',
|
||||
'language_placeholder' => 'اختر لغة…',
|
||||
'language_search' => 'ابحث عن لغة…',
|
||||
'language_empty' => 'لم يتم العثور على لغة.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -218,6 +218,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Inhaltssprache',
|
||||
'content_language_description' => 'Sprache für KI-generierte Bildunterschriften, Hashtags und jeglichen Text in generierten Bildern oder Videos.',
|
||||
'font_placeholder' => 'Schriftart auswählen…',
|
||||
'font_search' => 'Schriftart suchen…',
|
||||
'font_empty' => 'Keine Schriftarten gefunden.',
|
||||
'language_placeholder' => 'Sprache auswählen…',
|
||||
'language_search' => 'Sprache suchen…',
|
||||
'language_empty' => 'Keine Sprache gefunden.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Γλώσσα περιεχομένου',
|
||||
'content_language_description' => 'Γλώσσα που χρησιμοποιείται για λεζάντες, hashtags και οποιοδήποτε κείμενο μέσα σε εικόνες ή βίντεο που δημιουργεί το AI.',
|
||||
'font_placeholder' => 'Επιλογή γραμματοσειράς…',
|
||||
'font_search' => 'Αναζήτηση γραμματοσειράς…',
|
||||
'font_empty' => 'Δεν βρέθηκαν γραμματοσειρές.',
|
||||
'language_placeholder' => 'Επιλογή γλώσσας…',
|
||||
'language_search' => 'Αναζήτηση γλώσσας…',
|
||||
'language_empty' => 'Δεν βρέθηκε γλώσσα.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Content language',
|
||||
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
|
||||
'font_placeholder' => 'Select a font…',
|
||||
'font_search' => 'Search font…',
|
||||
'font_empty' => 'No fonts match.',
|
||||
'language_placeholder' => 'Select a language…',
|
||||
'language_search' => 'Search language…',
|
||||
'language_empty' => 'No language found.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Idioma del contenido',
|
||||
'content_language_description' => 'Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.',
|
||||
'font_placeholder' => 'Seleccionar una fuente…',
|
||||
'font_search' => 'Buscar fuente…',
|
||||
'font_empty' => 'No hay fuentes que coincidan.',
|
||||
'language_placeholder' => 'Seleccionar un idioma…',
|
||||
'language_search' => 'Buscar idioma…',
|
||||
'language_empty' => 'No se encontró ningún idioma.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Maquette',
|
||||
'content_language' => 'Langue du contenu',
|
||||
'content_language_description' => 'Langue utilisée pour les légendes, les hashtags générés par l\'IA et tout texte à l\'intérieur des images ou vidéos générées.',
|
||||
'font_placeholder' => 'Sélectionner une police…',
|
||||
'font_search' => 'Rechercher une police…',
|
||||
'font_empty' => 'Aucune police trouvée.',
|
||||
'language_placeholder' => 'Sélectionner une langue…',
|
||||
'language_search' => 'Rechercher une langue…',
|
||||
'language_empty' => 'Aucune langue trouvée.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Lingua dei contenuti',
|
||||
'content_language_description' => 'Lingua usata per didascalie, hashtag generati dall\'IA e qualsiasi testo all\'interno di immagini o video generati.',
|
||||
'font_placeholder' => 'Seleziona un font…',
|
||||
'font_search' => 'Cerca font…',
|
||||
'font_empty' => 'Nessun font trovato.',
|
||||
'language_placeholder' => 'Seleziona una lingua…',
|
||||
'language_search' => 'Cerca lingua…',
|
||||
'language_empty' => 'Nessuna lingua trovata.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'モックアップ',
|
||||
'content_language' => 'コンテンツの言語',
|
||||
'content_language_description' => 'AI が生成するキャプション、ハッシュタグ、生成された画像や動画内のテキストに使用される言語です。',
|
||||
'font_placeholder' => 'フォントを選択…',
|
||||
'font_search' => 'フォントを検索…',
|
||||
'font_empty' => '一致するフォントがありません。',
|
||||
'language_placeholder' => '言語を選択…',
|
||||
'language_search' => '言語を検索…',
|
||||
'language_empty' => '言語が見つかりません。',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => '목업',
|
||||
'content_language' => '콘텐츠 언어',
|
||||
'content_language_description' => 'AI가 생성하는 캡션, 해시태그, 생성된 이미지나 동영상 내 텍스트에 사용되는 언어입니다.',
|
||||
'font_placeholder' => '글꼴 선택…',
|
||||
'font_search' => '글꼴 검색…',
|
||||
'font_empty' => '일치하는 글꼴이 없습니다.',
|
||||
'language_placeholder' => '언어 선택…',
|
||||
'language_search' => '언어 검색…',
|
||||
'language_empty' => '언어를 찾을 수 없습니다.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Contenttaal',
|
||||
'content_language_description' => 'Taal die wordt gebruikt voor AI-gegenereerde bijschriften, hashtags en tekst in gegenereerde afbeeldingen of video\'s.',
|
||||
'font_placeholder' => 'Kies een lettertype…',
|
||||
'font_search' => 'Lettertype zoeken…',
|
||||
'font_empty' => 'Geen lettertypen gevonden.',
|
||||
'language_placeholder' => 'Kies een taal…',
|
||||
'language_search' => 'Taal zoeken…',
|
||||
'language_empty' => 'Geen taal gevonden.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Makieta',
|
||||
'content_language' => 'Język treści',
|
||||
'content_language_description' => 'Język używany w podpisach, hasztagach generowanych przez AI oraz w dowolnym tekście wewnątrz generowanych obrazów lub filmów.',
|
||||
'font_placeholder' => 'Wybierz czcionkę…',
|
||||
'font_search' => 'Szukaj czcionki…',
|
||||
'font_empty' => 'Nie znaleziono czcionek.',
|
||||
'language_placeholder' => 'Wybierz język…',
|
||||
'language_search' => 'Szukaj języka…',
|
||||
'language_empty' => 'Nie znaleziono języka.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Mockup',
|
||||
'content_language' => 'Idioma do conteúdo',
|
||||
'content_language_description' => 'Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.',
|
||||
'font_placeholder' => 'Selecionar uma fonte…',
|
||||
'font_search' => 'Buscar fonte…',
|
||||
'font_empty' => 'Nenhuma fonte encontrada.',
|
||||
'language_placeholder' => 'Selecionar um idioma…',
|
||||
'language_search' => 'Buscar idioma…',
|
||||
'language_empty' => 'Nenhum idioma encontrado.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => 'Макет',
|
||||
'content_language' => 'Язык контента',
|
||||
'content_language_description' => 'Язык, используемый для сгенерированных ИИ подписей, хэштегов и любого текста внутри сгенерированных изображений или видео.',
|
||||
'font_placeholder' => 'Выберите шрифт…',
|
||||
'font_search' => 'Поиск шрифта…',
|
||||
'font_empty' => 'Шрифты не найдены.',
|
||||
'language_placeholder' => 'Выберите язык…',
|
||||
'language_search' => 'Поиск языка…',
|
||||
'language_empty' => 'Язык не найден.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -218,6 +218,13 @@
|
|||
'image_style_mockup' => 'Model (Mockup)',
|
||||
'content_language' => 'İçerik dili',
|
||||
'content_language_description' => 'AI ile oluşturulan açıklamalar, hashtag\'ler ve oluşturulan görsel veya videoların içindeki metinler için kullanılan dil.',
|
||||
'font_placeholder' => 'Yazı tipi seçin…',
|
||||
'font_search' => 'Yazı tipi ara…',
|
||||
'font_empty' => 'Yazı tipi bulunamadı.',
|
||||
'language_placeholder' => 'Dil seçin…',
|
||||
'language_search' => 'Dil ara…',
|
||||
'language_empty' => 'Dil bulunamadı.',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@
|
|||
'image_style_mockup' => '样机',
|
||||
'content_language' => '内容语言',
|
||||
'content_language_description' => '用于 AI 生成的文案、话题标签,以及生成图片或视频中任何文字的语言。',
|
||||
'font_placeholder' => '选择字体…',
|
||||
'font_search' => '搜索字体…',
|
||||
'font_empty' => '没有匹配的字体。',
|
||||
'language_placeholder' => '选择语言…',
|
||||
'language_search' => '搜索语言…',
|
||||
'language_empty' => '未找到语言。',
|
||||
|
||||
],
|
||||
|
||||
'members' => [
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ createInertiaApp({
|
|||
// Set dayjs locale based on user's language
|
||||
dayjs.locale(locale.toLowerCase());
|
||||
|
||||
// Mirror the layout for right-to-left locales on SPA navigations, where
|
||||
// the server-rendered <html dir> from the initial load does not re-run.
|
||||
const dir = (props.initialPage.props as { dir?: string })?.dir || 'ltr';
|
||||
document.documentElement.dir = dir;
|
||||
|
||||
const auth = props.initialPage.props.auth as Auth | undefined;
|
||||
const flash = props.initialPage.props.flash as
|
||||
| { conversion_event?: string; [key: string]: unknown }
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconCheck, IconLoader2, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import FontPicker from '@/components/FontPicker.vue';
|
||||
import HexColorInput from '@/components/HexColorInput.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import LanguagePicker from '@/components/LanguagePicker.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
|
|
@ -19,13 +20,6 @@ import {
|
|||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { autofill as autofillBrand } from '@/routes/app/workspaces';
|
||||
|
||||
|
|
@ -96,10 +90,6 @@ const toggleTrait = (group: string, value: string): void => {
|
|||
props.fields.brand_voice_traits = [...current.filter((v) => !groupValues.includes(v)), value];
|
||||
};
|
||||
|
||||
const languageLabel = computed(
|
||||
() => props.availableContentLanguages.find((language) => language.value === props.fields.content_language)?.label ?? '',
|
||||
);
|
||||
|
||||
const runAutofill = async () => {
|
||||
const url = props.fields.brand_website?.trim() ?? '';
|
||||
if (!url) {
|
||||
|
|
@ -186,18 +176,13 @@ const runAutofill = async () => {
|
|||
|
||||
<div class="grid gap-2">
|
||||
<Label for="content_language">{{ $t('settings.brand.content_language') }}</Label>
|
||||
<Select v-model="fields.content_language">
|
||||
<SelectTrigger id="content_language" class="w-full">
|
||||
<SelectValue :placeholder="$t('settings.brand.content_language')">
|
||||
{{ languageLabel }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="language in availableContentLanguages" :key="language.value" :value="language.value">
|
||||
{{ language.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<LanguagePicker
|
||||
v-model="fields.content_language"
|
||||
:options="availableContentLanguages"
|
||||
:placeholder="$t('settings.brand.language_placeholder')"
|
||||
:search-placeholder="$t('settings.brand.language_search')"
|
||||
:empty-text="$t('settings.brand.language_empty')"
|
||||
/>
|
||||
<p class="text-xs font-medium text-foreground/60">
|
||||
{{ $t('settings.brand.content_language_description') }}
|
||||
</p>
|
||||
|
|
@ -252,7 +237,13 @@ const runAutofill = async () => {
|
|||
|
||||
<div class="grid gap-2">
|
||||
<Label for="brand_font">{{ $t('settings.brand.font') }}</Label>
|
||||
<FontPicker v-model="fields.brand_font" :fonts="availableFonts" />
|
||||
<FontPicker
|
||||
v-model="fields.brand_font"
|
||||
:fonts="availableFonts"
|
||||
:placeholder="$t('settings.brand.font_placeholder')"
|
||||
:search-placeholder="$t('settings.brand.font_search')"
|
||||
:empty-text="$t('settings.brand.font_empty')"
|
||||
/>
|
||||
<InputError :message="errors.brand_font" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,12 +19,16 @@ interface Props {
|
|||
* preview text in the dropdown renders in the actual typeface. */
|
||||
fonts: string[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Select a font…',
|
||||
searchPlaceholder: 'Search font…',
|
||||
emptyText: 'No fonts match.',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
|
|
@ -89,9 +93,9 @@ const select = (font: string) => {
|
|||
|
||||
<PopoverContent class="w-[--reka-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search font…" />
|
||||
<CommandInput :placeholder="searchPlaceholder" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No fonts match.</CommandEmpty>
|
||||
<CommandEmpty>{{ emptyText }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="font in fonts"
|
||||
|
|
|
|||
76
resources/js/components/LanguagePicker.vue
Normal file
76
resources/js/components/LanguagePicker.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Selectable languages as { value: locale code, label: native name }. */
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Select a language…',
|
||||
searchPlaceholder: 'Search language…',
|
||||
emptyText: 'No language found.',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const value = defineModel<string | null>({ required: true });
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const selectedLabel = computed(() => props.options.find((option) => option.value === value.value)?.label ?? '');
|
||||
|
||||
const select = (code: string) => {
|
||||
value.value = code;
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span :class="selectedLabel ? '' : 'text-muted-foreground'">
|
||||
{{ selectedLabel || placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-[--reka-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput :placeholder="searchPlaceholder" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ emptyText }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="select(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<IconCheck :class="cn('ml-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
|
@ -5,7 +5,6 @@ import {
|
|||
IconLogout,
|
||||
IconUser,
|
||||
} from '@tabler/icons-vue';
|
||||
import { loadLanguageAsync } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { updateLanguage } from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
|
||||
|
|
@ -20,7 +19,6 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import dayjs from '@/dayjs';
|
||||
import posthog from '@/posthog';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/app/profile';
|
||||
|
|
@ -42,18 +40,8 @@ const languages = computed<Language[]>(() => page.props.languages as Language[])
|
|||
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
|
||||
|
||||
const switchLanguage = (code: string) => {
|
||||
const previousCode = currentLanguage.value?.code || 'en';
|
||||
|
||||
loadLanguageAsync(code);
|
||||
dayjs.locale(code.toLowerCase());
|
||||
|
||||
router.put(updateLanguage.url(), { locale: code }, {
|
||||
preserveScroll: true,
|
||||
preserveState: false,
|
||||
onError: () => {
|
||||
loadLanguageAsync(previousCode);
|
||||
dayjs.locale(previousCode.toLowerCase());
|
||||
},
|
||||
onSuccess: () => window.location.reload(),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue