From c6606d96026c6a1108ad0aabd2499fa16e00c327 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Mon, 18 May 2026 00:16:37 +0330 Subject: [PATCH] Implemented project-wide localization plumbing and converted the visible UI/app messages to translation-ready strings. --- .../AdminChannelCreationController.php | 8 +- .../Controllers/AdminModerationController.php | 10 +- .../AdminSupportConversationController.php | 10 +- .../Controllers/ChatMessageController.php | 2 +- .../CreatorDashboardController.php | 16 +- .../SupportConversationController.php | 10 +- app/Http/Middleware/HandleInertiaRequests.php | 33 ++ app/Models/SupportConversation.php | 8 +- app/Rules/SupportAttachmentFile.php | 6 +- lang/en.json | 323 ++++++++++++++++++ lang/en/auth.php | 20 ++ lang/en/pagination.php | 19 ++ lang/en/passwords.php | 22 ++ lang/en/validation.php | 200 +++++++++++ resources/js/components/alert-error.tsx | 5 +- resources/js/components/app-header.tsx | 12 +- resources/js/components/appearance-tabs.tsx | 4 +- resources/js/components/breadcrumbs.tsx | 7 +- resources/js/components/delete-user.tsx | 19 +- resources/js/components/heading.tsx | 10 +- resources/js/components/nav-main.tsx | 8 +- resources/js/components/password-input.tsx | 4 +- .../components/two-factor-recovery-codes.tsx | 31 +- .../js/components/two-factor-setup-modal.tsx | 33 +- resources/js/components/ui/breadcrumb.tsx | 9 +- resources/js/components/ui/button.tsx | 7 +- resources/js/components/ui/dialog.tsx | 5 +- resources/js/components/ui/label.tsx | 8 +- resources/js/components/ui/sheet.tsx | 5 +- resources/js/components/ui/sidebar.tsx | 16 +- resources/js/components/ui/spinner.tsx | 5 +- resources/js/components/user-menu-content.tsx | 6 +- resources/js/components/video-player.tsx | 4 +- .../js/layouts/auth/auth-card-layout.tsx | 11 +- .../js/layouts/auth/auth-simple-layout.tsx | 11 +- .../js/layouts/auth/auth-split-layout.tsx | 8 +- resources/js/layouts/settings/layout.tsx | 4 +- resources/js/lib/translations.tsx | 103 ++++++ resources/js/pages/admin/dashboard.tsx | 79 +++-- resources/js/pages/admin/support/index.tsx | 30 +- resources/js/pages/admin/support/show.tsx | 32 +- resources/js/pages/auth/confirm-password.tsx | 7 +- resources/js/pages/auth/forgot-password.tsx | 11 +- resources/js/pages/auth/login.tsx | 15 +- resources/js/pages/auth/register.tsx | 17 +- resources/js/pages/auth/reset-password.tsx | 9 +- .../js/pages/auth/two-factor-challenge.tsx | 24 +- resources/js/pages/auth/verify-email.tsx | 12 +- resources/js/pages/channels/show.tsx | 99 ++++-- resources/js/pages/dashboard.tsx | 123 ++++--- resources/js/pages/settings/appearance.tsx | 7 +- resources/js/pages/settings/profile.tsx | 26 +- resources/js/pages/settings/security.tsx | 25 +- resources/js/pages/support/index.tsx | 27 +- resources/js/pages/support/show.tsx | 17 +- resources/js/pages/welcome.tsx | 103 ++++-- resources/js/types/global.d.ts | 4 + resources/js/types/index.ts | 1 + resources/js/types/localization.ts | 6 + tests/Feature/LocalizationTest.php | 25 ++ 60 files changed, 1376 insertions(+), 345 deletions(-) create mode 100644 lang/en.json create mode 100644 lang/en/auth.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php create mode 100644 resources/js/lib/translations.tsx create mode 100644 resources/js/types/localization.ts create mode 100644 tests/Feature/LocalizationTest.php diff --git a/app/Http/Controllers/AdminChannelCreationController.php b/app/Http/Controllers/AdminChannelCreationController.php index a1af04d..4116fb7 100644 --- a/app/Http/Controllers/AdminChannelCreationController.php +++ b/app/Http/Controllers/AdminChannelCreationController.php @@ -29,8 +29,8 @@ class AdminChannelCreationController extends Controller return back()->with( 'success', $settings->channel_creation_open - ? 'All verified users can create channels.' - : 'Channel creation now requires admin approval.', + ? __('All verified users can create channels.') + : __('Channel creation now requires admin approval.'), ); } @@ -51,8 +51,8 @@ class AdminChannelCreationController extends Controller return back()->with( 'success', $user->can_create_channel - ? 'User can create a channel.' - : 'User channel creation access removed.', + ? __('User can create a channel.') + : __('User channel creation access removed.'), ); } diff --git a/app/Http/Controllers/AdminModerationController.php b/app/Http/Controllers/AdminModerationController.php index aa3453d..2be5ec9 100644 --- a/app/Http/Controllers/AdminModerationController.php +++ b/app/Http/Controllers/AdminModerationController.php @@ -25,7 +25,7 @@ class AdminModerationController extends Controller $this->audit($request, $channel, 'channel.suspended'); - return back()->with('success', 'Channel suspended.'); + return back()->with('success', __('Channel suspended.')); } public function restoreChannel(Request $request, Channel $channel): RedirectResponse @@ -34,7 +34,7 @@ class AdminModerationController extends Controller $this->audit($request, $channel, 'channel.restored'); - return back()->with('success', 'Channel restored.'); + return back()->with('success', __('Channel restored.')); } public function suspendUser(Request $request, User $user, ViewerCountStore $viewerCounts): RedirectResponse @@ -52,7 +52,7 @@ class AdminModerationController extends Controller $this->audit($request, $user, 'user.suspended'); - return back()->with('success', 'User suspended.'); + return back()->with('success', __('User suspended.')); } public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse @@ -71,7 +71,7 @@ class AdminModerationController extends Controller $this->audit($request, $broadcast, 'broadcast.stopped'); - return back()->with('success', 'Broadcast stopped.'); + return back()->with('success', __('Broadcast stopped.')); } public function deleteVod(Request $request, Vod $vod): RedirectResponse @@ -80,7 +80,7 @@ class AdminModerationController extends Controller $this->audit($request, $vod, 'vod.deleted'); - return back()->with('success', 'VOD deleted.'); + return back()->with('success', __('VOD deleted.')); } private function audit(Request $request, object $subject, string $action): void diff --git a/app/Http/Controllers/AdminSupportConversationController.php b/app/Http/Controllers/AdminSupportConversationController.php index 4ef97b0..a9fc204 100644 --- a/app/Http/Controllers/AdminSupportConversationController.php +++ b/app/Http/Controllers/AdminSupportConversationController.php @@ -46,7 +46,7 @@ class AdminSupportConversationController extends Controller public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse { - abort_unless($conversation->isOpen(), 422, 'Reopen this conversation before replying.'); + abort_unless($conversation->isOpen(), 422, __('Reopen this conversation before replying.')); $validated = $request->validated(); @@ -57,7 +57,7 @@ class AdminSupportConversationController extends Controller $request->file('attachments', []) ?: [], ); - return back()->with('success', 'Reply sent.'); + return back()->with('success', __('Reply sent.')); } public function update(UpdateSupportConversationStatusRequest $request, SupportConversation $conversation): RedirectResponse @@ -68,7 +68,7 @@ class AdminSupportConversationController extends Controller return back()->with( 'success', - $conversation->isOpen() ? 'Conversation reopened.' : 'Conversation closed.', + $conversation->isOpen() ? __('Conversation reopened.') : __('Conversation closed.'), ); } @@ -103,7 +103,7 @@ class AdminSupportConversationController extends Controller return [ 'id' => $conversation->id, 'category' => $conversation->category, - 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other', + 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'), 'subject' => $conversation->subject, 'status' => $conversation->status, 'last_message_at' => $conversation->last_message_at?->toIso8601String(), @@ -144,7 +144,7 @@ class AdminSupportConversationController extends Controller 'created_at' => $message->created_at?->toIso8601String(), 'author' => [ 'id' => $message->user?->id, - 'name' => $message->user?->name ?? 'Deleted user', + 'name' => $message->user?->name ?? __('Deleted user'), 'is_admin' => (bool) $message->user?->is_admin, ], ]; diff --git a/app/Http/Controllers/ChatMessageController.php b/app/Http/Controllers/ChatMessageController.php index 0995946..eb3f24a 100644 --- a/app/Http/Controllers/ChatMessageController.php +++ b/app/Http/Controllers/ChatMessageController.php @@ -11,7 +11,7 @@ class ChatMessageController extends Controller public function store(Request $request, Channel $channel): RedirectResponse { abort_if($request->user()->isSuspended() || $channel->isSuspended(), 403); - abort_unless($channel->is_live && $channel->live_broadcast_id, 422, 'Chat is available only while the channel is live.'); + abort_unless($channel->is_live && $channel->live_broadcast_id, 422, __('Chat is available only while the channel is live.')); $validated = $request->validate([ 'body' => ['required', 'string', 'max:500'], diff --git a/app/Http/Controllers/CreatorDashboardController.php b/app/Http/Controllers/CreatorDashboardController.php index 666ee9f..8737481 100644 --- a/app/Http/Controllers/CreatorDashboardController.php +++ b/app/Http/Controllers/CreatorDashboardController.php @@ -58,8 +58,8 @@ class CreatorDashboardController extends Controller public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse { - abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.'); - abort_unless($request->user()->canCreateChannel(), 403, 'An admin must enable channel creation for this account.'); + abort_if($request->user()->channel()->exists(), 422, __('This account already owns a channel.')); + abort_unless($request->user()->canCreateChannel(), 403, __('An admin must enable channel creation for this account.')); $validated = $request->validate([ 'display_name' => ['required', 'string', 'max:80'], @@ -79,7 +79,7 @@ class CreatorDashboardController extends Controller return redirect() ->route('dashboard') ->with('plain_stream_key', $plainStreamKey) - ->with('success', 'Channel created. Save the stream key now; it will not be shown again.'); + ->with('success', __('Channel created. Save the stream key now; it will not be shown again.')); } public function updateChannel(Request $request): RedirectResponse @@ -112,7 +112,7 @@ class CreatorDashboardController extends Controller 'slug' => Str::slug($validated['slug']), ]); - return back()->with('success', 'Channel updated.'); + return back()->with('success', __('Channel updated.')); } public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse @@ -125,7 +125,7 @@ class CreatorDashboardController extends Controller return back() ->with('plain_stream_key', $plainStreamKey) - ->with('success', 'Stream key rotated. Update OBS before going live again.'); + ->with('success', __('Stream key rotated. Update OBS before going live again.')); } public function storeBroadcast(Request $request): RedirectResponse @@ -137,7 +137,7 @@ class CreatorDashboardController extends Controller abort_if( $channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(), 422, - 'Finish the current broadcast before creating another.', + __('Finish the current broadcast before creating another.'), ); $validated = $request->validate([ @@ -152,7 +152,7 @@ class CreatorDashboardController extends Controller 'mediamtx_path' => $channel->slug, ]); - return back()->with('success', 'Broadcast is ready. Start streaming from OBS.'); + return back()->with('success', __('Broadcast is ready. Start streaming from OBS.')); } public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse @@ -171,7 +171,7 @@ class CreatorDashboardController extends Controller ])->save(); $viewerCounts->forget($broadcast->channel); - return back()->with('success', 'Broadcast ended.'); + return back()->with('success', __('Broadcast ended.')); } private function channelPayload(Channel $channel): array diff --git a/app/Http/Controllers/SupportConversationController.php b/app/Http/Controllers/SupportConversationController.php index 511ebaf..17bc237 100644 --- a/app/Http/Controllers/SupportConversationController.php +++ b/app/Http/Controllers/SupportConversationController.php @@ -45,7 +45,7 @@ class SupportConversationController extends Controller return redirect() ->route('support.show', $conversation) - ->with('success', 'Message sent to admin.'); + ->with('success', __('Message sent to admin.')); } public function show(Request $request, SupportConversation $conversation): Response @@ -65,7 +65,7 @@ class SupportConversationController extends Controller public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse { $this->authorizeUserConversation($request, $conversation); - abort_unless($conversation->isOpen(), 422, 'This conversation is closed.'); + abort_unless($conversation->isOpen(), 422, __('This conversation is closed.')); $validated = $request->validated(); @@ -76,7 +76,7 @@ class SupportConversationController extends Controller $request->file('attachments', []) ?: [], ); - return back()->with('success', 'Reply sent.'); + return back()->with('success', __('Reply sent.')); } private function authorizeUserConversation(Request $request, SupportConversation $conversation): void @@ -117,7 +117,7 @@ class SupportConversationController extends Controller return [ 'id' => $conversation->id, 'category' => $conversation->category, - 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other', + 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'), 'subject' => $conversation->subject, 'status' => $conversation->status, 'last_message_at' => $conversation->last_message_at?->toIso8601String(), @@ -153,7 +153,7 @@ class SupportConversationController extends Controller 'created_at' => $message->created_at?->toIso8601String(), 'author' => [ 'id' => $message->user?->id, - 'name' => $message->user?->name ?? 'Deleted user', + 'name' => $message->user?->name ?? __('Deleted user'), 'is_admin' => (bool) $message->user?->is_admin, ], ]; diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..41021c6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -41,7 +41,40 @@ class HandleInertiaRequests extends Middleware 'auth' => [ 'user' => $request->user(), ], + 'fallbackLocale' => (string) config('app.fallback_locale'), + 'locale' => app()->getLocale(), 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'translations' => fn () => $this->translations(), ]; } + + /** + * @return array + */ + private function translations(): array + { + $fallbackLocale = (string) config('app.fallback_locale'); + $locale = app()->getLocale(); + + return [ + ...$this->jsonTranslations($fallbackLocale), + ...$this->jsonTranslations($locale), + ]; + } + + /** + * @return array + */ + private function jsonTranslations(string $locale): array + { + $path = lang_path("{$locale}.json"); + + if (! is_file($path)) { + return []; + } + + $translations = json_decode((string) file_get_contents($path), true); + + return is_array($translations) ? $translations : []; + } } diff --git a/app/Models/SupportConversation.php b/app/Models/SupportConversation.php index 54ead81..8e1995b 100644 --- a/app/Models/SupportConversation.php +++ b/app/Models/SupportConversation.php @@ -34,10 +34,10 @@ class SupportConversation extends Model public static function categoryLabels(): array { return [ - self::CATEGORY_BUG => 'Bug report', - self::CATEGORY_SUGGESTION => 'Suggestion', - self::CATEGORY_CHANNEL_REQUEST => 'Channel request', - self::CATEGORY_OTHER => 'Other', + self::CATEGORY_BUG => (string) __('Bug report'), + self::CATEGORY_SUGGESTION => (string) __('Suggestion'), + self::CATEGORY_CHANNEL_REQUEST => (string) __('Channel request'), + self::CATEGORY_OTHER => (string) __('Other'), ]; } diff --git a/app/Rules/SupportAttachmentFile.php b/app/Rules/SupportAttachmentFile.php index 422e31c..a5aecb9 100644 --- a/app/Rules/SupportAttachmentFile.php +++ b/app/Rules/SupportAttachmentFile.php @@ -64,9 +64,9 @@ class SupportAttachmentFile implements ValidationRule ], ], [ - 'attachment.file' => 'Attachment upload failed. Try the file again or choose a different file.', - 'attachment.max' => 'Attachments must be 200 MB or smaller.', - 'attachment.mimetypes' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.', + 'attachment.file' => __('Attachment upload failed. Try the file again or choose a different file.'), + 'attachment.max' => __('Attachments must be 200 MB or smaller.'), + 'attachment.mimetypes' => __('Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.'), ], ); diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..f5fe549 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,323 @@ +{ + "2FA recovery codes": "2FA recovery codes", + ":channel by :owner": ":channel by :owner", + ":channel is live now with :count viewers.": ":channel is live now with :count viewers.", + ":channel is offline": ":channel is offline", + ":count active broadcasts": ":count active broadcasts", + ":count available": ":count available", + ":count followers": ":count followers", + ":count messages": ":count messages", + ":count rows": ":count rows", + ":count threads": ":count threads", + ":count viewers": ":count viewers", + ":status - :mode": ":status - :mode", + ":status - :mode - VOD ready": ":status - :mode - VOD ready", + "@:slug owned by :owner": "@:slug owned by :owner", + "A new verification link has been sent to the email address you provided during registration.": "A new verification link has been sent to the email address you provided during registration.", + "A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.", + "ADMIN": "ADMIN", + "Admin": "Admin", + "All categories": "All categories", + "All verified users can create channels.": "All verified users can create channels.", + "Already have an account?": "Already have an account?", + "An admin can grant creator access from the admin console.": "An admin can grant creator access from the admin console.", + "An admin must enable channel creation for this account.": "An admin must enable channel creation for this account.", + "Appearance": "Appearance", + "Appearance settings": "Appearance settings", + "Approval only": "Approval only", + "Are you sure you want to delete your account?": "Are you sure you want to delete your account?", + "Attach up to 3 files, 200 MB each.": "Attach up to 3 files, 200 MB each.", + "Attachment upload failed. Try the file again or choose a different file.": "Attachment upload failed. Try the file again or choose a different file.", + "Attachments": "Attachments", + "Attachments must be 200 MB or smaller.": "Attachments must be 200 MB or smaller.", + "Authentication code": "Authentication code", + "Avatar": "Avatar", + "Back": "Back", + "Broadcast ended.": "Broadcast ended.", + "Broadcast is ready. Start streaming from OBS.": "Broadcast is ready. Start streaming from OBS.", + "Broadcast prep": "Broadcast prep", + "Broadcast state": "Broadcast state", + "Broadcast stopped.": "Broadcast stopped.", + "Bug report": "Bug report", + "Bug reports, suggestions, channel requests, and account questions.": "Bug reports, suggestions, channel requests, and account questions.", + "Building Nyone live": "Building Nyone live", + "CAN CREATE": "CAN CREATE", + "CAN CREATE CHANNEL": "CAN CREATE CHANNEL", + "CLOSED": "CLOSED", + "Cancel": "Cancel", + "Categories": "Categories", + "Category": "Category", + "Channel": "Channel", + "Channel created. Save the stream key now; it will not be shown again.": "Channel created. Save the stream key now; it will not be shown again.", + "Channel creation access": "Channel creation access", + "Channel creation now requires admin approval.": "Channel creation now requires admin approval.", + "Channel creation requires approval": "Channel creation requires approval", + "Channel is offline": "Channel is offline", + "Channel metadata": "Channel metadata", + "Channel request": "Channel request", + "Channel restored.": "Channel restored.", + "Channel setup": "Channel setup", + "Channel suspended.": "Channel suspended.", + "Channel updated.": "Channel updated.", + "Chat": "Chat", + "Chat appears when the channel is live.": "Chat appears when the channel is live.", + "Chat is available only while the channel is live.": "Chat is available only while the channel is live.", + "Clear search or switch categories.": "Clear search or switch categories.", + "Click here to resend the verification email.": "Click here to resend the verification email.", + "Close": "Close", + "Confirm": "Confirm", + "Confirm password": "Confirm password", + "Confirm your password": "Confirm your password", + "Contact": "Contact", + "Contact admin": "Contact admin", + "Contact threads from users and channel request notes.": "Contact threads from users and channel request notes.", + "Continue": "Continue", + "Continue setup": "Continue setup", + "Control who can create a creator channel.": "Control who can create a creator channel.", + "Conversations": "Conversations", + "Create a pending session before starting OBS.": "Create a pending session before starting OBS.", + "Create account": "Create account", + "Create an account": "Create an account", + "Create channel": "Create channel", + "Create your channel": "Create your channel", + "Creator Studio": "Creator Studio", + "Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.", + "Creator grants": "Creator grants", + "Creators can prepare a broadcast in the studio and go live from OBS when ready.": "Creators can prepare a broadcast in the studio and go live from OBS when ready.", + "Current password": "Current password", + "Current session": "Current session", + "Current viewers": "Current viewers", + "DEFAULT": "DEFAULT", + "Dark": "Dark", + "Delete account": "Delete account", + "Delete your account and all of its resources": "Delete your account and all of its resources", + "Deleted user": "Deleted user", + "Description": "Description", + "Disable 2FA": "Disable 2FA", + "Displays the mobile sidebar.": "Displays the mobile sidebar.", + "Display name": "Display name", + "Don't have an account?": "Don't have an account?", + "Each account can own one public channel in this version.": "Each account can own one public channel in this version.", + "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click", + "Email": "Email", + "Email address": "Email address", + "Email password reset link": "Email password reset link", + "Email verification": "Email verification", + "Enable 2FA": "Enable 2FA", + "Enable two-factor authentication": "Enable two-factor authentication", + "Ensure your account is using a long, random password to stay secure": "Ensure your account is using a long, random password to stay secure", + "Enter recovery code": "Enter recovery code", + "Enter the 6-digit code from your authenticator app": "Enter the 6-digit code from your authenticator app", + "Enter the authentication code provided by your authenticator application.": "Enter the authentication code provided by your authenticator application.", + "Enter your details below to create your account": "Enter your details below to create your account", + "Enter your email and password below to log in": "Enter your email and password below to log in", + "Enter your email to receive a password reset link": "Enter your email to receive a password reset link", + "Expires :date": "Expires :date", + "Featured live": "Featured live", + "Finish the current broadcast before creating another.": "Finish the current broadcast before creating another.", + "Finish the current broadcast before preparing another session.": "Finish the current broadcast before preparing another session.", + "Follow": "Follow", + "Following": "Following", + "Forgot password": "Forgot password", + "Forgot password?": "Forgot password?", + "Full name": "Full name", + "Grant": "Grant", + "Hide password": "Hide password", + "Hide recovery codes": "Hide recovery codes", + "Insert :emoji": "Insert :emoji", + "JPG, PNG, or WebP up to 2 MB.": "JPG, PNG, or WebP up to 2 MB.", + "Light": "Light", + "LIVE": "LIVE", + "LIVE - :count viewers": "LIVE - :count viewers", + "Live": "Live", + "Live broadcasts": "Live broadcasts", + "Live channels": "Live channels", + "Live directory": "Live directory", + "Live ingest signal": "Live ingest signal", + "Live only": "Live only", + "Live preview": "Live preview", + "Live signal": "Live signal", + "Loading": "Loading", + "Loading recovery codes": "Loading recovery codes", + "Log in": "Log in", + "Log in to chat": "Log in to chat", + "Log in to follow": "Log in to follow", + "Log in to your account": "Log in to your account", + "Log out": "Log out", + "Manage your profile and account settings": "Manage your profile and account settings", + "Manage your two-factor authentication settings": "Manage your two-factor authentication settings", + "Message": "Message", + "Message sent to admin.": "Message sent to admin.", + "Moderation console": "Moderation console", + "More": "More", + "NEEDS APPROVAL": "NEEDS APPROVAL", + "NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT", + "Name": "Name", + "Navigation menu": "Navigation menu", + "Never": "Never", + "New conversation": "New conversation", + "New password": "New password", + "No active session.": "No active session.", + "No broadcasts yet.": "No broadcasts yet.", + "No category": "No category", + "No channels are live.": "No channels are live.", + "No conversations yet.": "No conversations yet.", + "No messages yet.": "No messages yet.", + "No public recordings yet.": "No public recordings yet.", + "No streams match this view": "No streams match this view", + "No support conversations found.": "No support conversations found.", + "No thumbnail": "No thumbnail", + "No users found.": "No users found.", + "Nyone Live": "Nyone Live", + "OBS readiness": "OBS readiness", + "OBS stream key": "OBS stream key", + "OFFLINE": "OFFLINE", + "OPEN": "OPEN", + "Offline": "Offline", + "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.", + "Open": "Open", + "Open channel": "Open channel", + "Open emoji picker": "Open emoji picker", + "Open moderation": "Open moderation", + "Open studio": "Open studio", + "Open to all": "Open to all", + "Or, return to": "Or, return to", + "Other": "Other", + "Owner": "Owner", + "Password": "Password", + "Password updated.": "Password updated.", + "Platform": "Platform", + "Please confirm access to your account by entering one of your emergency recovery codes.": "Please confirm access to your account by entering one of your emergency recovery codes.", + "Please enter your new password below": "Please enter your new password below", + "Please proceed with caution, this cannot be undone.": "Please proceed with caution, this cannot be undone.", + "Please verify your email address by clicking on the link we just emailed to you.": "Please verify your email address by clicking on the link we just emailed to you.", + "Prepare": "Prepare", + "Prepare broadcasts, secure your stream key, and monitor the current session.": "Prepare broadcasts, secure your stream key, and monitor the current session.", + "Prepared broadcast": "Prepared broadcast", + "Private stream key": "Private stream key", + "Processing recording": "Processing recording", + "Profile": "Profile", + "Profile information": "Profile information", + "Profile settings": "Profile settings", + "Profile updated.": "Profile updated.", + "Public channels broadcasting now": "Public channels broadcasting now", + "Public metadata used in the directory and watch page.": "Public metadata used in the directory and watch page.", + "READY": "READY", + "Recent VODs": "Recent VODs", + "Recent broadcasts": "Recent broadcasts", + "Recent channels": "Recent channels", + "Recent recordings remain available below when the creator publishes VODs.": "Recent recordings remain available below when the creator publishes VODs.", + "Record VOD": "Record VOD", + "Recording enabled": "Recording enabled", + "Recovery code": "Recovery code", + "Recovery codes": "Recovery codes", + "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.", + "Regenerate codes": "Regenerate codes", + "Register": "Register", + "Remember me": "Remember me", + "Remove grant": "Remove grant", + "Reopen": "Reopen", + "Reopen the conversation to reply.": "Reopen the conversation to reply.", + "Reopen this conversation before replying.": "Reopen this conversation before replying.", + "Reply": "Reply", + "Reply sent.": "Reply sent.", + "Resend verification email": "Resend verification email", + "Reset password": "Reset password", + "Restore": "Restore", + "Restore channel?": "Restore channel?", + "Review live streams, suspended states, users, and recordings.": "Review live streams, suspended states, users, and recordings.", + "Rotate immediately if it was exposed.": "Rotate immediately if it was exposed.", + "Rotate key": "Rotate key", + "Rotate the key to reveal it once.": "Rotate the key to reveal it once.", + "Save": "Save", + "Save channel": "Save channel", + "Save password": "Save password", + "Search live channels": "Search live channels", + "Security": "Security", + "Security settings": "Security settings", + "Send": "Send", + "Send a message": "Send a message", + "Send message": "Send message", + "Send reply": "Send reply", + "Server": "Server", + "Settings": "Settings", + "Show password": "Show password", + "Sidebar": "Sidebar", + "Sign up": "Sign up", + "Slug": "Slug", + "Something went wrong.": "Something went wrong.", + "Start OBS to push the session live.": "Start OBS to push the session live.", + "Start a channel": "Start a channel", + "Stop": "Stop", + "Stop broadcast": "Stop broadcast", + "Stop broadcast?": "Stop broadcast?", + "Stop live broadcast?": "Stop live broadcast?", + "Stream key": "Stream key", + "Stream key rotated. Update OBS before going live again.": "Stream key rotated. Update OBS before going live again.", + "Stream key used": "Stream key used", + "Stream keys can publish to this channel. Rotate immediately if it was exposed.": "Stream keys can publish to this channel. Rotate immediately if it was exposed.", + "Stream title": "Stream title", + "Studio": "Studio", + "Subject": "Subject", + "Suggestion": "Suggestion", + "Support": "Support", + "Support inbox": "Support inbox", + "SUSPENDED": "SUSPENDED", + "Suspend": "Suspend", + "Suspend channel?": "Suspend channel?", + "System": "System", + "The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.", + "The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.", + "The live floor is quiet": "The live floor is quiet", + "The live session will end immediately and the channel will move offline.": "The live session will end immediately and the channel will move offline.", + "This account already owns a channel.": "This account already owns a channel.", + "This account can browse and follow channels, but cannot create a channel yet.": "This account can browse and follow channels, but cannot create a channel yet.", + "This conversation is closed.": "This conversation is closed.", + "This ends the live session and clears the channel's live state. Viewers will see the offline screen.": "This ends the live session and clears the channel's live state. Viewers will see the offline screen.", + "This key is shown once. Store it in OBS before leaving this page.": "This key is shown once. Store it in OBS before leaving this page.", + "To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app": "To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app", + "Toggle sidebar": "Toggle sidebar", + "Two-factor authentication": "Two-factor authentication", + "Two-factor authentication enabled": "Two-factor authentication enabled", + "Uncategorized": "Uncategorized", + "Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.": "Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.", + "Update password": "Update password", + "Update your account's appearance settings": "Update your account's appearance settings", + "Update your name, email address, and avatar": "Update your name, email address, and avatar", + "Use a 16:9 JPG, PNG, or WebP up to 4 MB.": "Use a 16:9 JPG, PNG, or WebP up to 4 MB.", + "USER": "USER", + "User": "User", + "User can create a channel.": "User can create a channel.", + "User channel creation access removed.": "User channel creation access removed.", + "User suspended.": "User suspended.", + "Users": "Users", + "Verify email": "Verify email", + "View channel": "View channel", + "View recovery codes": "View recovery codes", + "Viewers": "Viewers", + "VOD deleted.": "VOD deleted.", + "VOD ready": "VOD ready", + "VODs": "VODs", + "Warning": "Warning", + "Watch": "Watch", + "Watch VOD": "Watch VOD", + "When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.": "When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.", + "You": "You", + "YOU": "YOU", + "You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.", + "Your email address is unverified.": "Your email address is unverified.", + "above.": "above.", + "breadcrumb": "breadcrumb", + "email@example.com": "email@example.com", + "later": "later", + "live only": "live only", + "log in": "log in", + "login using a recovery code": "login using a recovery code", + "login using an authentication code": "login using an authentication code", + "nyone-live": "nyone-live", + "or you can": "or you can", + "or, enter the code manually": "or, enter the code manually", + "pending": "pending", + "recording": "recording", + "unknown": "unknown" +} diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..6598e2c --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 0000000..d481411 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 0000000..fad3a7d --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..63ec29a --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,200 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'any_of' => 'The :attribute field is invalid.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', + 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/resources/js/components/alert-error.tsx b/resources/js/components/alert-error.tsx index a9eacd3..fe58888 100644 --- a/resources/js/components/alert-error.tsx +++ b/resources/js/components/alert-error.tsx @@ -1,5 +1,6 @@ import { AlertCircleIcon } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { useTranslation } from '@/lib/translations'; export default function AlertError({ errors, @@ -8,10 +9,12 @@ export default function AlertError({ errors: string[]; title?: string; }) { + const { t } = useTranslation(); + return ( - {title || 'Something went wrong.'} + {t(title || 'Something went wrong.')}
    {Array.from(new Set(errors)).map((error, index) => ( diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index 86ec274..d38d6f3 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -38,6 +38,7 @@ import { import { UserMenuContent } from '@/components/user-menu-content'; import { useCurrentUrl } from '@/hooks/use-current-url'; import { useInitials } from '@/hooks/use-initials'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; import { dashboard, home } from '@/routes'; import { dashboard as adminDashboard } from '@/routes/admin'; @@ -56,6 +57,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { const { auth } = page.props; const getInitials = useInitials(); const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl(); + const { t } = useTranslation(); const mainNavItems: NavItem[] = [ { title: 'Live', @@ -108,7 +110,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar" > - Navigation menu + {t('Navigation menu')} @@ -125,7 +127,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { {item.icon && ( )} - {item.title} + {t(item.title)} ))} @@ -168,7 +170,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) { {item.icon && ( )} - {item.title} + {t(item.title)} {isCurrentUrl(item.href) && (
    @@ -199,13 +201,13 @@ export function AppHeader({ breadcrumbs = [] }: Props) { className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none" > - Live directory + {t('Live directory')} -

    Live directory

    +

    {t('Live directory')}

    diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx index b013862..70fca0e 100644 --- a/resources/js/components/appearance-tabs.tsx +++ b/resources/js/components/appearance-tabs.tsx @@ -3,6 +3,7 @@ import { Monitor, Moon, Sun } from 'lucide-react'; import type { HTMLAttributes } from 'react'; import type { Appearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; export default function AppearanceToggleTab({ @@ -10,6 +11,7 @@ export default function AppearanceToggleTab({ ...props }: HTMLAttributes) { const { appearance, updateAppearance } = useAppearance(); + const { t } = useTranslation(); const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ { value: 'light', icon: Sun, label: 'Light' }, @@ -37,7 +39,7 @@ export default function AppearanceToggleTab({ )} > - {label} + {t(label)} ))} diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx index 3420084..9b49085 100644 --- a/resources/js/components/breadcrumbs.tsx +++ b/resources/js/components/breadcrumbs.tsx @@ -8,6 +8,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { useTranslation } from '@/lib/translations'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; export function Breadcrumbs({ @@ -15,6 +16,8 @@ export function Breadcrumbs({ }: { breadcrumbs: BreadcrumbItemType[]; }) { + const { t } = useTranslation(); + return ( <> {breadcrumbs.length > 0 && ( @@ -28,12 +31,12 @@ export function Breadcrumbs({ {isLast ? ( - {item.title} + {t(item.title)} ) : ( - {item.title} + {t(item.title)} )} diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 213e76f..a0b2780 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -15,9 +15,11 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; +import { useTranslation } from '@/lib/translations'; export default function DeleteUser() { const passwordInput = useRef(null); + const { t } = useTranslation(); return (
    @@ -28,9 +30,11 @@ export default function DeleteUser() { />
    -

    Warning

    +

    {t('Warning')}

    - Please proceed with caution, this cannot be undone. + {t( + 'Please proceed with caution, this cannot be undone.', + )}

    @@ -45,13 +49,12 @@ export default function DeleteUser() { - Are you sure you want to delete your account? + {t('Are you sure you want to delete your account?')} - Once your account is deleted, all of its resources - and data will also be permanently deleted. Please - enter your password to confirm you would like to - permanently delete your account. + {t( + 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.', + )}
    diff --git a/resources/js/components/heading.tsx b/resources/js/components/heading.tsx index 515242d..f457007 100644 --- a/resources/js/components/heading.tsx +++ b/resources/js/components/heading.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from '@/lib/translations'; + export default function Heading({ title, description, @@ -7,6 +9,8 @@ export default function Heading({ description?: string; variant?: 'default' | 'small'; }) { + const { t } = useTranslation(); + return (

    - {title} + {t(title)}

    {description && ( -

    {description}

    +

    + {t(description)} +

    )}
    ); diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index 54095ac..823cc04 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -7,25 +7,27 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { useCurrentUrl } from '@/hooks/use-current-url'; +import { useTranslation } from '@/lib/translations'; import type { NavItem } from '@/types'; export function NavMain({ items = [] }: { items: NavItem[] }) { const { isCurrentUrl } = useCurrentUrl(); + const { t } = useTranslation(); return ( - Platform + {t('Platform')} {items.map((item) => ( {item.icon && } - {item.title} + {t(item.title)} diff --git a/resources/js/components/password-input.tsx b/resources/js/components/password-input.tsx index 081e5cd..8af0ca9 100644 --- a/resources/js/components/password-input.tsx +++ b/resources/js/components/password-input.tsx @@ -2,6 +2,7 @@ import { Eye, EyeOff } from 'lucide-react'; import type { ComponentProps, Ref } from 'react'; import { useState } from 'react'; import { Input } from '@/components/ui/input'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; export default function PasswordInput({ @@ -10,6 +11,7 @@ export default function PasswordInput({ ...props }: Omit, 'type'> & { ref?: Ref }) { const [showPassword, setShowPassword] = useState(false); + const { t } = useTranslation(); return (
    @@ -23,7 +25,7 @@ export default function PasswordInput({ type="button" onClick={() => setShowPassword((prev) => !prev)} className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none" - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={t(showPassword ? 'Hide password' : 'Show password')} tabIndex={-1} > {showPassword ? ( diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 557885a..77acc2e 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -10,6 +10,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { useTranslation } from '@/lib/translations'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; type Props = { @@ -26,6 +27,7 @@ export default function TwoFactorRecoveryCodes({ const [codesAreVisible, setCodesAreVisible] = useState(false); const codesSectionRef = useRef(null); const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; + const { t } = useTranslation(); const toggleCodesVisibility = useCallback(async () => { if (!codesAreVisible && !recoveryCodesList.length) { @@ -57,11 +59,12 @@ export default function TwoFactorRecoveryCodes({ - Recovery codes let you regain access if you lose your 2FA - device. Store them in a secure password manager. + {t( + 'Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.', + )} @@ -76,7 +79,9 @@ export default function TwoFactorRecoveryCodes({ className="size-4" aria-hidden="true" /> - {codesAreVisible ? 'Hide' : 'View'} recovery codes + {codesAreVisible + ? t('Hide recovery codes') + : t('View recovery codes')} {canRegenerateCodes && ( @@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({ disabled={processing} aria-describedby="regenerate-warning" > - Regenerate codes + {t('Regenerate codes')} )} @@ -112,7 +117,7 @@ export default function TwoFactorRecoveryCodes({ ref={codesSectionRef} className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm" role="list" - aria-label="Recovery codes" + aria-label={t('Recovery codes')} > {recoveryCodesList.length ? ( recoveryCodesList.map((code, index) => ( @@ -127,7 +132,9 @@ export default function TwoFactorRecoveryCodes({ ) : (
    {Array.from( { length: 8 }, @@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({

    - Each recovery code can be used once to - access your account and will be removed - after use. If you need more, click{' '} + {t( + 'Each recovery code can be used once to access your account and will be removed after use. If you need more, click', + )}{' '} - Regenerate codes + {t('Regenerate codes')} {' '} - above. + {t('above.')}

    diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index a968858..42c328c 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner'; import { useAppearance } from '@/hooks/use-appearance'; import { useClipboard } from '@/hooks/use-clipboard'; import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; +import { useTranslation } from '@/lib/translations'; import { confirm } from '@/routes/two-factor'; function GridScanIcon() { @@ -64,6 +65,7 @@ function TwoFactorSetupStep({ }) { const { resolvedAppearance } = useAppearance(); const [copiedText, copy] = useClipboard(); + const { t } = useTranslation(); const IconComponent = copiedText === manualSetupKey ? Check : Copy; return ( @@ -104,7 +106,7 @@ function TwoFactorSetupStep({
    - or, enter the code manually + {t('or, enter the code manually')}
    @@ -147,6 +149,7 @@ function TwoFactorVerificationStep({ }) { const [code, setCode] = useState(''); const pinInputContainerRef = useRef(null); + const { t } = useTranslation(); useEffect(() => { setTimeout(() => { @@ -210,7 +213,7 @@ function TwoFactorVerificationStep({ onClick={onBack} disabled={processing} > - Back + {t('Back')}
    @@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({ }: Props) { const [showVerificationStep, setShowVerificationStep] = useState(false); + const { t } = useTranslation(); const modalConfig = useMemo<{ title: string; @@ -262,29 +266,32 @@ export default function TwoFactorSetupModal({ }>(() => { if (twoFactorEnabled) { return { - title: 'Two-factor authentication enabled', - description: + title: t('Two-factor authentication enabled'), + description: t( 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.', - buttonText: 'Close', + ), + buttonText: t('Close'), }; } if (showVerificationStep) { return { - title: 'Verify authentication code', - description: + title: t('Verify authentication code'), + description: t( 'Enter the 6-digit code from your authenticator app', - buttonText: 'Continue', + ), + buttonText: t('Continue'), }; } return { - title: 'Enable two-factor authentication', - description: + title: t('Enable two-factor authentication'), + description: t( 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app', - buttonText: 'Continue', + ), + buttonText: t('Continue'), }; - }, [twoFactorEnabled, showVerificationStep]); + }, [showVerificationStep, t, twoFactorEnabled]); const resetModalState = useCallback(() => { setShowVerificationStep(false); diff --git a/resources/js/components/ui/breadcrumb.tsx b/resources/js/components/ui/breadcrumb.tsx index 12a631e..15355a1 100644 --- a/resources/js/components/ui/breadcrumb.tsx +++ b/resources/js/components/ui/breadcrumb.tsx @@ -2,10 +2,13 @@ import { Slot } from "@radix-ui/react-slot" import { ChevronRight, MoreHorizontal } from "lucide-react" import * as React from "react" +import { useTranslation } from "@/lib/translations" import { cn } from "@/lib/utils" function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return
    @@ -319,9 +333,13 @@ export default function AdminDashboard({
    -

    Recent channels

    +

    + {t('Recent channels')} +

    - {channels.length} rows + {t(':count rows', { + count: channels.length, + })}
    @@ -336,18 +354,20 @@ export default function AdminDashboard({ {channel.display_name}
    - @{channel.slug} owned by{' '} - {channel.owner.name} + {t('@:slug owned by :owner', { + slug: channel.slug, + owner: channel.owner.name, + })}
    {channel.is_live && ( - LIVE + {t('LIVE')} )} {channel.suspended_at && ( - SUSPENDED + {t('SUSPENDED')} )}
    @@ -400,9 +420,9 @@ export default function AdminDashboard({ > - {dialogTitle(action)} + {t(dialogTitle(action))} - {dialogDescription(action)} + {t(dialogDescription(action))} @@ -449,10 +469,12 @@ function Stat({ value: number; live?: boolean; }) { + const { t } = useTranslation(); + return (
    - {label} + {t(label)}
    {value}
    @@ -470,6 +492,7 @@ function CreatorAccessRow({ onChange: (canCreateChannel: boolean) => void; }) { const effectiveAccess = defaultOpen || user.can_create_channel; + const { t } = useTranslation(); return (
    @@ -478,12 +501,12 @@ function CreatorAccessRow({ {user.name} {user.is_admin && ( - ADMIN + {t('ADMIN')} )} {user.suspended_at && ( - SUSPENDED + {t('SUSPENDED')} )} - {effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'} + {effectiveAccess + ? t('CAN CREATE') + : t('NEEDS APPROVAL')} {defaultOpen && !user.can_create_channel && ( - DEFAULT + {t('DEFAULT')} )}
    @@ -563,9 +588,11 @@ function Avatar({ channel }: { channel: AdminChannel }) { } function EmptyState({ text }: { text: string }) { + const { t } = useTranslation(); + return (
    - {text} + {t(text)}
    ); } diff --git a/resources/js/pages/admin/support/index.tsx b/resources/js/pages/admin/support/index.tsx index 3573314..04f6085 100644 --- a/resources/js/pages/admin/support/index.tsx +++ b/resources/js/pages/admin/support/index.tsx @@ -1,5 +1,6 @@ import { Head, Link } from '@inertiajs/react'; import { Inbox, MessageSquare, Shield, User } from 'lucide-react'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; import { index as adminSupportIndex, @@ -16,9 +17,11 @@ type Props = { }; export default function AdminSupportIndex({ stats, conversations }: Props) { + const { t } = useTranslation(); + return ( <> - +
    @@ -27,11 +30,12 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {

    - Support inbox + {t('Support inbox')}

    - Contact threads from users and channel request - notes. + {t( + 'Contact threads from users and channel request notes.', + )}

    @@ -45,10 +49,14 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
    -

    Conversations

    +

    + {t('Conversations')} +

    - {conversations.length} rows + {t(':count rows', { + count: conversations.length, + })}
    @@ -96,7 +104,7 @@ export default function AdminSupportIndex({ stats, conversations }: Props) { {conversations.length === 0 && (
    - No support conversations found. + {t('No support conversations found.')}
    )}
    @@ -116,15 +124,19 @@ AdminSupportIndex.layout = { }; function Stat({ label, value }: { label: string; value: number }) { + const { t } = useTranslation(); + return (
    - {label}{' '} + {t(label)}{' '} {value}
    ); } function StatusBadge({ status }: { status: 'open' | 'closed' }) { + const { t } = useTranslation(); + return ( - {status === 'open' ? 'OPEN' : 'CLOSED'} + {status === 'open' ? t('OPEN') : t('CLOSED')} ); } diff --git a/resources/js/pages/admin/support/show.tsx b/resources/js/pages/admin/support/show.tsx index e324ad2..dbc34d1 100644 --- a/resources/js/pages/admin/support/show.tsx +++ b/resources/js/pages/admin/support/show.tsx @@ -15,6 +15,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors' import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; import { dashboard as adminDashboard } from '@/routes/admin'; import { @@ -36,6 +37,7 @@ type Props = { export default function AdminSupportShow({ conversation }: Props) { const fileInputRef = useRef(null); + const { t } = useTranslation(); const form = useForm({ body: '', attachments: [] as File[], @@ -84,7 +86,7 @@ export default function AdminSupportShow({ conversation }: Props) { > - Support inbox + {t('Support inbox')}
    @@ -111,7 +113,7 @@ export default function AdminSupportShow({ conversation }: Props) { ) : ( )} - {isClosed ? 'Reopen' : 'Close'} + {isClosed ? t('Reopen') : t('Close')}
    @@ -126,7 +128,9 @@ export default function AdminSupportShow({ conversation }: Props) { {isClosed ? (
    - Reopen the conversation to reply. + + {t('Reopen the conversation to reply.')} +
    ) : (
    @@ -197,7 +201,7 @@ export default function AdminSupportShow({ conversation }: Props) {
    -

    User

    +

    {t('User')}

    @@ -218,12 +222,12 @@ export default function AdminSupportShow({ conversation }: Props) { )} > {conversation.user?.can_create_channel - ? 'CAN CREATE CHANNEL' - : 'NEEDS CHANNEL GRANT'} + ? t('CAN CREATE CHANNEL') + : t('NEEDS CHANNEL GRANT')} {conversation.user?.suspended_at && ( - SUSPENDED + {t('SUSPENDED')} )}
    @@ -246,12 +250,13 @@ export default function AdminSupportShow({ conversation }: Props) {

    - Channel request + {t('Channel request')}

    - Creator access grants stay in the moderation - console. + {t( + 'Creator access grants stay in the moderation console.', + )}

    @@ -58,7 +61,7 @@ export default function Login({ className="ml-auto text-sm" tabIndex={5} > - Forgot password? + {t('Forgot password?')} )}
    @@ -68,7 +71,7 @@ export default function Login({ required tabIndex={2} autoComplete="current-password" - placeholder="Password" + placeholder={t('Password')} />
    @@ -96,9 +99,9 @@ export default function Login({ {canRegister && (
    - Don't have an account?{' '} + {t("Don't have an account?")}{' '} - Sign up + {t('Sign up')}
    )} diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx index ce766ef..b71585a 100644 --- a/resources/js/pages/auth/register.tsx +++ b/resources/js/pages/auth/register.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; +import { useTranslation } from '@/lib/translations'; import { login } from '@/routes'; import { store } from '@/routes/register'; @@ -14,9 +15,11 @@ type Props = { }; export default function Register({ passwordRules }: Props) { + const { t } = useTranslation(); + return ( <> - +
    @@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) { tabIndex={3} autoComplete="new-password" name="password" - placeholder="Password" + placeholder={t('Password')} passwordrules={passwordRules} /> @@ -82,7 +85,7 @@ export default function Register({ passwordRules }: Props) { tabIndex={4} autoComplete="new-password" name="password_confirmation" - placeholder="Confirm password" + placeholder={t('Confirm password')} passwordrules={passwordRules} />
    - Already have an account?{' '} + {t('Already have an account?')}{' '} - Log in + {t('Log in')}
    diff --git a/resources/js/pages/auth/reset-password.tsx b/resources/js/pages/auth/reset-password.tsx index 8b762c3..36a08bd 100644 --- a/resources/js/pages/auth/reset-password.tsx +++ b/resources/js/pages/auth/reset-password.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; +import { useTranslation } from '@/lib/translations'; import { update } from '@/routes/password'; type Props = { @@ -14,9 +15,11 @@ type Props = { }; export default function ResetPassword({ token, email, passwordRules }: Props) { + const { t } = useTranslation(); + return ( <> - + @@ -65,7 +68,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) { name="password_confirmation" autoComplete="new-password" className="mt-1 block w-full" - placeholder="Confirm password" + placeholder={t('Confirm password')} passwordrules={passwordRules} /> (false); const [code, setCode] = useState(''); @@ -23,20 +25,22 @@ export default function TwoFactorChallenge() { }>(() => { if (showRecoveryInput) { return { - title: 'Recovery code', - description: + title: t('Recovery code'), + description: t( 'Please confirm access to your account by entering one of your emergency recovery codes.', - toggleText: 'login using an authentication code', + ), + toggleText: t('login using an authentication code'), }; } return { - title: 'Authentication code', - description: + title: t('Authentication code'), + description: t( 'Enter the authentication code provided by your authenticator application.', - toggleText: 'login using a recovery code', + ), + toggleText: t('login using a recovery code'), }; - }, [showRecoveryInput]); + }, [showRecoveryInput, t]); setLayoutProps({ title: authConfigContent.title, @@ -51,7 +55,7 @@ export default function TwoFactorChallenge() { return ( <> - +
    @@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
    - or you can + {t('or you can')}
    {channel.description && ( @@ -185,7 +191,7 @@ export default function ChannelShow({ className="xl:hidden" > - Chat + {t('Chat')} - Live chat + + {t('Live chat')} + - {isFollowing ? 'Following' : 'Follow'} + {isFollowing + ? t('Following') + : t('Follow')} ) : ( )} @@ -241,11 +251,13 @@ export default function ChannelShow({
    - {vods.length} available + {t(':count available', { + count: vods.length, + })}
    {vods.length > 0 ? ( @@ -256,7 +268,7 @@ export default function ChannelShow({
    ) : (
    - No public recordings yet. + {t('No public recordings yet.')}
    )} @@ -288,6 +300,8 @@ ChannelShow.layout = { }; function OfflinePlayer({ channel }: { channel: ChannelDetail }) { + const { t } = useTranslation(); + return (
    {channel.banner_url ? ( @@ -304,11 +318,14 @@ function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
    - {channel.display_name} is offline + {t(':channel is offline', { + channel: channel.display_name, + })}

    - Recent recordings remain available below when the - creator publishes VODs. + {t( + 'Recent recordings remain available below when the creator publishes VODs.', + )}

    @@ -334,6 +351,7 @@ function ChatPanel({ compact?: boolean; }) { const getInitials = useInitials(); + const { t } = useTranslation(); const inputRef = useRef(null); const messagesEndRef = useRef(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); @@ -383,9 +401,11 @@ function ChatPanel({
    -

    Live chat

    +

    {t('Live chat')}

    - {formatMessageCount(messages.length)} + {t(':count messages', { + count: messages.length, + })}

    @@ -438,13 +458,13 @@ function ChatPanel({ )} > {isOwnMessage - ? 'You' + ? t('You') : message.user.name} {isChannelOwner && ( - Owner + {t('Owner')} )} {message.created_at && ( @@ -484,8 +504,10 @@ function ChatPanel({ {channel.is_live - ? 'No messages yet.' - : 'Chat appears when the channel is live.'} + ? t('No messages yet.') + : t( + 'Chat appears when the channel is live.', + )}
    )} @@ -504,8 +526,8 @@ function ChatPanel({ } placeholder={ channel.is_live - ? 'Send a message' - : 'Channel is offline' + ? t('Send a message') + : t('Channel is offline') } disabled={!channel.is_live || form.processing} maxLength={500} @@ -518,7 +540,7 @@ function ChatPanel({ disabled={ !channel.is_live || form.processing } - aria-label="Open emoji picker" + aria-label={t('Open emoji picker')} aria-expanded={emojiPickerOpen} onClick={() => setEmojiPickerOpen((open) => !open) @@ -532,7 +554,9 @@ function ChatPanel({ @@ -562,7 +586,7 @@ function ChatPanel({ > {form.errors.body ?? (!channel.is_live - ? 'Channel is offline' + ? t('Channel is offline') : '')} ) : ( )} @@ -585,10 +609,6 @@ function ChatPanel({ ); } -function formatMessageCount(count: number): string { - return count === 1 ? '1 message' : `${count} messages`; -} - function formatChatTime(value: string): string { return new Date(value).toLocaleTimeString([], { hour: 'numeric', @@ -601,6 +621,8 @@ function formatChatDateTime(value: string): string { } function VodCard({ vod }: { vod: VodSummary }) { + const { t } = useTranslation(); + return (
    @@ -610,19 +632,20 @@ function VodCard({ vod }: { vod: VodSummary }) {
    {vod.title}
    - Expires{' '} - {vod.expires_at - ? new Date(vod.expires_at).toLocaleDateString() - : 'later'} + {t('Expires :date', { + date: vod.expires_at + ? new Date(vod.expires_at).toLocaleDateString() + : t('later'), + })}
    {vod.playback_url ? ( ) : (
    - Processing recording + {t('Processing recording')}
    )}
    @@ -637,6 +660,8 @@ function LiveState({ channel: ChannelDetail; compact?: boolean; }) { + const { t } = useTranslation(); + if (channel.is_live) { return ( - LIVE + {t('LIVE')} ); } return ( - Offline + {t('Offline')} ); } diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 1638aae..a764805 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -25,6 +25,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useClipboard } from '@/hooks/use-clipboard'; +import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; import { dashboard as dashboardRoute } from '@/routes'; import { show as showChannel } from '@/routes/channels'; @@ -75,6 +76,7 @@ export default function Dashboard({ recentBroadcasts, }: Props) { const [copiedText, copy] = useClipboard(); + const { t } = useTranslation(); const [stopTarget, setStopTarget] = useState(null); const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState< string | null @@ -155,7 +157,7 @@ export default function Dashboard({ return ( <> - +
    @@ -168,16 +170,17 @@ export default function Dashboard({ /> {pendingBroadcast && !liveBroadcast && ( - READY + {t('READY')} )}

    - {channel?.display_name ?? 'Creator Studio'} + {channel?.display_name ?? t('Creator Studio')}

    - Prepare broadcasts, secure your stream key, and - monitor the current session. + {t( + 'Prepare broadcasts, secure your stream key, and monitor the current session.', + )}

    @@ -214,7 +217,7 @@ export default function Dashboard({ event.target.value, ) } - placeholder="Nyone Live" + placeholder={t('Nyone Live')} /> @@ -284,9 +287,9 @@ export default function Dashboard({
    - This account can browse and follow - channels, but cannot create a channel - yet. + {t( + 'This account can browse and follow channels, but cannot create a channel yet.', + )}
    @@ -306,10 +309,10 @@ export default function Dashboard({ label="Broadcast state" value={ channel.is_live - ? 'Live' + ? t('Live') : pendingBroadcast - ? 'Ready' - : 'Offline' + ? t('Ready') + : t('Offline') } /> @@ -348,7 +351,9 @@ export default function Dashboard({ event.target.value, ) } - placeholder="Building Nyone live" + placeholder={t( + 'Building Nyone live', + )} disabled={ Boolean(liveBroadcast) || Boolean(pendingBroadcast) @@ -373,7 +378,7 @@ export default function Dashboard({ Boolean(pendingBroadcast) } /> - Record VOD + {t('Record VOD')}
    @@ -486,8 +492,9 @@ export default function Dashboard({
    - Use a 16:9 JPG, PNG, or - WebP up to 4 MB. + {t( + 'Use a 16:9 JPG, PNG, or WebP up to 4 MB.', + )} {channelForm.data .thumbnail && ( @@ -584,7 +591,9 @@ export default function Dashboard({ label="Stream key" value={ streaming.tokenizedPath ?? - 'Rotate the key to reveal it once.' + t( + 'Rotate the key to reveal it once.', + ) } copied={ copiedText === @@ -595,8 +604,9 @@ export default function Dashboard({ /> {plainStreamKey && (
    - This key is shown once. Store it in - OBS before leaving this page. + {t( + 'This key is shown once. Store it in OBS before leaving this page.', + )}
    )}