Implemented project-wide localization plumbing and converted the visible UI/app messages to translation-ready strings.

This commit is contained in:
2026-05-18 00:16:37 +03:30
parent b49b59a056
commit c6606d9602
60 changed files with 1376 additions and 345 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string>
*/
private function translations(): array
{
$fallbackLocale = (string) config('app.fallback_locale');
$locale = app()->getLocale();
return [
...$this->jsonTranslations($fallbackLocale),
...$this->jsonTranslations($locale),
];
}
/**
* @return array<string, string>
*/
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 : [];
}
}

View File

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

View File

@@ -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.'),
],
);

323
lang/en.json Normal file
View File

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

20
lang/en/auth.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];

19
lang/en/pagination.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];

22
lang/en/passwords.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => '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.",
];

200
lang/en/validation.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => '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' => [],
];

View File

@@ -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 (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
<AlertTitle>{t(title || 'Something went wrong.')}</AlertTitle>
<AlertDescription>
<ul className="list-inside list-disc text-sm">
{Array.from(new Set(errors)).map((error, index) => (

View File

@@ -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"
>
<SheetTitle className="sr-only">
Navigation menu
{t('Navigation menu')}
</SheetTitle>
<SheetHeader className="flex justify-start text-left">
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
@@ -125,7 +127,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
{item.icon && (
<item.icon className="h-5 w-5" />
)}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</Link>
))}
</div>
@@ -168,7 +170,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
{item.icon && (
<item.icon className="mr-2 h-4 w-4" />
)}
{item.title}
{t(item.title)}
</Link>
{isCurrentUrl(item.href) && (
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
@@ -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"
>
<span className="sr-only">
Live directory
{t('Live directory')}
</span>
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Live directory</p>
<p>{t('Live directory')}</p>
</TooltipContent>
</Tooltip>
</div>

View File

@@ -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<HTMLDivElement>) {
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({
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
<span className="ml-1.5 text-sm">{t(label)}</span>
</button>
))}
</div>

View File

@@ -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({
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>
{item.title}
{t(item.title)}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={item.href}>
{item.title}
{t(item.title)}
</Link>
</BreadcrumbLink>
)}

View File

@@ -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<HTMLInputElement>(null);
const { t } = useTranslation();
return (
<div className="space-y-6">
@@ -28,9 +30,11 @@ export default function DeleteUser() {
/>
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p>
<p className="font-medium">{t('Warning')}</p>
<p className="text-sm">
Please proceed with caution, this cannot be undone.
{t(
'Please proceed with caution, this cannot be undone.',
)}
</p>
</div>
@@ -45,13 +49,12 @@ export default function DeleteUser() {
</DialogTrigger>
<DialogContent>
<DialogTitle>
Are you sure you want to delete your account?
{t('Are you sure you want to delete your account?')}
</DialogTitle>
<DialogDescription>
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.',
)}
</DialogDescription>
<Form
@@ -77,7 +80,7 @@ export default function DeleteUser() {
id="password"
name="password"
ref={passwordInput}
placeholder="Password"
placeholder={t('Password')}
autoComplete="current-password"
/>

View File

@@ -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 (
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
<h2
@@ -16,10 +20,12 @@ export default function Heading({
: 'text-xl font-semibold tracking-tight'
}
>
{title}
{t(title)}
</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-sm text-muted-foreground">
{t(description)}
</p>
)}
</header>
);

View File

@@ -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 (
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{t('Platform')}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isCurrentUrl(item.href)}
tooltip={{ children: item.title }}
tooltip={{ children: t(item.title) }}
>
<Link href={item.href} prefetch>
{item.icon && <item.icon />}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -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<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
const [showPassword, setShowPassword] = useState(false);
const { t } = useTranslation();
return (
<div className="relative">
@@ -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 ? (

View File

@@ -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<boolean>(false);
const codesSectionRef = useRef<HTMLDivElement | null>(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({
<CardHeader>
<CardTitle className="flex gap-3">
<LockKeyhole className="size-4" aria-hidden="true" />
2FA recovery codes
{t('2FA recovery codes')}
</CardTitle>
<CardDescription>
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.',
)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -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')}
</Button>
{canRegenerateCodes && (
@@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({
disabled={processing}
aria-describedby="regenerate-warning"
>
<RefreshCw /> Regenerate codes
<RefreshCw /> {t('Regenerate codes')}
</Button>
)}
</Form>
@@ -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({
) : (
<div
className="space-y-2"
aria-label="Loading recovery codes"
aria-label={t(
'Loading recovery codes',
)}
>
{Array.from(
{ length: 8 },
@@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({
<div className="text-xs text-muted-foreground select-none">
<p id="regenerate-warning">
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',
)}{' '}
<span className="font-bold">
Regenerate codes
{t('Regenerate codes')}
</span>{' '}
above.
{t('above.')}
</p>
</div>
</>

View File

@@ -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({
<div className="relative flex w-full items-center justify-center">
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
<span className="relative bg-card px-2 py-1">
or, enter the code manually
{t('or, enter the code manually')}
</span>
</div>
@@ -147,6 +149,7 @@ function TwoFactorVerificationStep({
}) {
const [code, setCode] = useState<string>('');
const pinInputContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
setTimeout(() => {
@@ -210,7 +213,7 @@ function TwoFactorVerificationStep({
onClick={onBack}
disabled={processing}
>
Back
{t('Back')}
</Button>
<Button
type="submit"
@@ -219,7 +222,7 @@ function TwoFactorVerificationStep({
processing || code.length < OTP_MAX_LENGTH
}
>
Confirm
{t('Confirm')}
</Button>
</div>
</div>
@@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({
}: Props) {
const [showVerificationStep, setShowVerificationStep] =
useState<boolean>(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);

View File

@@ -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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
const { t } = useTranslation()
return <nav aria-label={t("breadcrumb")} data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -84,6 +87,8 @@ function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
const { t } = useTranslation()
return (
<span
data-slot="breadcrumb-ellipsis"
@@ -93,7 +98,7 @@ function BreadcrumbEllipsis({
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
<span className="sr-only">{t("More")}</span>
</span>
)
}

View File

@@ -2,6 +2,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { useTranslatedChildren } from "@/lib/translations"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
@@ -39,19 +40,23 @@ function Button({
variant,
size,
asChild = false,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
const translatedChildren = useTranslatedChildren(children)
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
>
{translatedChildren}
</Comp>
)
}

View File

@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react"
import { useTranslation } from "@/lib/translations"
import { cn } from "@/lib/utils"
function Dialog({
@@ -49,6 +50,8 @@ function DialogContent({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
const { t } = useTranslation()
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@@ -63,7 +66,7 @@ function DialogContent({
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t("Close")}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>

View File

@@ -1,12 +1,16 @@
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from "react"
import { useTranslatedChildren } from "@/lib/translations"
import { cn } from "@/lib/utils"
function Label({
className,
children,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const translatedChildren = useTranslatedChildren(children)
return (
<LabelPrimitive.Root
data-slot="label"
@@ -15,7 +19,9 @@ function Label({
className
)}
{...props}
/>
>
{translatedChildren}
</LabelPrimitive.Root>
)
}

View File

@@ -2,6 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react"
import { useTranslation } from "@/lib/translations"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
@@ -50,6 +51,8 @@ function SheetContent({
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
const { t } = useTranslation()
return (
<SheetPortal>
<SheetOverlay />
@@ -72,7 +75,7 @@ function SheetContent({
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{t("Close")}</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View File

@@ -21,6 +21,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { useTranslation } from "@/lib/translations"
import { cn } from "@/lib/utils"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
@@ -160,6 +161,7 @@ function Sidebar({
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { t } = useTranslation()
if (collapsible === "none") {
return (
@@ -180,8 +182,10 @@ function Sidebar({
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>{t("Sidebar")}</SheetTitle>
<SheetDescription>
{t("Displays the mobile sidebar.")}
</SheetDescription>
</SheetHeader>
<SheetContent
data-sidebar="sidebar"
@@ -252,6 +256,7 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, isMobile, state } = useSidebar()
const { t } = useTranslation()
return (
<Button
@@ -267,22 +272,23 @@ function SidebarTrigger({
{...props}
>
{isMobile || state === "collapsed" ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
<span className="sr-only">Toggle sidebar</span>
<span className="sr-only">{t("Toggle sidebar")}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle sidebar"
aria-label={t("Toggle sidebar")}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle sidebar"
title={t("Toggle sidebar")}
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",

View File

@@ -1,12 +1,15 @@
import { Loader2Icon } from "lucide-react"
import { useTranslation } from "@/lib/translations"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
const { t } = useTranslation()
return (
<Loader2Icon
role="status"
aria-label="Loading"
aria-label={t("Loading")}
className={cn("size-4 animate-spin", className)}
{...props}
/>

View File

@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { UserInfo } from '@/components/user-info';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { useTranslation } from '@/lib/translations';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
@@ -18,6 +19,7 @@ type Props = {
export function UserMenuContent({ user }: Props) {
const cleanup = useMobileNavigation();
const { t } = useTranslation();
const handleLogout = () => {
cleanup();
@@ -41,7 +43,7 @@ export function UserMenuContent({ user }: Props) {
onClick={cleanup}
>
<Settings className="mr-2" />
Settings
{t('Settings')}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -55,7 +57,7 @@ export function UserMenuContent({ user }: Props) {
data-test="logout-button"
>
<LogOut className="mr-2" />
Log out
{t('Log out')}
</Link>
</DropdownMenuItem>
</>

View File

@@ -1,5 +1,6 @@
import Hls from 'hls.js/light';
import { useEffect, useRef } from 'react';
import { useTranslation } from '@/lib/translations';
export function VideoPlayer({
src,
@@ -9,6 +10,7 @@ export function VideoPlayer({
title: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation();
useEffect(() => {
const video = videoRef.current;
@@ -39,7 +41,7 @@ export function VideoPlayer({
if (!src) {
return (
<div className="flex aspect-video w-full items-center justify-center bg-neutral-950 text-sm text-neutral-300">
Offline
{t('Offline')}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useTranslation } from '@/lib/translations';
import { home } from '@/routes';
export default function AuthCardLayout({
@@ -19,6 +20,8 @@ export default function AuthCardLayout({
title?: string;
description?: string;
}>) {
const { t } = useTranslation();
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div className="flex w-full max-w-md flex-col gap-6">
@@ -34,8 +37,12 @@ export default function AuthCardLayout({
<div className="flex flex-col gap-6">
<Card className="rounded-md shadow-sm">
<CardHeader className="px-10 pt-8 pb-0 text-center">
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
<CardTitle className="text-xl">
{t(title ?? '')}
</CardTitle>
<CardDescription>
{t(description ?? '')}
</CardDescription>
</CardHeader>
<CardContent className="px-10 py-8">
{children}

View File

@@ -1,5 +1,6 @@
import { Link } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { useTranslation } from '@/lib/translations';
import { home } from '@/routes';
import type { AuthLayoutProps } from '@/types';
@@ -8,6 +9,8 @@ export default function AuthSimpleLayout({
title,
description,
}: AuthLayoutProps) {
const { t } = useTranslation();
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
@@ -20,13 +23,15 @@ export default function AuthSimpleLayout({
<div className="mb-1 flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<AppLogoIcon className="size-6 fill-current" />
</div>
<span className="sr-only">{title}</span>
<span className="sr-only">{t(title ?? '')}</span>
</Link>
<div className="space-y-2 text-center">
<h1 className="text-xl font-semibold">{title}</h1>
<h1 className="text-xl font-semibold">
{t(title ?? '')}
</h1>
<p className="text-center text-sm text-muted-foreground">
{description}
{t(description ?? '')}
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Link, usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { useTranslation } from '@/lib/translations';
import { home } from '@/routes';
import type { AuthLayoutProps } from '@/types';
@@ -9,6 +10,7 @@ export default function AuthSplitLayout({
description,
}: AuthLayoutProps) {
const { name } = usePage().props;
const { t } = useTranslation();
return (
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
@@ -31,9 +33,11 @@ export default function AuthSplitLayout({
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
</Link>
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
<h1 className="text-xl font-medium">{title}</h1>
<h1 className="text-xl font-medium">
{t(title ?? '')}
</h1>
<p className="text-sm text-balance text-muted-foreground">
{description}
{t(description ?? '')}
</p>
</div>
{children}

View File

@@ -5,6 +5,7 @@ import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useTranslation } from '@/lib/translations';
import { cn, toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { edit } from '@/routes/profile';
@@ -31,6 +32,7 @@ const sidebarNavItems: NavItem[] = [
export default function SettingsLayout({ children }: PropsWithChildren) {
const { isCurrentOrParentUrl } = useCurrentUrl();
const { t } = useTranslation();
return (
<div className="px-4 py-6">
@@ -43,7 +45,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<aside className="w-full max-w-xl lg:w-48">
<nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Settings"
aria-label={t('Settings')}
>
{sidebarNavItems.map((item, index) => (
<Button

View File

@@ -0,0 +1,103 @@
import { usePage } from '@inertiajs/react';
import {
Children,
cloneElement,
isValidElement,
useCallback,
useMemo,
} from 'react';
import type { ReactElement, ReactNode } from 'react';
import type { TranslationReplacements, Translations } from '@/types';
export type Translate = (
key: string,
replacements?: TranslationReplacements,
) => string;
export function translateString(
translations: Translations,
key: string,
replacements: TranslationReplacements = {},
): string {
return Object.entries(replacements).reduce(
(message, [name, value]) =>
message
.replaceAll(`:${name}`, String(value ?? ''))
.replaceAll(`{${name}}`, String(value ?? '')),
translations[key] ?? key,
);
}
export function translateChildren(
children: ReactNode,
translate: Translate,
): ReactNode {
return Children.map(children, (child) => {
if (typeof child === 'string') {
return translateTextNode(child, translate);
}
if (!isValidElement(child)) {
return child;
}
const element = child as ReactElement<{ children?: ReactNode }>;
if (!element.props.children) {
return element;
}
return cloneElement(
element,
undefined,
translateChildren(element.props.children, translate),
);
});
}
export function useTranslation(): {
locale: string;
t: Translate;
translateNode: (children: ReactNode) => ReactNode;
} {
const { locale, translations } = usePage().props;
const t = useCallback<Translate>(
(key, replacements = {}) =>
translateString(translations, key, replacements),
[translations],
);
const translateNode = useCallback(
(children: ReactNode) => translateChildren(children, t),
[t],
);
return useMemo(
() => ({
locale,
t,
translateNode,
}),
[locale, t, translateNode],
);
}
export function useTranslatedChildren(children: ReactNode): ReactNode {
const { translateNode } = useTranslation();
return translateNode(children);
}
function translateTextNode(value: string, translate: Translate): string {
const text = value.trim();
if (text === '' || !/[A-Za-z]/.test(text)) {
return value;
}
const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
return `${leadingWhitespace}${translate(text)}${trailingWhitespace}`;
}

View File

@@ -27,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useTranslation } from '@/lib/translations';
import { dashboard as adminDashboard } from '@/routes/admin';
import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
import { restore, suspend } from '@/routes/admin/channels';
@@ -104,6 +105,7 @@ export default function AdminDashboard({
users,
}: Props) {
const [action, setAction] = useState<ModerationAction>(null);
const { t } = useTranslation();
function setChannelCreationDefault(channelCreationOpen: boolean) {
router.patch(
@@ -153,7 +155,7 @@ export default function AdminDashboard({
return (
<>
<Head title="Admin" />
<Head title={t('Admin')} />
<div className="grid gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
@@ -162,16 +164,19 @@ export default function AdminDashboard({
</span>
<div>
<h1 className="text-2xl font-semibold">
Moderation console
{t('Moderation console')}
</h1>
<p className="text-sm text-muted-foreground">
Review live streams, suspended states, users,
and recordings.
{t(
'Review live streams, suspended states, users, and recordings.',
)}
</p>
</div>
</div>
<div className="rounded-md border bg-background px-3 py-2 text-sm">
{liveBroadcasts.length} active broadcasts
{t(':count active broadcasts', {
count: liveBroadcasts.length,
})}
</div>
</div>
@@ -202,10 +207,12 @@ export default function AdminDashboard({
<KeyRound className="mt-0.5 size-5 text-primary" />
<div>
<h2 className="font-semibold">
Channel creation access
{t('Channel creation access')}
</h2>
<p className="text-sm text-muted-foreground">
Control who can create a creator channel.
{t(
'Control who can create a creator channel.',
)}
</p>
</div>
</div>
@@ -259,10 +266,14 @@ export default function AdminDashboard({
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Radio className="text-live size-5" />
<h2 className="font-semibold">Live broadcasts</h2>
<h2 className="font-semibold">
{t('Live broadcasts')}
</h2>
</div>
<span className="text-sm text-muted-foreground">
{liveBroadcasts.length} rows
{t(':count rows', {
count: liveBroadcasts.length,
})}
</span>
</div>
<div className="grid gap-2">
@@ -277,12 +288,15 @@ export default function AdminDashboard({
{broadcast.title}
</div>
<div className="text-sm text-muted-foreground">
{broadcast.channel.display_name} by{' '}
{broadcast.channel.owner}
{t(':channel by :owner', {
channel:
broadcast.channel.display_name,
owner: broadcast.channel.owner,
})}
</div>
<div className="bg-live mt-2 inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
{t('LIVE')}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -319,9 +333,13 @@ export default function AdminDashboard({
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="font-semibold">Recent channels</h2>
<h2 className="font-semibold">
{t('Recent channels')}
</h2>
<span className="text-sm text-muted-foreground">
{channels.length} rows
{t(':count rows', {
count: channels.length,
})}
</span>
</div>
<div className="grid gap-2">
@@ -336,18 +354,20 @@ export default function AdminDashboard({
{channel.display_name}
</div>
<div className="text-sm text-muted-foreground">
@{channel.slug} owned by{' '}
{channel.owner.name}
{t('@:slug owned by :owner', {
slug: channel.slug,
owner: channel.owner.name,
})}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{channel.is_live && (
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
LIVE
{t('LIVE')}
</span>
)}
{channel.suspended_at && (
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
SUSPENDED
{t('SUSPENDED')}
</span>
)}
</div>
@@ -400,9 +420,9 @@ export default function AdminDashboard({
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle(action)}</DialogTitle>
<DialogTitle>{t(dialogTitle(action))}</DialogTitle>
<DialogDescription>
{dialogDescription(action)}
{t(dialogDescription(action))}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -449,10 +469,12 @@ function Stat({
value: number;
live?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="rounded-md border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>{label}</span>
<span>{t(label)}</span>
<Icon className={live ? 'text-live size-4' : 'size-4'} />
</div>
<div className="mt-2 text-2xl font-semibold">{value}</div>
@@ -470,6 +492,7 @@ function CreatorAccessRow({
onChange: (canCreateChannel: boolean) => void;
}) {
const effectiveAccess = defaultOpen || user.can_create_channel;
const { t } = useTranslation();
return (
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
@@ -478,12 +501,12 @@ function CreatorAccessRow({
<span className="truncate font-medium">{user.name}</span>
{user.is_admin && (
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
ADMIN
{t('ADMIN')}
</span>
)}
{user.suspended_at && (
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
SUSPENDED
{t('SUSPENDED')}
</span>
)}
<span
@@ -493,11 +516,13 @@ function CreatorAccessRow({
: 'border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-0.5 text-xs font-medium'
}
>
{effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'}
{effectiveAccess
? t('CAN CREATE')
: t('NEEDS APPROVAL')}
</span>
{defaultOpen && !user.can_create_channel && (
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
DEFAULT
{t('DEFAULT')}
</span>
)}
</div>
@@ -563,9 +588,11 @@ function Avatar({ channel }: { channel: AdminChannel }) {
}
function EmptyState({ text }: { text: string }) {
const { t } = useTranslation();
return (
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
{text}
{t(text)}
</div>
);
}

View File

@@ -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 (
<>
<Head title="Support inbox" />
<Head title={t('Support inbox')} />
<div className="grid gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
@@ -27,11 +30,12 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
</span>
<div>
<h1 className="text-2xl font-semibold">
Support inbox
{t('Support inbox')}
</h1>
<p className="text-sm text-muted-foreground">
Contact threads from users and channel request
notes.
{t(
'Contact threads from users and channel request notes.',
)}
</p>
</div>
</div>
@@ -45,10 +49,14 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Shield className="size-5 text-primary" />
<h2 className="font-semibold">Conversations</h2>
<h2 className="font-semibold">
{t('Conversations')}
</h2>
</div>
<span className="text-sm text-muted-foreground">
{conversations.length} rows
{t(':count rows', {
count: conversations.length,
})}
</span>
</div>
<div className="grid gap-2">
@@ -96,7 +104,7 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
{conversations.length === 0 && (
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
No support conversations found.
{t('No support conversations found.')}
</div>
)}
</div>
@@ -116,15 +124,19 @@ AdminSupportIndex.layout = {
};
function Stat({ label, value }: { label: string; value: number }) {
const { t } = useTranslation();
return (
<div className="rounded-md border bg-background px-3 py-2">
<span className="text-muted-foreground">{label}</span>{' '}
<span className="text-muted-foreground">{t(label)}</span>{' '}
<span className="font-semibold">{value}</span>
</div>
);
}
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -134,7 +146,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
: 'text-muted-foreground',
)}
>
{status === 'open' ? 'OPEN' : 'CLOSED'}
{status === 'open' ? t('OPEN') : t('CLOSED')}
</span>
);
}

View File

@@ -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<HTMLInputElement>(null);
const { t } = useTranslation();
const form = useForm({
body: '',
attachments: [] as File[],
@@ -84,7 +86,7 @@ export default function AdminSupportShow({ conversation }: Props) {
>
<Link href={adminSupportIndex()}>
<ArrowLeft className="size-4" />
Support inbox
{t('Support inbox')}
</Link>
</Button>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
@@ -111,7 +113,7 @@ export default function AdminSupportShow({ conversation }: Props) {
) : (
<Lock className="size-4" />
)}
{isClosed ? 'Reopen' : 'Close'}
{isClosed ? t('Reopen') : t('Close')}
</Button>
</div>
</div>
@@ -126,7 +128,9 @@ export default function AdminSupportShow({ conversation }: Props) {
{isClosed ? (
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
<Lock className="mt-0.5 size-4 shrink-0" />
<span>Reopen the conversation to reply.</span>
<span>
{t('Reopen the conversation to reply.')}
</span>
</div>
) : (
<form onSubmit={submit} className="grid gap-4">
@@ -197,7 +201,7 @@ export default function AdminSupportShow({ conversation }: Props) {
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<User className="size-5 text-primary" />
<h2 className="font-semibold">User</h2>
<h2 className="font-semibold">{t('User')}</h2>
</div>
<div className="grid gap-3 text-sm">
<div>
@@ -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')}
</span>
{conversation.user?.suspended_at && (
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
SUSPENDED
{t('SUSPENDED')}
</span>
)}
</div>
@@ -246,12 +250,13 @@ export default function AdminSupportShow({ conversation }: Props) {
<div className="mb-3 flex items-center gap-2">
<KeyRound className="size-5 text-primary" />
<h2 className="font-semibold">
Channel request
{t('Channel request')}
</h2>
</div>
<p className="mb-4 text-sm text-muted-foreground">
Creator access grants stay in the moderation
console.
{t(
'Creator access grants stay in the moderation console.',
)}
</p>
<Button asChild variant="outline" size="sm">
<Link href={adminDashboard()}>
@@ -277,6 +282,7 @@ AdminSupportShow.layout = {
function MessageBubble({ message }: { message: SupportMessage }) {
const isAdmin = message.author.is_admin;
const { t } = useTranslation();
return (
<article
@@ -288,7 +294,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{message.author.name}</span>
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
{isAdmin ? 'ADMIN' : 'USER'}
{isAdmin ? t('ADMIN') : t('USER')}
</span>
{message.created_at && (
<span className="text-xs text-muted-foreground">
@@ -345,6 +351,8 @@ function Field({
}
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -354,7 +362,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
: 'text-muted-foreground',
)}
>
{status === 'open' ? 'OPEN' : 'CLOSED'}
{status === 'open' ? t('OPEN') : t('CLOSED')}
</span>
);
}

View File

@@ -4,12 +4,15 @@ import PasswordInput from '@/components/password-input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { useTranslation } from '@/lib/translations';
import { store } from '@/routes/password/confirm';
export default function ConfirmPassword() {
const { t } = useTranslation();
return (
<>
<Head title="Confirm password" />
<Head title={t('Confirm password')} />
<Form {...store.form()} resetOnSuccess={['password']}>
{({ processing, errors }) => (
@@ -19,7 +22,7 @@ export default function ConfirmPassword() {
<PasswordInput
id="password"
name="password"
placeholder="Password"
placeholder={t('Password')}
autoComplete="current-password"
autoFocus
/>

View File

@@ -6,13 +6,16 @@ import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/lib/translations';
import { login } from '@/routes';
import { email } from '@/routes/password';
export default function ForgotPassword({ status }: { status?: string }) {
const { t } = useTranslation();
return (
<>
<Head title="Forgot password" />
<Head title={t('Forgot password')} />
{status && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
@@ -32,7 +35,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
name="email"
autoComplete="off"
autoFocus
placeholder="email@example.com"
placeholder={t('email@example.com')}
/>
<InputError message={errors.email} />
@@ -55,8 +58,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
</Form>
<div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink href={login()}>log in</TextLink>
<span>{t('Or, return to')}</span>
<TextLink href={login()}>{t('log in')}</TextLink>
</div>
</div>
</>

View File

@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { useTranslation } from '@/lib/translations';
import { register } from '@/routes';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
@@ -22,9 +23,11 @@ export default function Login({
canResetPassword,
canRegister,
}: Props) {
const { t } = useTranslation();
return (
<>
<Head title="Log in" />
<Head title={t('Log in')} />
<Form
{...store.form()}
@@ -44,7 +47,7 @@ export default function Login({
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
placeholder={t('email@example.com')}
/>
<InputError message={errors.email} />
</div>
@@ -58,7 +61,7 @@ export default function Login({
className="ml-auto text-sm"
tabIndex={5}
>
Forgot password?
{t('Forgot password?')}
</TextLink>
)}
</div>
@@ -68,7 +71,7 @@ export default function Login({
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
placeholder={t('Password')}
/>
<InputError message={errors.password} />
</div>
@@ -96,9 +99,9 @@ export default function Login({
{canRegister && (
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
{t("Don't have an account?")}{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
{t('Sign up')}
</TextLink>
</div>
)}

View File

@@ -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 (
<>
<Head title="Register" />
<Head title={t('Register')} />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
@@ -36,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
placeholder={t('Full name')}
/>
<InputError
message={errors.name}
@@ -53,7 +56,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={2}
autoComplete="email"
name="email"
placeholder="email@example.com"
placeholder={t('email@example.com')}
/>
<InputError message={errors.email} />
</div>
@@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
placeholder={t('Password')}
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -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}
/>
<InputError
@@ -102,9 +105,9 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
{t('Already have an account?')}{' '}
<TextLink href={login()} tabIndex={6}>
Log in
{t('Log in')}
</TextLink>
</div>
</>

View File

@@ -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 (
<>
<Head title="Reset password" />
<Head title={t('Reset password')} />
<Form
{...update.form()}
@@ -50,7 +53,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
placeholder={t('Password')}
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -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}
/>
<InputError

View File

@@ -10,9 +10,11 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
import { useTranslation } from '@/lib/translations';
import { store } from '@/routes/two-factor/login';
export default function TwoFactorChallenge() {
const { t } = useTranslation();
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
const [code, setCode] = useState<string>('');
@@ -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 (
<>
<Head title="Two-factor authentication" />
<Head title={t('Two-factor authentication')} />
<div className="space-y-6">
<Form
@@ -67,7 +71,7 @@ export default function TwoFactorChallenge() {
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
placeholder={t('Enter recovery code')}
autoFocus={showRecoveryInput}
required
/>
@@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
</Button>
<div className="text-center text-sm text-muted-foreground">
<span>or you can </span>
<span>{t('or you can')} </span>
<button
type="button"
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"

View File

@@ -3,18 +3,22 @@ import { Form, Head } from '@inertiajs/react';
import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { useTranslation } from '@/lib/translations';
import { logout } from '@/routes';
import { send } from '@/routes/verification';
export default function VerifyEmail({ status }: { status?: string }) {
const { t } = useTranslation();
return (
<>
<Head title="Email verification" />
<Head title={t('Email verification')} />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address
you provided during registration.
{t(
'A new verification link has been sent to the email address you provided during registration.',
)}
</div>
)}
@@ -30,7 +34,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
href={logout()}
className="mx-auto block text-sm"
>
Log out
{t('Log out')}
</TextLink>
</>
)}

View File

@@ -35,6 +35,7 @@ import {
} from '@/components/ui/sheet';
import { VideoPlayer } from '@/components/video-player';
import { useInitials } from '@/hooks/use-initials';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
import { home, login } from '@/routes';
import { follow, unfollow } from '@/routes/channels';
@@ -84,6 +85,7 @@ export default function ChannelShow({
isFollowing,
}: Props) {
const { auth } = usePage().props;
const { t } = useTranslation();
const [chatOpen, setChatOpen] = useState(false);
const chatForm = useForm({ body: '' });
const currentViewers = viewerStats.current;
@@ -160,10 +162,14 @@ export default function ChannelShow({
<span>{channel.display_name}</span>
<span className="inline-flex items-center gap-1">
<Users className="size-4" />
{currentViewers} viewers
{t(':count viewers', {
count: currentViewers,
})}
</span>
<span>
{channel.followers_count} followers
{t(':count followers', {
count: channel.followers_count,
})}
</span>
</div>
{channel.description && (
@@ -185,7 +191,7 @@ export default function ChannelShow({
className="xl:hidden"
>
<MessageSquare className="size-4" />
Chat
{t('Chat')}
</Button>
</SheetTrigger>
<SheetContent
@@ -193,7 +199,9 @@ export default function ChannelShow({
className="h-[82svh] rounded-t-md"
>
<SheetHeader>
<SheetTitle>Live chat</SheetTitle>
<SheetTitle>
{t('Live chat')}
</SheetTitle>
</SheetHeader>
<ChatPanel
channel={channel}
@@ -223,13 +231,15 @@ export default function ChannelShow({
'fill-current text-primary',
)}
/>
{isFollowing ? 'Following' : 'Follow'}
{isFollowing
? t('Following')
: t('Follow')}
</Button>
) : (
<Button asChild variant="outline">
<Link href={login()}>
<LogIn className="size-4" />
Log in to follow
{t('Log in to follow')}
</Link>
</Button>
)}
@@ -241,11 +251,13 @@ export default function ChannelShow({
<div className="flex items-center gap-2">
<Video className="size-5 text-primary" />
<h2 className="font-semibold">
Recent VODs
{t('Recent VODs')}
</h2>
</div>
<span className="text-sm text-muted-foreground">
{vods.length} available
{t(':count available', {
count: vods.length,
})}
</span>
</div>
{vods.length > 0 ? (
@@ -256,7 +268,7 @@ export default function ChannelShow({
</div>
) : (
<div className="rounded-md border border-dashed bg-card p-6 text-sm text-muted-foreground">
No public recordings yet.
{t('No public recordings yet.')}
</div>
)}
</section>
@@ -288,6 +300,8 @@ ChannelShow.layout = {
};
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
const { t } = useTranslation();
return (
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
{channel.banner_url ? (
@@ -304,11 +318,14 @@ function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
</span>
<div>
<div className="text-xl font-semibold">
{channel.display_name} is offline
{t(':channel is offline', {
channel: channel.display_name,
})}
</div>
<p className="mt-1 text-sm text-white/70">
Recent recordings remain available below when the
creator publishes VODs.
{t(
'Recent recordings remain available below when the creator publishes VODs.',
)}
</p>
</div>
</div>
@@ -334,6 +351,7 @@ function ChatPanel({
compact?: boolean;
}) {
const getInitials = useInitials();
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
@@ -383,9 +401,11 @@ function ChatPanel({
<div className="flex items-center gap-2">
<MessageSquare className="size-5 text-primary" />
<div>
<h2 className="font-semibold">Live chat</h2>
<h2 className="font-semibold">{t('Live chat')}</h2>
<p className="text-xs text-muted-foreground">
{formatMessageCount(messages.length)}
{t(':count messages', {
count: messages.length,
})}
</p>
</div>
</div>
@@ -438,13 +458,13 @@ function ChatPanel({
)}
>
{isOwnMessage
? 'You'
? t('You')
: message.user.name}
</span>
{isChannelOwner && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
<Crown className="size-3" />
Owner
{t('Owner')}
</span>
)}
{message.created_at && (
@@ -484,8 +504,10 @@ function ChatPanel({
<MessageSquare className="size-5 text-primary" />
<span>
{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.',
)}
</span>
</div>
)}
@@ -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({
<button
key={emoji}
type="button"
aria-label={`Insert ${emoji}`}
aria-label={t('Insert :emoji', {
emoji,
})}
className="flex size-10 items-center justify-center rounded-md text-lg hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() =>
insertEmoji(emoji)
@@ -548,7 +572,7 @@ function ChatPanel({
type="submit"
size="icon"
disabled={!canSend}
aria-label="Send message"
aria-label={t('Send message')}
>
<Send className="size-4" />
</Button>
@@ -562,7 +586,7 @@ function ChatPanel({
>
{form.errors.body ??
(!channel.is_live
? 'Channel is offline'
? t('Channel is offline')
: '')}
</span>
<span
@@ -577,7 +601,7 @@ function ChatPanel({
</form>
) : (
<Button asChild variant="outline" className="w-full">
<Link href={login()}>Log in to chat</Link>
<Link href={login()}>{t('Log in to chat')}</Link>
</Button>
)}
</div>
@@ -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 (
<div className="overflow-hidden rounded-md border bg-card">
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
@@ -610,19 +632,20 @@ function VodCard({ vod }: { vod: VodSummary }) {
<div>
<div className="line-clamp-2 font-medium">{vod.title}</div>
<div className="mt-1 text-sm text-muted-foreground">
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'),
})}
</div>
</div>
{vod.playback_url ? (
<Button asChild variant="outline" size="sm">
<a href={vod.playback_url}>Watch VOD</a>
<a href={vod.playback_url}>{t('Watch VOD')}</a>
</Button>
) : (
<div className="text-sm text-muted-foreground">
Processing recording
{t('Processing recording')}
</div>
)}
</div>
@@ -637,6 +660,8 @@ function LiveState({
channel: ChannelDetail;
compact?: boolean;
}) {
const { t } = useTranslation();
if (channel.is_live) {
return (
<span
@@ -646,14 +671,14 @@ function LiveState({
)}
>
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
{t('LIVE')}
</span>
);
}
return (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
Offline
{t('Offline')}
</span>
);
}

View File

@@ -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<BroadcastSummary | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
string | null
@@ -155,7 +157,7 @@ export default function Dashboard({
return (
<>
<Head title="Creator Studio" />
<Head title={t('Creator Studio')} />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-4">
@@ -168,16 +170,17 @@ export default function Dashboard({
/>
{pendingBroadcast && !liveBroadcast && (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
{t('READY')}
</span>
)}
</div>
<h1 className="truncate text-2xl font-semibold">
{channel?.display_name ?? 'Creator Studio'}
{channel?.display_name ?? t('Creator Studio')}
</h1>
<p className="text-sm text-muted-foreground">
Prepare broadcasts, secure your stream key, and
monitor the current session.
{t(
'Prepare broadcasts, secure your stream key, and monitor the current session.',
)}
</p>
</div>
</div>
@@ -214,7 +217,7 @@ export default function Dashboard({
event.target.value,
)
}
placeholder="Nyone Live"
placeholder={t('Nyone Live')}
/>
</Field>
<Field
@@ -229,7 +232,7 @@ export default function Dashboard({
event.target.value.toLowerCase(),
)
}
placeholder="nyone-live"
placeholder={t('nyone-live')}
/>
</Field>
<Field label="Category">
@@ -284,9 +287,9 @@ export default function Dashboard({
<div className="border-warning/40 bg-warning/10 text-warning flex items-start gap-3 rounded-md border p-3 text-sm">
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
<span>
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.',
)}
</span>
</div>
</Panel>
@@ -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')
}
/>
<MetricCard
@@ -323,7 +326,7 @@ export default function Dashboard({
? new Date(
channel.stream_key_last_used_at,
).toLocaleDateString()
: 'Never'
: t('Never')
}
/>
</section>
@@ -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')}
</label>
<Button
type="submit"
@@ -390,8 +395,9 @@ export default function Dashboard({
</form>
{(liveBroadcast ?? pendingBroadcast) && (
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
Finish the current broadcast before
preparing another session.
{t(
'Finish the current broadcast before preparing another session.',
)}
</div>
)}
</Panel>
@@ -466,7 +472,7 @@ export default function Dashboard({
/>
) : (
<div className="grid h-full place-items-center text-xs text-muted-foreground">
No thumbnail
{t('No thumbnail')}
</div>
)}
</div>
@@ -486,8 +492,9 @@ export default function Dashboard({
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ImagePlus className="size-3.5" />
<span>
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.',
)}
</span>
{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 && (
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
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.',
)}
</div>
)}
<Button
@@ -624,8 +634,8 @@ export default function Dashboard({
</div>
<div className="text-sm text-muted-foreground">
{liveBroadcast.recording_enabled
? 'Recording enabled'
: 'Live only'}
? t('Recording enabled')
: t('Live only')}
</div>
</div>
<Button
@@ -647,8 +657,9 @@ export default function Dashboard({
{pendingBroadcast.title}
</div>
<div className="text-sm text-muted-foreground">
Start OBS to push the session
live.
{t(
'Start OBS to push the session live.',
)}
</div>
</div>
</div>
@@ -667,10 +678,11 @@ export default function Dashboard({
>
<DialogContent>
<DialogHeader>
<DialogTitle>Stop broadcast?</DialogTitle>
<DialogTitle>{t('Stop broadcast?')}</DialogTitle>
<DialogDescription>
This ends the live session and clears the channel's
live state. Viewers will see the offline screen.
{t(
"This ends the live session and clears the channel's live state. Viewers will see the offline screen.",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -709,13 +721,15 @@ function Panel({
description?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<section className="min-w-0 rounded-md border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="font-semibold">{title}</h2>
<h2 className="font-semibold">{t(title)}</h2>
{description && (
<p className="text-sm text-muted-foreground">
{description}
{t(description)}
</p>
)}
</div>
@@ -753,13 +767,15 @@ function CategorySelect({
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="">No category</option>
<option value="">{t('No category')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -782,10 +798,12 @@ function CopyRow({
muted?: boolean;
onCopy: (text: string) => Promise<boolean>;
}) {
const { t } = useTranslation();
return (
<div className="grid min-w-0 gap-2">
<div className="text-xs font-medium text-muted-foreground uppercase">
{label}
{t(label)}
</div>
<div className="flex min-w-0 gap-2">
<code
@@ -815,9 +833,11 @@ function CopyRow({
}
function MetricCard({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
return (
<div className="rounded-md border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-sm text-muted-foreground">{t(label)}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
@@ -831,15 +851,24 @@ function BroadcastRow({
onStop: () => void;
}) {
const active = broadcast.status !== 'ended';
const { t } = useTranslation();
const status = t(broadcast.status ?? 'unknown');
const mode = broadcast.recording_enabled ? t('recording') : t('live only');
return (
<div className="flex flex-col gap-3 rounded-md border bg-background p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium">{broadcast.title}</div>
<div className="text-sm text-muted-foreground">
{broadcast.status ?? 'unknown'} -{' '}
{broadcast.recording_enabled ? 'recording' : 'live only'}
{broadcast.has_vod ? ' - VOD ready' : ''}
{broadcast.has_vod
? t(':status - :mode - VOD ready', {
status,
mode,
})
: t(':status - :mode', {
status,
mode,
})}
</div>
</div>
{active && (
@@ -861,11 +890,13 @@ function StatusPill({
ready?: boolean;
viewers?: number;
}) {
const { t } = useTranslation();
if (live) {
return (
<span className="bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE - {viewers} viewers
{t('LIVE - :count viewers', { count: viewers })}
</span>
);
}
@@ -873,14 +904,14 @@ function StatusPill({
if (ready) {
return (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
{t('READY')}
</span>
);
}
return (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
OFFLINE
{t('OFFLINE')}
</span>
);
}
@@ -908,6 +939,8 @@ function ReadinessRow({
done?: boolean;
label: string;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
<span
@@ -918,15 +951,17 @@ function ReadinessRow({
>
{done && <Check className="size-3" />}
</span>
<span className="text-sm">{label}</span>
<span className="text-sm">{t(label)}</span>
</div>
);
}
function EmptyState({ text }: { text: string }) {
const { t } = useTranslation();
return (
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
{text}
{t(text)}
</div>
);
}

View File

@@ -1,14 +1,17 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import Heading from '@/components/heading';
import { useTranslation } from '@/lib/translations';
import { edit as editAppearance } from '@/routes/appearance';
export default function Appearance() {
const { t } = useTranslation();
return (
<>
<Head title="Appearance settings" />
<Head title={t('Appearance settings')} />
<h1 className="sr-only">Appearance settings</h1>
<h1 className="sr-only">{t('Appearance settings')}</h1>
<div className="space-y-6">
<Heading

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useInitials } from '@/hooks/use-initials';
import { useTranslation } from '@/lib/translations';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
@@ -21,6 +22,7 @@ export default function Profile({
status?: string;
}) {
const { auth } = usePage().props;
const { t } = useTranslation();
const user = auth.user;
const getInitials = useInitials();
const avatarInputRef = useRef<HTMLInputElement>(null);
@@ -52,9 +54,9 @@ export default function Profile({
return (
<>
<Head title="Profile settings" />
<Head title={t('Profile settings')} />
<h1 className="sr-only">Profile settings</h1>
<h1 className="sr-only">{t('Profile settings')}</h1>
<div className="space-y-6">
<Heading
@@ -114,7 +116,7 @@ export default function Profile({
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ImagePlus className="size-3.5" />
<span>
JPG, PNG, or WebP up to 2 MB.
{t('JPG, PNG, or WebP up to 2 MB.')}
</span>
</div>
{progress && (
@@ -138,7 +140,7 @@ export default function Profile({
name="name"
required
autoComplete="name"
placeholder="Full name"
placeholder={t('Full name')}
/>
<InputError
@@ -158,7 +160,7 @@ export default function Profile({
name="email"
required
autoComplete="username"
placeholder="Email address"
placeholder={t('Email address')}
/>
<InputError
@@ -171,22 +173,26 @@ export default function Profile({
user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
{t(
'Your email address is unverified.',
)}{' '}
<Link
href={send()}
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the
verification email.
{t(
'Click here to resend the verification email.',
)}
</Link>
</p>
{status ===
'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been
sent to your email address.
{t(
'A new verification link has been sent to your email address.',
)}
</div>
)}
</div>

View File

@@ -10,6 +10,7 @@ import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
import { useTranslation } from '@/lib/translations';
import { edit } from '@/routes/security';
import { disable, enable } from '@/routes/two-factor';
@@ -26,6 +27,7 @@ export default function Security({
twoFactorEnabled = false,
passwordRules,
}: Props) {
const { t } = useTranslation();
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
@@ -53,9 +55,9 @@ export default function Security({
return (
<>
<Head title="Security settings" />
<Head title={t('Security settings')} />
<h1 className="sr-only">Security settings</h1>
<h1 className="sr-only">{t('Security settings')}</h1>
<div className="space-y-6">
<Heading
@@ -99,7 +101,7 @@ export default function Security({
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
placeholder={t('Current password')}
/>
<InputError message={errors.current_password} />
@@ -114,7 +116,7 @@ export default function Security({
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
placeholder={t('New password')}
passwordrules={passwordRules}
/>
@@ -131,7 +133,7 @@ export default function Security({
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
placeholder={t('Confirm password')}
passwordrules={passwordRules}
/>
@@ -163,9 +165,9 @@ export default function Security({
{twoFactorEnabled ? (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-sm text-muted-foreground">
You will be prompted for a secure, random pin
during login, which you can retrieve from the
TOTP-supported application on your phone.
{t(
'You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.',
)}
</p>
<div className="relative inline">
@@ -191,10 +193,9 @@ export default function Security({
) : (
<div className="flex flex-col items-start justify-start space-y-4">
<p className="text-sm text-muted-foreground">
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.
{t(
'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.',
)}
</p>
<div>

View File

@@ -6,6 +6,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 {
index as supportIndex,
@@ -24,6 +25,7 @@ type Props = {
export default function SupportIndex({ categories, conversations }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const form = useForm({
category: categories[0]?.value ?? 'bug',
subject: '',
@@ -53,7 +55,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
return (
<>
<Head title="Contact admin" />
<Head title={t('Contact admin')} />
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_420px]">
<section className="grid content-start gap-4">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
@@ -63,16 +65,19 @@ export default function SupportIndex({ categories, conversations }: Props) {
</span>
<div>
<h1 className="text-2xl font-semibold">
Contact admin
{t('Contact admin')}
</h1>
<p className="text-sm text-muted-foreground">
Bug reports, suggestions, channel requests,
and account questions.
{t(
'Bug reports, suggestions, channel requests, and account questions.',
)}
</p>
</div>
</div>
<div className="rounded-md border bg-background px-3 py-2 text-sm">
{conversations.length} threads
{t(':count threads', {
count: conversations.length,
})}
</div>
</div>
@@ -114,7 +119,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
{conversations.length === 0 && (
<div className="grid min-h-64 place-items-center rounded-md border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
No conversations yet.
{t('No conversations yet.')}
</div>
)}
</div>
@@ -122,9 +127,11 @@ export default function SupportIndex({ categories, conversations }: Props) {
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="font-semibold">New conversation</h2>
<h2 className="font-semibold">
{t('New conversation')}
</h2>
<p className="text-sm text-muted-foreground">
Attach up to 3 files, 200 MB each.
{t('Attach up to 3 files, 200 MB each.')}
</p>
</div>
<form onSubmit={submit} className="grid gap-4">
@@ -237,6 +244,8 @@ function Field({
}
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -246,7 +255,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
: 'text-muted-foreground',
)}
>
{status === 'open' ? 'OPEN' : 'CLOSED'}
{status === 'open' ? t('OPEN') : t('CLOSED')}
</span>
);
}

View File

@@ -6,6 +6,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 { index as supportIndex } from '@/routes/support';
import { show as showAttachment } from '@/routes/support/attachments';
@@ -22,6 +23,7 @@ type Props = {
export default function SupportShow({ conversation }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const form = useForm({
body: '',
attachments: [] as File[],
@@ -56,7 +58,7 @@ export default function SupportShow({ conversation }: Props) {
<Button asChild variant="ghost" size="sm" className="mb-4">
<Link href={supportIndex()}>
<ArrowLeft className="size-4" />
Contact admin
{t('Contact admin')}
</Link>
</Button>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
@@ -72,7 +74,9 @@ export default function SupportShow({ conversation }: Props) {
</h1>
</div>
<div className="rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground">
{conversation.messages_count} messages
{t(':count messages', {
count: conversation.messages_count,
})}
</div>
</div>
</div>
@@ -87,7 +91,7 @@ export default function SupportShow({ conversation }: Props) {
{isClosed ? (
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
<Lock className="mt-0.5 size-4 shrink-0" />
<span>This conversation is closed.</span>
<span>{t('This conversation is closed.')}</span>
</div>
) : (
<form onSubmit={submit} className="grid gap-4">
@@ -161,6 +165,7 @@ SupportShow.layout = {
function MessageBubble({ message }: { message: SupportMessage }) {
const isAdmin = message.author.is_admin;
const { t } = useTranslation();
return (
<article
@@ -172,7 +177,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{message.author.name}</span>
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
{isAdmin ? 'ADMIN' : 'YOU'}
{isAdmin ? t('ADMIN') : t('YOU')}
</span>
{message.created_at && (
<span className="text-xs text-muted-foreground">
@@ -229,6 +234,8 @@ function Field({
}
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -238,7 +245,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
: 'text-muted-foreground',
)}
>
{status === 'open' ? 'OPEN' : 'CLOSED'}
{status === 'open' ? t('OPEN') : t('CLOSED')}
</span>
);
}

View File

@@ -13,6 +13,7 @@ import {
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
import { dashboard, home, login, register } from '@/routes';
import { show as showChannel } from '@/routes/channels';
@@ -31,6 +32,7 @@ export default function Welcome({
categories,
}: Props) {
const { auth } = usePage().props;
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [category, setCategory] = useState<string>('all');
@@ -55,7 +57,7 @@ export default function Welcome({
return (
<>
<Head title="Live" />
<Head title={t('Live')} />
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur">
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4">
@@ -74,7 +76,7 @@ export default function Welcome({
href={home()}
className="rounded-md px-3 py-2 text-foreground"
>
Live
{t('Live')}
</Link>
{auth.user && (
<>
@@ -82,13 +84,13 @@ export default function Welcome({
href={dashboard()}
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
>
Studio
{t('Studio')}
</Link>
<Link
href={supportIndex()}
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
>
Contact
{t('Contact')}
</Link>
</>
)}
@@ -100,26 +102,28 @@ export default function Welcome({
<Button asChild size="sm">
<Link href={dashboard()}>
<LayoutDashboard className="size-4" />
Studio
{t('Studio')}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={supportIndex()}>
<LifeBuoy className="size-4" />
Contact
{t('Contact')}
</Link>
</Button>
</>
) : (
<>
<Button asChild variant="ghost" size="sm">
<Link href={login()}>Log in</Link>
<Link href={login()}>
{t('Log in')}
</Link>
</Button>
{canRegister && (
<Button asChild size="sm">
<Link href={register()}>
<UserPlus className="size-4" />
Register
{t('Register')}
</Link>
</Button>
)}
@@ -140,7 +144,7 @@ export default function Welcome({
<Thumbnail
channel={featuredChannel}
className="aspect-video lg:aspect-auto lg:w-[58%] lg:flex-none"
label="Featured live"
label={t('Featured live')}
/>
<div className="grid min-w-0 flex-1 content-between gap-6 p-5">
<div className="min-w-0 space-y-4">
@@ -152,7 +156,14 @@ export default function Welcome({
</h1>
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
{featuredChannel.description ??
`${featuredChannel.display_name} is live now with ${featuredChannel.viewer_count} viewers.`}
t(
':channel is live now with :count viewers.',
{
channel:
featuredChannel.display_name,
count: featuredChannel.viewer_count,
},
)}
</p>
</div>
</div>
@@ -162,17 +173,17 @@ export default function Welcome({
</span>
<span className="min-w-0 break-words">
{featuredChannel.category
?.name ?? 'Uncategorized'}
?.name ??
t('Uncategorized')}
</span>
<span className="inline-flex items-center gap-1">
<Users className="size-4" />
{
featuredChannel.viewer_count
}{' '}
viewers
{t(':count viewers', {
count: featuredChannel.viewer_count,
})}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
Watch
{t('Watch')}
<ArrowRight className="size-4" />
</span>
</div>
@@ -186,11 +197,12 @@ export default function Welcome({
</div>
<div className="max-w-xl space-y-2">
<h1 className="text-2xl font-semibold md:text-4xl">
The live floor is quiet
{t('The live floor is quiet')}
</h1>
<p className="text-sm leading-6 text-muted-foreground md:text-base">
Creators can prepare a broadcast in the
studio and go live from OBS when ready.
{t(
'Creators can prepare a broadcast in the studio and go live from OBS when ready.',
)}
</p>
</div>
</div>
@@ -200,10 +212,10 @@ export default function Welcome({
<div className="flex items-center justify-between gap-3 border-b pb-3">
<div>
<div className="text-sm font-medium">
Live signal
{t('Live signal')}
</div>
<div className="text-xs text-muted-foreground">
Public channels broadcasting now
{t('Public channels broadcasting now')}
</div>
</div>
<div className="bg-live rounded-md px-2 py-1 text-xs font-medium text-white">
@@ -212,12 +224,12 @@ export default function Welcome({
</div>
<Metric
icon={Radio}
label="Live channels"
label={t('Live channels')}
value={String(liveChannels.length)}
/>
<Metric
icon={Users}
label="Viewers"
label={t('Viewers')}
value={String(
liveChannels.reduce(
(total, item) =>
@@ -228,7 +240,7 @@ export default function Welcome({
/>
<Metric
icon={Compass}
label="Categories"
label={t('Categories')}
value={String(categories.length)}
/>
<Button asChild variant="outline">
@@ -236,8 +248,8 @@ export default function Welcome({
href={auth.user ? dashboard() : register()}
>
{auth.user
? 'Open studio'
: 'Start a channel'}
? t('Open studio')
: t('Start a channel')}
</Link>
</Button>
</aside>
@@ -247,10 +259,12 @@ export default function Welcome({
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold">
Live directory
{t('Live directory')}
</h2>
<p className="text-sm text-muted-foreground">
Browse active channels by category or title.
{t(
'Browse active channels by category or title.',
)}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
@@ -261,7 +275,7 @@ export default function Welcome({
onChange={(event) =>
setQuery(event.target.value)
}
placeholder="Search live channels"
placeholder={t('Search live channels')}
className="pl-9 sm:w-72"
/>
</div>
@@ -272,7 +286,9 @@ export default function Welcome({
}
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="all">All categories</option>
<option value="all">
{t('All categories')}
</option>
{categories.map((item) => (
<option key={item.id} value={item.slug}>
{item.name}
@@ -296,10 +312,12 @@ export default function Welcome({
<Search className="size-8 text-muted-foreground" />
<div>
<div className="font-medium">
No streams match this view
{t('No streams match this view')}
</div>
<div className="text-sm text-muted-foreground">
Clear search or switch categories.
{t(
'Clear search or switch categories.',
)}
</div>
</div>
</div>
@@ -312,6 +330,8 @@ export default function Welcome({
}
function StreamCard({ channel }: { channel: ChannelCard }) {
const { t } = useTranslation();
return (
<Link
href={showChannel(channel.slug)}
@@ -325,16 +345,21 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
{channel.broadcast?.title ?? channel.display_name}
</div>
<div className="truncate text-sm text-muted-foreground">
{channel.display_name} by {channel.owner.name}
{t(':channel by :owner', {
channel: channel.display_name,
owner: channel.owner.name,
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span className="truncate">
{channel.category?.name ?? 'Uncategorized'}
{channel.category?.name ?? t('Uncategorized')}
</span>
<span className="shrink-0">
{channel.viewer_count} viewers
{t(':count viewers', {
count: channel.viewer_count,
})}
</span>
</div>
</div>
@@ -351,6 +376,8 @@ function Thumbnail({
className?: string;
label?: string;
}) {
const { t } = useTranslation();
return (
<div
className={cn(
@@ -368,7 +395,7 @@ function Thumbnail({
<div className="grid h-full w-full place-items-center bg-[radial-gradient(circle_at_40%_30%,oklch(0.34_0.12_326),oklch(0.14_0.04_315)_55%)]">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-live live-pulse size-2 rounded-full" />
{label}
{t(label)}
</div>
</div>
)}
@@ -380,6 +407,8 @@ function Thumbnail({
}
function LiveBadge({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation();
return (
<span
className={cn(
@@ -388,7 +417,7 @@ function LiveBadge({ compact = false }: { compact?: boolean }) {
)}
>
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
{t('LIVE')}
</span>
);
}

View File

@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth';
import type { Translations } from '@/types/localization';
declare module 'react' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -12,7 +13,10 @@ declare module '@inertiajs/core' {
sharedPageProps: {
name: string;
auth: Auth;
fallbackLocale: string;
locale: string;
sidebarOpen: boolean;
translations: Translations;
[key: string]: unknown;
};
}

View File

@@ -1,4 +1,5 @@
export type * from './auth';
export type * from './localization';
export type * from './navigation';
export type * from './streaming';
export type * from './support';

View File

@@ -0,0 +1,6 @@
export type Translations = Record<string, string>;
export type TranslationReplacements = Record<
string,
boolean | null | number | string | undefined
>;

View File

@@ -0,0 +1,25 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class LocalizationTest extends TestCase
{
use RefreshDatabase;
public function test_inertia_responses_share_locale_and_translations(): void
{
$this->get(route('home'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('welcome')
->where('locale', app()->getLocale())
->where('fallbackLocale', config('app.fallback_locale'))
->where('translations.Live', 'Live')
->where('translations.Password', 'Password')
);
}
}