diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json
index 0b4e7ff58..ef462dc08 100644
--- a/apps/api/src/locales/@vitnode/core/en.json
+++ b/apps/api/src/locales/@vitnode/core/en.json
@@ -102,6 +102,7 @@
"go_back": "Go back",
"select_option": "Select an option",
"select_options": "Select options",
+ "select_language": "Select language",
"go_to_prev_page": "Go to previous page",
"go_to_next_page": "Go to next page",
"errors": {
diff --git a/apps/docs/content/docs/ui/auto-form.mdx b/apps/docs/content/docs/ui/auto-form.mdx
index 55dd75627..f29997f5b 100644
--- a/apps/docs/content/docs/ui/auto-form.mdx
+++ b/apps/docs/content/docs/ui/auto-form.mdx
@@ -296,6 +296,82 @@ const formSchema = z.object({
});
```
+## Multi-language fields
+
+Some values - a category name, a page title - need a translation per language.
+`AutoFormInput` and `AutoFormEditor` accept a `multiLang` prop that collects the
+value in every enabled language. The field renders a language **select** (shown
+only when more than one language is enabled) that switches which language you
+are editing; it does **not** change the app-wide locale, only the value inside
+that field.
+
+The value is stored as an array matching the
+[`core_languages_words`](/docs/dev/working-with-users/roles) table - one
+`{ languageCode, value }` entry per language. Declare the field with the
+`multiLangValueSchema` helper so the Zod schema matches that array shape (its
+`minLength` / `maxLength` apply to each language's value):
+
+```ts
+import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang";
+
+const formSchema = z.object({
+ name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1),
+});
+```
+
+```tsx
+,
+ },
+ ]}
+ onSubmit={values => {
+ // values.name => [{ languageCode: "en", value: "News" }, ...]
+ }}
+/>
+```
+
+### Saving to the backend
+
+The form only produces `{ languageCode, value }[]`. On the backend, persist it
+with `saveLanguageWords`, which fills the remaining `core_languages_words`
+columns - the `(pluginCode, tableName, variable, itemId)` tuple that identifies
+the field - and replaces the existing rows in a single transaction:
+
+```ts title="plugins/{plugin_name}/src/api/routes/create-category.route.ts"
+import { buildRoute } from "@vitnode/core/api/lib/route";
+import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words";
+
+export const createCategoryRoute = buildRoute({
+ handler: async c => {
+ const { name } = c.req.valid("json");
+
+ const [category] = await c
+ .get("db")
+ .insert(blog_categories)
+ .values({})
+ .returning({ id: blog_categories.id });
+
+ await saveLanguageWords(c, {
+ pluginCode: "blog",
+ tableName: "blog_categories",
+ variable: "name",
+ itemId: category.id,
+ values: name, // the { languageCode, value }[] from the form
+ });
+
+ return c.json({ id: category.id }, 201);
+ },
+});
+```
+
+To read the translations back for an edit form, query `core_languages_words` by
+the same tuple and return `{ languageCode, value }[]` - the shape
+[`resolveRoleNames`](/docs/dev/working-with-users/roles) produces for role names.
+
## Custom Fields
Because Auto Form fields are controlled entirely by the `component` property function, creating a custom field component is just a matter of rendering your own UI using the provided props. The props passed to the `component` function contain the field properties managed by `react-hook-form` along with Zod validation details.
diff --git a/apps/docs/content/docs/ui/editor.mdx b/apps/docs/content/docs/ui/editor.mdx
index 53bb14d1e..f31d5c403 100644
--- a/apps/docs/content/docs/ui/editor.mdx
+++ b/apps/docs/content/docs/ui/editor.mdx
@@ -67,6 +67,42 @@ const [content, setContent] = useState("
Hello World! 🌎️
");
+## Multi-language
+
+Set the `multiLang` prop to edit the content in every enabled language. A
+language **select** (shown only when more than one language is enabled) switches
+which language you are editing - it does **not** change the app-wide locale,
+only the content inside this editor.
+
+The value becomes an array matching the `core_languages_words` table:
+
+```ts
+[
+ { languageCode: "en", value: "
Hello
" },
+ { languageCode: "pl", value: "
Cześć
" },
+];
+```
+
+Declare the field with the `multiLangValueSchema` helper and enable `multiLang`:
+
+```ts
+import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang";
+
+const formSchema = z.object({
+ content: multiLangValueSchema({ minLength: 1 }).min(1),
+});
+```
+
+```tsx
+{
+ id: "content",
+ component: props => ,
+}
+```
+
+Persist the array on the backend with `saveLanguageWords`. See
+[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields).
+
## Rendering Content
Use `EditorContent` to render the stored HTML as read-only content outside of
@@ -108,5 +144,11 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type: "string",
default: "",
},
+ multiLang: {
+ description:
+ "Edit the content per language. The value becomes a { languageCode, value }[] array and a language select is shown when more than one language is enabled.",
+ type: "boolean",
+ default: "false",
+ },
}}
/>
diff --git a/apps/docs/content/docs/ui/input.mdx b/apps/docs/content/docs/ui/input.mdx
index ad6bb31e4..86fb2ea3e 100644
--- a/apps/docs/content/docs/ui/input.mdx
+++ b/apps/docs/content/docs/ui/input.mdx
@@ -68,6 +68,45 @@ import { Input } from '@vitnode/core/components/ui/input';
+## Multi-language
+
+Set the `multiLang` prop to collect the value in every enabled language. The
+field renders a language **select** (shown only when more than one language is
+enabled) that switches which language you are editing - it does **not** change
+the app-wide locale, only the value inside this input.
+
+The value becomes an array matching the `core_languages_words` table, one entry
+per language you edited:
+
+```ts
+[
+ { languageCode: "en", value: "Category" },
+ { languageCode: "pl", value: "Kategoria" },
+];
+```
+
+Declare the field with the `multiLangValueSchema` helper so the schema matches
+the array shape (its `minLength` / `maxLength` apply to each language's value):
+
+```ts
+import { multiLangValueSchema } from "@vitnode/core/lib/helpers/multi-lang";
+
+const formSchema = z.object({
+ name: multiLangValueSchema({ minLength: 1, maxLength: 255 }).min(1),
+});
+```
+
+```tsx
+{
+ id: "name",
+ component: props => ,
+}
+```
+
+On the backend, persist the array with `saveLanguageWords` - it fills the
+remaining `core_languages_words` columns. See
+[Auto Form - Multi-language fields](/docs/ui/auto-form#multi-language-fields).
+
## Props
import { TypeTable } from "fumadocs-ui/components/type-table";
@@ -97,5 +136,11 @@ import { TypeTable } from "fumadocs-ui/components/type-table";
type: "string",
default: "",
},
+ multiLang: {
+ description:
+ "Collect the value per language. The value becomes a { languageCode, value }[] array and a language select is shown when more than one language is enabled.",
+ type: "boolean",
+ default: "false",
+ },
}}
/>
diff --git a/apps/docs/src/locales/@vitnode/blog/pl.json b/apps/docs/src/locales/@vitnode/blog/pl.json
new file mode 100644
index 000000000..a05c74435
--- /dev/null
+++ b/apps/docs/src/locales/@vitnode/blog/pl.json
@@ -0,0 +1,80 @@
+{
+ "@vitnode/blog": {
+ "title": "Blog",
+ "admin": {
+ "nav": {
+ "posts": "Wpisy",
+ "categories": "Kategorie"
+ },
+ "categories": {
+ "desc": "Zarządzaj kategoriami wpisów na blogu.",
+ "table": {
+ "title": "Tytuł",
+ "updated_at": "Zaktualizowano"
+ },
+ "delete": {
+ "title": "Usuń kategorię",
+ "desc": "Czy na pewno chcesz usunąć kategorię ? Tej akcji nie można cofnąć.",
+ "confirm": "Tak, usuń tę kategorię",
+ "success": "Kategoria została pomyślnie usunięta."
+ },
+ "create": {
+ "title": "Utwórz kategorię",
+ "desc": "Nowa kategoria dla wpisów na blogu.",
+ "form": {
+ "title": {
+ "label": "Tytuł",
+ "already_exists": "Kategoria o tym tytule już istnieje."
+ }
+ },
+ "submit": "Utwórz"
+ },
+ "edit": {
+ "title": "Edytuj kategorię",
+ "submit": "Zapisz zmiany"
+ }
+ },
+ "posts": {
+ "desc": "Twórz wpisy na blogu i zarządzaj nimi.",
+ "table": {
+ "title": "Tytuł",
+ "category": "Kategoria",
+ "updated_at": "Zaktualizowano"
+ },
+ "create": {
+ "title": "Utwórz wpis",
+ "desc": "Napisz nowy artykuł na swój blog.",
+ "form": {
+ "title": {
+ "label": "Tytuł",
+ "already_exists": "Wpis o tym tytule już istnieje."
+ },
+ "content": "Treść",
+ "category": "Kategoria"
+ },
+ "submit": "Utwórz wpis"
+ },
+ "edit": {
+ "title": "Edytuj wpis",
+ "submit": "Zapisz zmiany"
+ },
+ "delete": {
+ "title": "Usuń wpis",
+ "desc": "Czy na pewno chcesz usunąć wpis ? Tej akcji nie można cofnąć.",
+ "confirm": "Tak, usuń ten wpis",
+ "success": "Wpis został pomyślnie usunięty."
+ }
+ }
+ }
+ },
+ "@vitnode/blog:posts": "Wpisy",
+ "@vitnode/blog:posts:can_view": "Wyświetlanie listy wpisów",
+ "@vitnode/blog:posts:can_create": "Tworzenie wpisów",
+ "@vitnode/blog:posts:can_edit": "Edytowanie wpisów",
+ "@vitnode/blog:posts:can_delete": "Usuwanie wpisów",
+ "@vitnode/blog:categories": "Kategorie",
+ "@vitnode/blog:categories:can_view": "Wyświetlanie listy kategorii",
+ "@vitnode/blog:categories:can_create": "Tworzenie kategorii",
+ "@vitnode/blog:categories:can_edit": "Edytowanie kategorii",
+ "@vitnode/blog:categories:can_delete": "Usuwanie kategorii"
+}
diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json
index 0b4e7ff58..ef462dc08 100644
--- a/apps/docs/src/locales/@vitnode/core/en.json
+++ b/apps/docs/src/locales/@vitnode/core/en.json
@@ -102,6 +102,7 @@
"go_back": "Go back",
"select_option": "Select an option",
"select_options": "Select options",
+ "select_language": "Select language",
"go_to_prev_page": "Go to previous page",
"go_to_next_page": "Go to next page",
"errors": {
diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json
new file mode 100644
index 000000000..bfc5f9ef2
--- /dev/null
+++ b/apps/docs/src/locales/@vitnode/core/pl.json
@@ -0,0 +1,748 @@
+{
+ "@vitnode/core": {
+ "title": "Rdzeń"
+ },
+ "@vitnode/core:users": "Użytkownicy",
+ "@vitnode/core:users:can_view": "Wyświetlanie listy użytkowników",
+ "@vitnode/core:users:can_create": "Tworzenie użytkowników",
+ "@vitnode/core:users:can_edit": "Edytowanie użytkowników",
+ "@vitnode/core:users:can_edit_admin": "Edytowanie użytkowników z uprawnieniami administratora",
+ "@vitnode/core:roles": "Role",
+ "@vitnode/core:roles:can_manage": "Zarządzanie rolami",
+ "@vitnode/core:debug": "Panel debugowania",
+ "@vitnode/core:debug:can_view": "Wyświetlanie panelu debugowania",
+ "@vitnode/core:debug:can_clear_cache": "Czyszczenie pamięci podręcznej",
+ "@vitnode/core:system": "System",
+ "@vitnode/core:system:can_view": "Wyświetlanie integracji",
+ "@vitnode/core:system:can_send_test_email": "Wysyłanie testowej wiadomości e-mail",
+ "@vitnode/core:system:can_test_storage": "Testowanie magazynu plików",
+ "@vitnode/core:files": "Pliki",
+ "@vitnode/core:files:can_view": "Wyświetlanie przesłanych plików",
+ "@vitnode/core:files:can_download": "Pobieranie plików",
+ "@vitnode/core:files:can_delete": "Usuwanie plików",
+ "@vitnode/core:queue": "Zadania w kolejce",
+ "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce",
+ "@vitnode/core:staff_moderators": "Zespół: Moderatorzy",
+ "@vitnode/core:staff_moderators:can_view": "Wyświetlanie listy moderatorów",
+ "@vitnode/core:staff_moderators:can_create": "Tworzenie moderatorów",
+ "@vitnode/core:staff_moderators:can_edit": "Edytowanie uprawnień moderatorów",
+ "@vitnode/core:staff_moderators:can_delete": "Usuwanie moderatorów",
+ "@vitnode/core:staff_admins": "Zespół: Administratorzy",
+ "@vitnode/core:staff_admins:can_view": "Wyświetlanie listy administratorów",
+ "@vitnode/core:staff_admins:can_create": "Tworzenie administratorów",
+ "@vitnode/core:staff_admins:can_edit": "Edytowanie uprawnień administratorów",
+ "@vitnode/core:staff_admins:can_delete": "Usuwanie administratorów",
+ "core": {
+ "global": {
+ "close": "Zamknij",
+ "confirm": "Potwierdź",
+ "previous": "Poprzedni",
+ "next": "Następny",
+ "current_page": "Bieżąca strona",
+ "go_to_page": "Przejdź do strony",
+ "previous_page": "Poprzednia strona",
+ "next_page": "Następna strona",
+ "remove": "Usuń",
+ "editor": {
+ "undo": "Cofnij",
+ "redo": "Ponów",
+ "paragraph": "Akapit",
+ "heading": "Nagłówek {level}",
+ "bold": "Pogrubienie",
+ "italic": "Kursywa",
+ "underline": "Podkreślenie",
+ "ordered_list": "Lista numerowana",
+ "bullet_list": "Lista punktowana",
+ "text_format_more": {
+ "label": "Więcej formatów tekstu",
+ "strike": "Przekreślenie"
+ },
+ "alignments": {
+ "left": "Wyrównaj do lewej",
+ "center": "Wyśrodkuj",
+ "right": "Wyrównaj do prawej",
+ "justify": "Wyjustuj"
+ }
+ },
+ "theme_switcher": "Przełącz motyw",
+ "language_switcher": "Zmień język",
+ "toggle_sidebar": "Przełącz pasek boczny",
+ "no_results": {
+ "title": "Nie znaleziono wyników",
+ "desc": "Spróbuj zmienić kryteria wyszukiwania lub filtrowania."
+ },
+ "are_you_sure_want_to_leave_form": {
+ "title": "Czy na pewno chcesz opuścić ten formularz?",
+ "desc": "Twoje zmiany nie zostaną zapisane.",
+ "cancel": "Anuluj",
+ "confirm": "Tak, opuść"
+ },
+ "confirm_action": {
+ "title": "Czy jesteś pewien?",
+ "desc": "Tej akcji nie można cofnąć.",
+ "cancel": "Anuluj",
+ "confirm": "Tak, potwierdź"
+ },
+ "search_placeholder": "Szukaj...",
+ "results_not_found": "Nie znaleziono wyników",
+ "selected_count": "Wybrano: {count}",
+ "clear_filters": "Wyczyść filtry",
+ "date": "{date, date}",
+ "date_medium": "{date, date, medium}",
+ "date_short": "{date, date, short}",
+ "register": "Zarejestruj się",
+ "login": "Zaloguj się",
+ "save": "Zapisz",
+ "submit": "Wyślij",
+ "cancel": "Anuluj",
+ "optional": "Opcjonalne",
+ "loading": "Ładowanie...",
+ "or": "lub",
+ "back_home": "Powrót do strony głównej",
+ "go_back": "Wróć",
+ "select_option": "Wybierz opcję",
+ "select_options": "Wybierz opcje",
+ "go_to_prev_page": "Przejdź do poprzedniej strony",
+ "go_to_next_page": "Przejdź do następnej strony",
+ "errors": {
+ "title": "Ups! Coś poszło nie tak.",
+ "internal_server_error": "Wewnętrzny błąd serwera.",
+ "field_required": "To pole jest wymagane.",
+ "field_min_length": "To pole musi mieć co najmniej {min} znaków.",
+ "captcha_internal_error": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie później.",
+ "404": {
+ "title": "Nie znaleziono strony",
+ "desc": "Ups! Strona, której szukasz, nie istnieje."
+ },
+ "500": {
+ "title": "Wewnętrzny błąd serwera",
+ "desc": "Przepraszamy, występują problemy techniczne po stronie serwera."
+ },
+ "400": {
+ "title": "Nieprawidłowe żądanie",
+ "desc": "Żądania nie udało się przetworzyć z powodu nieprawidłowych parametrów."
+ },
+ "403": {
+ "title": "Brak dostępu",
+ "desc": "Nie masz uprawnień do dostępu do tego zasobu."
+ },
+ "409": {
+ "title": "Konflikt",
+ "desc": "Nie można było ukończyć żądania z powodu konfliktu z bieżącym stanem zasobu."
+ }
+ },
+ "user_bar": {
+ "my_profile": "Mój profil",
+ "log_out": "Wyloguj się",
+ "mod_cp": "Panel moderatora",
+ "admin_cp": "Panel administracyjny",
+ "settings": "Ustawienia",
+ "files": "Moje pliki"
+ }
+ },
+ "files": {
+ "title": "Moje pliki",
+ "desc": "Pliki przesłane na Twoje konto.",
+ "list": {
+ "preview": "Podgląd",
+ "name": "Nazwa",
+ "folder": "Folder",
+ "size": "Rozmiar",
+ "dimensions": "Wymiary",
+ "metadata": "Metadane",
+ "createdAt": "Przesłano"
+ },
+ "metadata": {
+ "title": "Metadane",
+ "empty": "—"
+ },
+ "actions": {
+ "download": "Pobierz",
+ "delete": "Usuń"
+ },
+ "download": {
+ "error": "Nie udało się pobrać pliku."
+ },
+ "delete": {
+ "title": "Usuń plik",
+ "desc": "Czy na pewno chcesz usunąć ten plik? Spowoduje to jego trwałe usunięcie i nie można tego cofnąć.",
+ "confirm": "Usuń",
+ "success": "Plik został usunięty."
+ },
+ "noResults": {
+ "title": "Brak plików",
+ "description": "Przesłane pliki pojawią się tutaj."
+ }
+ },
+ "auth": {
+ "sso": {
+ "or": "Lub kontynuuj przez",
+ "access_denied": "Odmówiono dostępu do aplikacji lub żądanie wygasło. Spróbuj ponownie.",
+ "email_exists": {
+ "title": "Nie możesz zalogować się przez ",
+ "desc": "Konto z tym adresem e-mail już istnieje. Zaloguj się inną metodą i połącz swoje konto w ustawieniach profilu.",
+ "sign_in": "Przejdź do strony logowania"
+ }
+ },
+ "sign_up": {
+ "desc": "Witaj! Utwórz konto, aby rozpocząć.",
+ "already_have_account": "Masz już konto? Zaloguj się.",
+ "submit": "Zarejestruj się",
+ "username": {
+ "label": "Nazwa użytkownika",
+ "min_length": "Nazwa użytkownika musi mieć co najmniej 3 znaki.",
+ "max_length": "Nazwa użytkownika może mieć maksymalnie 32 znaki.",
+ "exists": "Nazwa użytkownika już istnieje.",
+ "your_user_code": "Twój kod użytkownika: "
+ },
+ "email": {
+ "label": "Adres e-mail",
+ "invalid": "Nieprawidłowy adres e-mail.",
+ "exists": "Adres e-mail już istnieje."
+ },
+ "password": {
+ "label": "Hasło",
+ "invalid": "Hasło jest zbyt słabe.",
+ "requirements": {
+ "label": "Hasło powinno zawierać:",
+ "min_length": "Co najmniej 8 znaków",
+ "uppercase": "Co najmniej jedną wielką literę",
+ "number": "Co najmniej jedną cyfrę",
+ "special_char": "Co najmniej jeden znak specjalny"
+ }
+ },
+ "terms": {
+ "label": "Akceptuję regulamin",
+ "required": "Musisz zaakceptować regulamin.",
+ "desc": "Wyrażasz zgodę na nasze dokumenty prawne i zasady."
+ },
+ "newsletter": {
+ "label": "Newsletter",
+ "desc": "Otrzymuj najnowsze wiadomości i aktualizacje."
+ },
+ "email_confirmation": {
+ "title": "Sprawdź swoją skrzynkę e-mail",
+ "desc": "Wysłaliśmy link potwierdzający na Twój adres e-mail",
+ "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem."
+ }
+ },
+ "sign_in": {
+ "desc": "Witaj ponownie! Zaloguj się na swoje konto.",
+ "do_not_have_account": "Nie masz konta? Zarejestruj się.",
+ "email": {
+ "label": "Adres e-mail",
+ "invalid": "Nieprawidłowy adres e-mail."
+ },
+ "password": {
+ "label": "Hasło",
+ "required": "Hasło jest wymagane.",
+ "reset": "Nie pamiętasz hasła?"
+ },
+ "errors": {
+ "access_denied": {
+ "title": "Nieprawidłowe dane logowania",
+ "desc": "Adres e-mail lub hasło były nieprawidłowe. Spróbuj ponownie (upewnij się, że Caps Lock jest wyłączony)."
+ }
+ },
+ "submit": "Zaloguj się"
+ },
+ "reset_password": {
+ "title": "Zresetuj hasło",
+ "desc": "Wprowadź poniżej swój adres e-mail, aby otrzymać link do resetowania hasła.",
+ "submit": "Wyślij link resetujący",
+ "confirmation": {
+ "title": "Sprawdź swoją skrzynkę e-mail",
+ "desc": "Wysłaliśmy link do resetowania hasła na Twój adres e-mail:",
+ "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem."
+ },
+ "email": {
+ "subject": "Zresetuj swoje hasło",
+ "greeting": "Witaj {name}!",
+ "intro": "Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta.",
+ "button": "Zresetuj hasło",
+ "instructions": "Kliknij powyższy przycisk, aby zresetować hasło. Ze względów bezpieczeństwa link wygaśnie za 30 minut.",
+ "no_action": "Jeśli nie prosiłeś o zresetowanie hasła, możesz bezpiecznie zignorować tę wiadomość. Twoje hasło pozostanie bez zmian.",
+ "security_note": "Ze względów bezpieczeństwa: to żądanie zostało wysłane z adresu IP: {ip}",
+ "help": "Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy adres URL do przeglądarki:",
+ "expire_time": "Ten link wygaśnie {date}"
+ }
+ },
+ "change_password": {
+ "title": "Zmień hasło",
+ "desc": "Wprowadź poniżej swoje nowe hasło.",
+ "submit": "Zmień hasło",
+ "success": {
+ "title": "Hasło zostało pomyślnie zmienione",
+ "desc": "Możesz teraz zalogować się przy użyciu nowego hasła."
+ }
+ },
+ "settings": {
+ "title": "Ustawienia",
+ "desc": "Zarządzaj swoim profilem, zabezpieczeniami i preferencjami konta.",
+ "nav": {
+ "overview": "Przegląd",
+ "security": "Zabezpieczenia"
+ }
+ }
+ }
+ },
+ "admin": {
+ "dashboard": {
+ "dev_mode": "Tryb deweloperski",
+ "version": "Wersja: {version}"
+ },
+ "global": {
+ "nav": {
+ "core": "Rdzeń",
+ "dashboard": "Pulpit",
+ "users": {
+ "title": "Użytkownicy",
+ "list": "Lista użytkowników",
+ "roles": "Role"
+ },
+ "staff": {
+ "title": "Zespół",
+ "moderators": "Moderatorzy",
+ "admins": "Administratorzy"
+ },
+ "user_bar": {
+ "home_page": "Strona główna",
+ "debug": "Panel debugowania",
+ "log_out": "Wyloguj się"
+ },
+ "system": {
+ "title": "System",
+ "integrations": "Integracje",
+ "files": "Pliki"
+ },
+ "advanced": {
+ "title": "Zaawansowane",
+ "cron": "Zadania cron",
+ "queue": "Zadania w kolejce"
+ }
+ }
+ },
+ "advanced": {
+ "cron": {
+ "title": "Zadania cron",
+ "desc": "Zarządzaj i monitoruj zaplanowane zadania.",
+ "list": {
+ "name": "Nazwa",
+ "pluginId": "ID wtyczki",
+ "module": "Moduł",
+ "schedule": "Harmonogram",
+ "lastRun": {
+ "title": "Ostatnie uruchomienie",
+ "never": "Nigdy"
+ },
+ "nextRun": {
+ "title": "Następne uruchomienie",
+ "never": "Nigdy"
+ },
+ "actions": {
+ "runNow": {
+ "label": "Uruchom zadanie teraz",
+ "success": "Zadanie cron zostało pomyślnie wykonane."
+ }
+ }
+ }
+ },
+ "queue": {
+ "title": "Zadania w kolejce",
+ "desc": "Monitoruj zadania w tle oczekujące na przetworzenie przez proces cron.",
+ "list": {
+ "name": "Zadanie",
+ "queue": "Kolejka",
+ "status": "Status",
+ "attempts": "Próby",
+ "availableAt": "Dostępne od",
+ "createdAt": "Utworzono",
+ "lastError": "Ostatni błąd",
+ "statusFilter": "Status"
+ },
+ "status": {
+ "pending": "Oczekujące",
+ "processing": "Przetwarzanie",
+ "completed": "Ukończone",
+ "failed": "Nieudane"
+ }
+ }
+ },
+ "user": {
+ "list": {
+ "desc": "Zarządzaj użytkownikami swojej aplikacji.",
+ "user": "Użytkownik",
+ "createdAt": "Utworzono",
+ "emailNotVerified": "E-mail niezweryfikowany",
+ "edit": "Edytuj profil",
+ "roles": "Role",
+ "email": "Adres e-mail",
+ "searchPlaceholder": "Szukaj użytkowników po adresie e-mail lub nazwie...",
+ "noResults": {
+ "title": "Nie znaleziono użytkowników",
+ "description": "Spróbuj zmienić kryteria wyszukiwania."
+ }
+ },
+ "show": {
+ "title": "Profil użytkownika",
+ "joined": "Dołączył",
+ "emailNotVerified": "E-mail niezweryfikowany",
+ "coverPlaceholder": "Brak obrazu tła",
+ "editCover": "Edytuj obraz tła",
+ "editAvatar": "Edytuj awatar",
+ "editName": "Edytuj nazwę użytkownika",
+ "editEmail": "Edytuj adres e-mail",
+ "uploadImage": "Prześlij obraz",
+ "removeImage": "Usuń obraz",
+ "goToProfile": "Przejdź do profilu publicznego",
+ "updateSuccess": "Profil został zaktualizowany.",
+ "editNameCode": "Edytuj kod nazwy",
+ "editNameCodeDesc": "Kod nazwy to unikalny identyfikator używany w adresie URL profilu tego użytkownika oraz w oznaczeniach @wzmianka.",
+ "editNameCodeWarningTitle": "Ta zmiana uszkodzi istniejące linki",
+ "editNameCodeWarning": "Zmiana kodu nazwy uszkodzi istniejące linki @wzmianek i zmieni adres URL profilu publicznego. Osoby korzystające ze starego linku nie dotrą już do tego profilu.",
+ "newNameCode": "Nowy kod nazwy",
+ "confirmNameCode": "Wpisz bieżący kod nazwy (), aby potwierdzić",
+ "nameCodeInvalid": "Dozwolone są tylko litery, cyfry i myślniki.",
+ "nameCodeSame": "Nowy kod nazwy musi różnić się od bieżącego.",
+ "nameCodeConfirmMismatch": "To nie zgadza się z bieżącym kodem nazwy.",
+ "nameCodeExists": "Ten kod nazwy jest już zajęty.",
+ "saveNameCode": "Zmień kod nazwy",
+ "rolesTitle": "Role",
+ "primaryRole": "Rola główna",
+ "secondaryRoles": "Role dodatkowe",
+ "editRoles": "Edytuj role",
+ "editRolesDesc": "Ustaw rolę główną tego użytkownika oraz dowolne role dodatkowe. Rola główna jest unikalna, natomiast role dodatkowe można łączyć.",
+ "selectRole": "Wybierz rolę",
+ "addSecondaryRole": "Dodaj rolę",
+ "removeRole": "Usuń rolę",
+ "saveRoles": "Zapisz role"
+ },
+ "verify_email": {
+ "label": "Zweryfikuj adres e-mail",
+ "success": "Adres e-mail został zweryfikowany."
+ },
+ "create": {
+ "title": "Dodaj użytkownika",
+ "desc": "Utwórz nowe konto dla użytkownika.",
+ "name": {
+ "label": "Nazwa użytkownika",
+ "min_length": "Nazwa użytkownika musi mieć co najmniej 3 znaki.",
+ "max_length": "Nazwa użytkownika może mieć maksymalnie 32 znaki.",
+ "exists": "Ta nazwa użytkownika jest już zajęta."
+ },
+ "email": {
+ "label": "Adres e-mail",
+ "invalid": "Wprowadź prawidłowy adres e-mail.",
+ "exists": "Ten adres e-mail jest już zarejestrowany."
+ },
+ "password": {
+ "label": "Hasło",
+ "invalid": "Hasło musi mieć co najmniej 8 znaków."
+ },
+ "submit": "Utwórz użytkownika",
+ "success": "Użytkownik „{name}” został utworzony."
+ }
+ },
+ "role": {
+ "list": {
+ "desc": "Zarządzaj rolami w swojej aplikacji.",
+ "role": "Rola",
+ "usersCount": "Liczba użytkowników",
+ "updatedAt": "Zaktualizowano",
+ "searchPlaceholder": "Szukaj ról po nazwie...",
+ "openUsersTooltip": "Wyświetl użytkowników z tą rolą",
+ "noResults": {
+ "title": "Nie znaleziono ról",
+ "description": "Spróbuj zmienić kryteria wyszukiwania."
+ }
+ }
+ },
+ "staff": {
+ "title": "Zespół",
+ "desc": "Zarządzaj zespołem swojej aplikacji.",
+ "protected": "Chroniony",
+ "self": "Nie możesz edytować własnych uprawnień",
+ "delete": {
+ "title": "Usunąć członka zespołu?",
+ "desc": "Spowoduje to odebranie przyznanego dostępu do zespołu. Tej akcji nie można cofnąć.",
+ "confirm": "Tak, usuń",
+ "success": "Członek zespołu został usunięty."
+ },
+ "tabs": {
+ "moderators": "Moderatorzy",
+ "admins": "Administratorzy"
+ },
+ "table": {
+ "role": "Rola",
+ "user": "Użytkownik",
+ "permissions": "Uprawnienia",
+ "unrestricted": "Bez ograniczeń",
+ "restricted": "Ograniczone",
+ "updatedAt": "Zaktualizowano",
+ "edit": "Edytuj uprawnienia"
+ },
+ "edit": {
+ "title": "Edytuj uprawnienia",
+ "subject": "Dla",
+ "back": "Wstecz",
+ "save": "Zapisz zmiany",
+ "success": "Uprawnienia zostały pomyślnie zaktualizowane.",
+ "error": "Nie udało się zaktualizować uprawnień.",
+ "protected": "Ten wpis jest chroniony i jego uprawnień nie można edytować.",
+ "self": "Nie możesz edytować własnych uprawnień zespołu, w tym wpisu dla swojej roli głównej.",
+ "no_permissions": "Żadna wtyczka nie zadeklarowała jeszcze uprawnień zespołu.",
+ "select_all": "Włącz wszystkie",
+ "clear_all": "Wyłącz wszystkie",
+ "search_plugins": "Szukaj wtyczek",
+ "search_empty": "Żadna wtyczka nie pasuje do wyszukiwania.",
+ "granted": "przyznano {granted}/{total}",
+ "requires": "Wymaga {permission}",
+ "mode": {
+ "label": "Poziom dostępu",
+ "unrestricted": {
+ "label": "Bez ograniczeń",
+ "desc": "Przyznaj wszystkie uprawnienia, w tym te dodane w przyszłości."
+ },
+ "restricted": {
+ "label": "Ograniczone",
+ "desc": "Wybierz dokładnie, które uprawnienia mają obowiązywać."
+ }
+ }
+ },
+ "create": {
+ "admins": "Dodaj administratora",
+ "moderators": "Dodaj moderatora",
+ "desc": "Przyznaj dostęp do zespołu roli lub konkretnemu użytkownikowi.",
+ "button": "Dodaj członka",
+ "back": "Wstecz",
+ "assign_to": "Przypisz do",
+ "tabs": {
+ "role": "Rola",
+ "role_desc": "Przyznaj dostęp do zespołu wszystkim osobom z daną rolą.",
+ "user": "Użytkownik",
+ "user_desc": "Przyznaj dostęp do zespołu jednej osobie."
+ },
+ "select_role": "Wybierz rolę",
+ "search_user": "Szukaj po nazwie lub adresie e-mail",
+ "submit": "Dodaj członka",
+ "success": "Członek zespołu został dodany.",
+ "error": "Nie udało się dodać członka zespołu.",
+ "already_exists": "Ta rola lub ten użytkownik jest już członkiem zespołu."
+ },
+ "moderators": {
+ "title": "Moderatorzy",
+ "desc": "Zarządzaj moderatorami swojej aplikacji.",
+ "create": "Dodaj moderatora",
+ "noResults": {
+ "title": "Nie znaleziono moderatorów",
+ "description": "Przypisz rolę lub użytkownika, aby przyznać uprawnienia moderatora."
+ }
+ },
+ "admins": {
+ "title": "Administratorzy",
+ "desc": "Zarządzaj administratorami swojej aplikacji.",
+ "create": "Dodaj administratora",
+ "noResults": {
+ "title": "Nie znaleziono administratorów",
+ "description": "Przypisz rolę lub użytkownika, aby przyznać uprawnienia administratora."
+ }
+ }
+ },
+ "system": {
+ "integrations": {
+ "title": "Integracje",
+ "desc": "Status podstawowych usług obsługujących tę instancję.",
+ "read_more": "Czytaj więcej",
+ "status": {
+ "active": "Aktywna",
+ "inactive": "Nieaktywna",
+ "warning": "Nieosiągalna"
+ },
+ "websocket": {
+ "title": "WebSocket",
+ "desc": "Aktualizacje na żywo i powiadomienia wysyłane do połączonych klientów w czasie rzeczywistym."
+ },
+ "redis": {
+ "title": "Redis",
+ "desc": "Współdzielona pamięć podręczna i magazyn ogranicznika żądań. Przy wyłączeniu przechodzi na pamięć wewnętrzną.",
+ "down": "Skonfigurowany, ale nieosiągalny — sprawdź połączenie z Redis."
+ },
+ "email": {
+ "title": "E-mail",
+ "desc": "Wiadomości transakcyjne, takie jak potwierdzenia rejestracji i resetowanie hasła.",
+ "test": {
+ "label": "Testowa wiadomość e-mail",
+ "title": "Wyślij testową wiadomość e-mail",
+ "desc": "Wyślij wiadomość e-mail na dowolny adres, aby potwierdzić, że adapter e-mail działa.",
+ "info": {
+ "title": "Jak zweryfikować konfigurację",
+ "desc": "Jeśli wiadomość dotrze do skrzynki odbiorczej, adapter e-mail jest skonfigurowany poprawnie. Jeśli się nie pojawi, sprawdź ustawienia adaptera oraz logi systemowe."
+ },
+ "to": {
+ "label": "Odbiorca",
+ "invalid": "Wprowadź prawidłowy adres e-mail."
+ },
+ "subject": {
+ "label": "Temat",
+ "default": "Testowa wiadomość e-mail VitNode"
+ },
+ "content": {
+ "label": "Treść",
+ "default": "To jest testowa wiadomość e-mail wysłana z panelu administracyjnego VitNode."
+ },
+ "submit": "Wyślij testową wiadomość e-mail",
+ "success": "Testowa wiadomość e-mail została wysłana. Sprawdź skrzynkę odbiorczą odbiorcy, aby potwierdzić konfigurację."
+ }
+ },
+ "storage": {
+ "title": "Magazyn",
+ "desc": "Przesyłanie plików, takich jak awatary i załączniki, przechowywanych za pomocą adaptera magazynu.",
+ "test": {
+ "label": "Testuj magazyn",
+ "title": "Testuj magazyn plików",
+ "desc": "Prześlij plik i wykonaj test pełnego obiegu, aby potwierdzić, że adapter magazynu działa.",
+ "info": {
+ "title": "Jak zweryfikować konfigurację",
+ "desc": "Prześlij testowy obraz. Jeśli operacja się powiedzie, adapter magazynu jest skonfigurowany poprawnie. Jeśli się nie powiedzie, sprawdź ustawienia adaptera oraz logi systemowe."
+ },
+ "upload": {
+ "label": "Prześlij testowy obraz",
+ "desc": "Kliknij, aby wybrać obraz",
+ "pending": "Przesyłanie…",
+ "done": "Przesłano",
+ "error": "Przesyłanie nie powiodło się — spróbuj ponownie",
+ "success": "Obraz został przesłany — jest przechowywany w Twoim adapterze."
+ }
+ }
+ },
+ "captcha": {
+ "title": "Captcha",
+ "desc": "Ochrona przed botami w publicznych formularzach, takich jak rejestracja i resetowanie hasła.",
+ "type": {
+ "cloudflare_turnstile": "Cloudflare Turnstile",
+ "recaptcha_v3": "reCAPTCHA v3"
+ }
+ },
+ "cron": {
+ "title": "Zadania cron",
+ "desc": "Zaplanowane zadania w tle, takie jak czyszczenie wygasłych sesji i tokenów.",
+ "insecure": "Używany jest domyślny CRON_SECRET — ustaw bezpieczny w środowisku produkcyjnym.",
+ "not_configured": "Nie skonfigurowano adaptera cron — zadania nie będą uruchamiać się automatycznie.",
+ "stale": "Żadne zadanie nie zostało uruchomione od ponad 6 godzin — cron może być źle skonfigurowany lub zatrzymany. Sprawdź konfigurację cron.",
+ "jobs": "{count, plural, =0 {Brak zaplanowanych zadań} one {# zaplanowane zadanie} few {# zaplanowane zadania} many {# zaplanowanych zadań} other {# zaplanowanych zadań}}"
+ },
+ "queue": {
+ "title": "Zadania w kolejce",
+ "desc": "Zadania w tle umieszczone w kolejce w bazie danych i przetwarzane przez proces cron.",
+ "tasks": "{count, plural, =0 {Brak zarejestrowanych zadań} one {# zarejestrowane zadanie} few {# zarejestrowane zadania} many {# zarejestrowanych zadań} other {# zarejestrowanych zadań}}",
+ "queued": "{pending} oczekujących · {processing} uruchomionych",
+ "cron_stale": "Offline — proces cron przetwarzający kolejkę nie działa. Napraw zadania cron, aby wznowić przetwarzanie."
+ }
+ },
+ "files": {
+ "title": "Pliki",
+ "desc": "Przeglądaj, pobieraj i usuwaj pliki przesłane do tej instancji.",
+ "list": {
+ "preview": "Podgląd",
+ "name": "Nazwa",
+ "folder": "Folder",
+ "size": "Rozmiar",
+ "dimensions": "Wymiary",
+ "uploadedBy": "Przesłane przez",
+ "metadata": "Metadane",
+ "createdAt": "Przesłano",
+ "no_preview": "Brak podglądu"
+ },
+ "anonymous": "—",
+ "metadata": {
+ "empty": "—",
+ "title": "Metadane"
+ },
+ "actions": {
+ "download": "Pobierz",
+ "delete": "Usuń"
+ },
+ "download": {
+ "error": "Nie udało się pobrać pliku."
+ },
+ "delete": {
+ "title": "Usuń plik",
+ "desc": "Czy na pewno chcesz usunąć ten plik? Spowoduje to jego trwałe usunięcie z magazynu i nie można tego cofnąć.",
+ "confirm": "Usuń",
+ "success": "Plik został usunięty."
+ },
+ "noResults": {
+ "title": "Nie przesłano żadnych plików",
+ "description": "Pliki przesłane w obrębie witryny pojawią się tutaj."
+ }
+ }
+ },
+ "debug": {
+ "title": "Panel debugowania",
+ "desc": "Sprawdzaj logi, błędy i inne informacje debugowania.",
+ "queue": {
+ "title": "Zadania w kolejce",
+ "empty": "Brak aktywnych zadań w kolejce.",
+ "counts": {
+ "pending": "Oczekujące",
+ "processing": "Przetwarzanie",
+ "completed": "Ukończone",
+ "failed": "Nieudane"
+ },
+ "list": {
+ "name": "Zadanie",
+ "queue": "Kolejka",
+ "status": "Status",
+ "attempts": "Próby",
+ "availableAt": "Dostępne od"
+ }
+ },
+ "actions": {
+ "clear_cache": {
+ "label": "Wyczyść pamięć podręczną",
+ "title": "Czy na pewno chcesz wyczyścić pamięć podręczną?",
+ "desc": "Ta akcja usunie wszystkie dane z pamięci podręcznej, co może tymczasowo wpłynąć na wydajność.",
+ "success": "Pamięć podręczna została pomyślnie wyczyszczona.",
+ "confirm": "Tak, wyczyść pamięć podręczną"
+ }
+ },
+ "logs": {
+ "title": "Logi systemowe",
+ "created_at": "Utworzono",
+ "plugin": "Wtyczka",
+ "content": "Treść",
+ "type": "Typ",
+ "types": {
+ "warn": "Ostrzeżenie",
+ "error": "Błąd",
+ "debug": "Debug"
+ },
+ "status_code": "Kod statusu",
+ "more": {
+ "title": "Więcej szczegółów",
+ "desc": "ID logu: ",
+ "log_overview": {
+ "title": "Przegląd logu",
+ "log_type": "Typ logu",
+ "status_code": "Kod statusu",
+ "log_id": "ID logu",
+ "created_at": "Utworzono",
+ "plugin": "Wtyczka",
+ "user": "Użytkownik"
+ },
+ "request_information": {
+ "title": "Informacje o żądaniu",
+ "ip_address": "Adres IP",
+ "request_method": "Metoda żądania",
+ "request_url": "Adres URL żądania",
+ "user_agent": "Agent użytkownika"
+ },
+ "log_content": {
+ "title": "Treść logu",
+ "full_log": "Pełna treść logu"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/src/vitnode.config.ts b/apps/docs/src/vitnode.config.ts
index 708f35f2f..31dbee076 100644
--- a/apps/docs/src/vitnode.config.ts
+++ b/apps/docs/src/vitnode.config.ts
@@ -15,6 +15,10 @@ export const vitNodeConfig = buildConfig({
code: "en",
name: "English",
},
+ {
+ code: "pl",
+ name: "Polski",
+ },
],
defaultLocale: "en",
},
diff --git a/packages/vitnode/src/api/lib/save-language-words.test.ts b/packages/vitnode/src/api/lib/save-language-words.test.ts
new file mode 100644
index 000000000..9247a8c0b
--- /dev/null
+++ b/packages/vitnode/src/api/lib/save-language-words.test.ts
@@ -0,0 +1,80 @@
+// @vitest-environment node
+import type { Context } from "hono";
+
+import { describe, expect, it, vi } from "vitest";
+
+import { core_languages_words } from "@/database/languages";
+
+import { saveLanguageWords } from "./save-language-words";
+
+const createDbMock = () => {
+ const insertValues = vi.fn();
+ const where = vi.fn();
+ const tx = {
+ delete: vi.fn(() => ({ where })),
+ insert: vi.fn(() => ({ values: insertValues })),
+ };
+ const db = {
+ transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => cb(tx)),
+ };
+
+ return { db, tx, insertValues, where };
+};
+
+const createContext = (db: unknown): Context =>
+ ({ get: (key: string) => (key === "db" ? db : undefined) }) as Context;
+
+describe("saveLanguageWords", () => {
+ it("replaces the tuple rows: delete then insert the mapped values", async () => {
+ const { db, tx, insertValues } = createDbMock();
+
+ await saveLanguageWords(createContext(db), {
+ pluginCode: "blog",
+ tableName: "blog_categories",
+ variable: "name",
+ itemId: 7,
+ values: [
+ { languageCode: "en", value: "News" },
+ { languageCode: "pl", value: "Aktualności" },
+ ],
+ });
+
+ expect(db.transaction).toHaveBeenCalledOnce();
+ expect(tx.delete).toHaveBeenCalledWith(core_languages_words);
+ expect(tx.insert).toHaveBeenCalledWith(core_languages_words);
+ expect(insertValues).toHaveBeenCalledWith([
+ {
+ languageCode: "en",
+ pluginCode: "blog",
+ itemId: 7,
+ value: "News",
+ tableName: "blog_categories",
+ variable: "name",
+ },
+ {
+ languageCode: "pl",
+ pluginCode: "blog",
+ itemId: 7,
+ value: "Aktualności",
+ tableName: "blog_categories",
+ variable: "name",
+ },
+ ]);
+ });
+
+ it("deletes but does not insert when values is empty", async () => {
+ const { db, tx, insertValues } = createDbMock();
+
+ await saveLanguageWords(createContext(db), {
+ pluginCode: "blog",
+ tableName: "blog_categories",
+ variable: "name",
+ itemId: 7,
+ values: [],
+ });
+
+ expect(tx.delete).toHaveBeenCalledOnce();
+ expect(tx.insert).not.toHaveBeenCalled();
+ expect(insertValues).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/vitnode/src/api/lib/save-language-words.ts b/packages/vitnode/src/api/lib/save-language-words.ts
new file mode 100644
index 000000000..98d6613f1
--- /dev/null
+++ b/packages/vitnode/src/api/lib/save-language-words.ts
@@ -0,0 +1,54 @@
+import type { Context } from "hono";
+
+import { and, eq } from "drizzle-orm";
+
+import type { MultiLangValue } from "@/lib/helpers/multi-lang";
+
+import { core_languages_words } from "@/database/languages";
+
+export interface SaveLanguageWordsArgs {
+ itemId: number;
+ pluginCode: string;
+ tableName: string;
+ values: MultiLangValue;
+ variable: string;
+}
+
+// Write mirror of `resolveRoleNames`: persists a `multiLang` field value (the
+// `{ languageCode, value }[]` produced by the form) into `core_languages_words`.
+// The remaining columns - the `(pluginCode, tableName, variable, itemId)` tuple
+// that identifies the field - are supplied here by the backend. There is no
+// unique constraint on that tuple, so the write replaces the existing rows
+// (delete + insert) inside a single transaction.
+export const saveLanguageWords = async (
+ c: Context,
+ { pluginCode, tableName, variable, itemId, values }: SaveLanguageWordsArgs,
+): Promise => {
+ await c.get("db").transaction(async tx => {
+ await tx
+ .delete(core_languages_words)
+ .where(
+ and(
+ eq(core_languages_words.pluginCode, pluginCode),
+ eq(core_languages_words.tableName, tableName),
+ eq(core_languages_words.variable, variable),
+ eq(core_languages_words.itemId, itemId),
+ ),
+ );
+
+ if (values.length === 0) {
+ return;
+ }
+
+ await tx.insert(core_languages_words).values(
+ values.map(({ languageCode, value }) => ({
+ languageCode,
+ pluginCode,
+ itemId,
+ value,
+ tableName,
+ variable,
+ })),
+ );
+ });
+};
diff --git a/packages/vitnode/src/components/form/fields/editor.test.tsx b/packages/vitnode/src/components/form/fields/editor.test.tsx
index dcd0bcb55..252796926 100644
--- a/packages/vitnode/src/components/form/fields/editor.test.tsx
+++ b/packages/vitnode/src/components/form/fields/editor.test.tsx
@@ -1,7 +1,8 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { type FieldValues, useForm } from "react-hook-form";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { LanguagesProvider } from "@/components/languages-provider";
import { Form, FormField } from "@/components/ui/form";
import { AutoFormEditor } from "./editor";
@@ -111,3 +112,129 @@ describe("AutoFormEditor", () => {
expect(editor.getAttribute("aria-invalid")).toBe("false");
});
});
+
+const LANGUAGES = [
+ { code: "en", name: "English" },
+ { code: "pl", name: "Polski" },
+];
+
+const MultiLangHarness = ({
+ onSubmit = vi.fn(),
+ defaultValue,
+ languages = LANGUAGES,
+}: {
+ defaultValue?: unknown;
+ languages?: { code: string; name: string }[];
+ onSubmit?: (values: FieldValues) => void;
+}) => {
+ const form = useForm({
+ defaultValues: { content: defaultValue } as FieldValues,
+ });
+
+ return (
+
+
+
+ );
+};
+
+describe("AutoFormEditor multiLang", () => {
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.hasPointerCapture = vi.fn(() => false);
+ Element.prototype.setPointerCapture = vi.fn();
+ Element.prototype.releasePointerCapture = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the language select when more than one language is enabled", () => {
+ render();
+
+ expect(screen.getByRole("combobox")).toBeDefined();
+ });
+
+ it("does not render the language select for a single language", () => {
+ render();
+
+ expect(screen.queryByRole("combobox")).toBeNull();
+ });
+
+ it("writes editor content as a { languageCode, value }[] array", async () => {
+ const onSubmit = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByTestId("editor"), {
+ target: { value: "