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:
Paulo Castellano 2026-07-03 18:17:38 -03:00
parent c59adb4871
commit e17b86ac93
21 changed files with 204 additions and 47 deletions

View file

@ -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,

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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' => [

View file

@ -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 }

View file

@ -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>

View file

@ -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"

View 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>

View file

@ -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(),
});
};