Implemented project-wide localization plumbing and converted the visible UI/app messages to translation-ready strings.
This commit is contained in:
@@ -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.'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
323
lang/en.json
Normal 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
20
lang/en/auth.php
Normal 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
19
lang/en/pagination.php
Normal 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' => '« Previous',
|
||||
'next' => 'Next »',
|
||||
|
||||
];
|
||||
22
lang/en/passwords.php
Normal file
22
lang/en/passwords.php
Normal 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
200
lang/en/validation.php
Normal 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' => [],
|
||||
|
||||
];
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
103
resources/js/lib/translations.tsx
Normal file
103
resources/js/lib/translations.tsx
Normal 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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
{t('Expires :date', {
|
||||
date: vod.expires_at
|
||||
? new Date(vod.expires_at).toLocaleDateString()
|
||||
: 'later'}
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
4
resources/js/types/global.d.ts
vendored
4
resources/js/types/global.d.ts
vendored
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type * from './auth';
|
||||
export type * from './localization';
|
||||
export type * from './navigation';
|
||||
export type * from './streaming';
|
||||
export type * from './support';
|
||||
|
||||
6
resources/js/types/localization.ts
Normal file
6
resources/js/types/localization.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Translations = Record<string, string>;
|
||||
|
||||
export type TranslationReplacements = Record<
|
||||
string,
|
||||
boolean | null | number | string | undefined
|
||||
>;
|
||||
25
tests/Feature/LocalizationTest.php
Normal file
25
tests/Feature/LocalizationTest.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user