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(
|
return back()->with(
|
||||||
'success',
|
'success',
|
||||||
$settings->channel_creation_open
|
$settings->channel_creation_open
|
||||||
? 'All verified users can create channels.'
|
? __('All verified users can create channels.')
|
||||||
: 'Channel creation now requires admin approval.',
|
: __('Channel creation now requires admin approval.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ class AdminChannelCreationController extends Controller
|
|||||||
return back()->with(
|
return back()->with(
|
||||||
'success',
|
'success',
|
||||||
$user->can_create_channel
|
$user->can_create_channel
|
||||||
? 'User can create a channel.'
|
? __('User can create a channel.')
|
||||||
: 'User channel creation access removed.',
|
: __('User channel creation access removed.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class AdminModerationController extends Controller
|
|||||||
|
|
||||||
$this->audit($request, $channel, 'channel.suspended');
|
$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
|
public function restoreChannel(Request $request, Channel $channel): RedirectResponse
|
||||||
@@ -34,7 +34,7 @@ class AdminModerationController extends Controller
|
|||||||
|
|
||||||
$this->audit($request, $channel, 'channel.restored');
|
$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
|
public function suspendUser(Request $request, User $user, ViewerCountStore $viewerCounts): RedirectResponse
|
||||||
@@ -52,7 +52,7 @@ class AdminModerationController extends Controller
|
|||||||
|
|
||||||
$this->audit($request, $user, 'user.suspended');
|
$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
|
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
|
||||||
@@ -71,7 +71,7 @@ class AdminModerationController extends Controller
|
|||||||
|
|
||||||
$this->audit($request, $broadcast, 'broadcast.stopped');
|
$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
|
public function deleteVod(Request $request, Vod $vod): RedirectResponse
|
||||||
@@ -80,7 +80,7 @@ class AdminModerationController extends Controller
|
|||||||
|
|
||||||
$this->audit($request, $vod, 'vod.deleted');
|
$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
|
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
|
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();
|
$validated = $request->validated();
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class AdminSupportConversationController extends Controller
|
|||||||
$request->file('attachments', []) ?: [],
|
$request->file('attachments', []) ?: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
return back()->with('success', 'Reply sent.');
|
return back()->with('success', __('Reply sent.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateSupportConversationStatusRequest $request, SupportConversation $conversation): RedirectResponse
|
public function update(UpdateSupportConversationStatusRequest $request, SupportConversation $conversation): RedirectResponse
|
||||||
@@ -68,7 +68,7 @@ class AdminSupportConversationController extends Controller
|
|||||||
|
|
||||||
return back()->with(
|
return back()->with(
|
||||||
'success',
|
'success',
|
||||||
$conversation->isOpen() ? 'Conversation reopened.' : 'Conversation closed.',
|
$conversation->isOpen() ? __('Conversation reopened.') : __('Conversation closed.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class AdminSupportConversationController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => $conversation->id,
|
'id' => $conversation->id,
|
||||||
'category' => $conversation->category,
|
'category' => $conversation->category,
|
||||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other',
|
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'),
|
||||||
'subject' => $conversation->subject,
|
'subject' => $conversation->subject,
|
||||||
'status' => $conversation->status,
|
'status' => $conversation->status,
|
||||||
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||||
@@ -144,7 +144,7 @@ class AdminSupportConversationController extends Controller
|
|||||||
'created_at' => $message->created_at?->toIso8601String(),
|
'created_at' => $message->created_at?->toIso8601String(),
|
||||||
'author' => [
|
'author' => [
|
||||||
'id' => $message->user?->id,
|
'id' => $message->user?->id,
|
||||||
'name' => $message->user?->name ?? 'Deleted user',
|
'name' => $message->user?->name ?? __('Deleted user'),
|
||||||
'is_admin' => (bool) $message->user?->is_admin,
|
'is_admin' => (bool) $message->user?->is_admin,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ChatMessageController extends Controller
|
|||||||
public function store(Request $request, Channel $channel): RedirectResponse
|
public function store(Request $request, Channel $channel): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_if($request->user()->isSuspended() || $channel->isSuspended(), 403);
|
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([
|
$validated = $request->validate([
|
||||||
'body' => ['required', 'string', 'max:500'],
|
'body' => ['required', 'string', 'max:500'],
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class CreatorDashboardController extends Controller
|
|||||||
|
|
||||||
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
|
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_unless($request->user()->canCreateChannel(), 403, __('An admin must enable channel creation for this account.'));
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'display_name' => ['required', 'string', 'max:80'],
|
'display_name' => ['required', 'string', 'max:80'],
|
||||||
@@ -79,7 +79,7 @@ class CreatorDashboardController extends Controller
|
|||||||
return redirect()
|
return redirect()
|
||||||
->route('dashboard')
|
->route('dashboard')
|
||||||
->with('plain_stream_key', $plainStreamKey)
|
->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
|
public function updateChannel(Request $request): RedirectResponse
|
||||||
@@ -112,7 +112,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'slug' => Str::slug($validated['slug']),
|
'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
|
public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
||||||
@@ -125,7 +125,7 @@ class CreatorDashboardController extends Controller
|
|||||||
|
|
||||||
return back()
|
return back()
|
||||||
->with('plain_stream_key', $plainStreamKey)
|
->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
|
public function storeBroadcast(Request $request): RedirectResponse
|
||||||
@@ -137,7 +137,7 @@ class CreatorDashboardController extends Controller
|
|||||||
abort_if(
|
abort_if(
|
||||||
$channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(),
|
$channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(),
|
||||||
422,
|
422,
|
||||||
'Finish the current broadcast before creating another.',
|
__('Finish the current broadcast before creating another.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
@@ -152,7 +152,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'mediamtx_path' => $channel->slug,
|
'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
|
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
|
||||||
@@ -171,7 +171,7 @@ class CreatorDashboardController extends Controller
|
|||||||
])->save();
|
])->save();
|
||||||
$viewerCounts->forget($broadcast->channel);
|
$viewerCounts->forget($broadcast->channel);
|
||||||
|
|
||||||
return back()->with('success', 'Broadcast ended.');
|
return back()->with('success', __('Broadcast ended.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function channelPayload(Channel $channel): array
|
private function channelPayload(Channel $channel): array
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class SupportConversationController extends Controller
|
|||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('support.show', $conversation)
|
->route('support.show', $conversation)
|
||||||
->with('success', 'Message sent to admin.');
|
->with('success', __('Message sent to admin.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Request $request, SupportConversation $conversation): Response
|
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
|
public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorizeUserConversation($request, $conversation);
|
$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();
|
$validated = $request->validated();
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class SupportConversationController extends Controller
|
|||||||
$request->file('attachments', []) ?: [],
|
$request->file('attachments', []) ?: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
return back()->with('success', 'Reply sent.');
|
return back()->with('success', __('Reply sent.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeUserConversation(Request $request, SupportConversation $conversation): void
|
private function authorizeUserConversation(Request $request, SupportConversation $conversation): void
|
||||||
@@ -117,7 +117,7 @@ class SupportConversationController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => $conversation->id,
|
'id' => $conversation->id,
|
||||||
'category' => $conversation->category,
|
'category' => $conversation->category,
|
||||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other',
|
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'),
|
||||||
'subject' => $conversation->subject,
|
'subject' => $conversation->subject,
|
||||||
'status' => $conversation->status,
|
'status' => $conversation->status,
|
||||||
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||||
@@ -153,7 +153,7 @@ class SupportConversationController extends Controller
|
|||||||
'created_at' => $message->created_at?->toIso8601String(),
|
'created_at' => $message->created_at?->toIso8601String(),
|
||||||
'author' => [
|
'author' => [
|
||||||
'id' => $message->user?->id,
|
'id' => $message->user?->id,
|
||||||
'name' => $message->user?->name ?? 'Deleted user',
|
'name' => $message->user?->name ?? __('Deleted user'),
|
||||||
'is_admin' => (bool) $message->user?->is_admin,
|
'is_admin' => (bool) $message->user?->is_admin,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -41,7 +41,40 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
|
'fallbackLocale' => (string) config('app.fallback_locale'),
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'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
|
public static function categoryLabels(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
self::CATEGORY_BUG => 'Bug report',
|
self::CATEGORY_BUG => (string) __('Bug report'),
|
||||||
self::CATEGORY_SUGGESTION => 'Suggestion',
|
self::CATEGORY_SUGGESTION => (string) __('Suggestion'),
|
||||||
self::CATEGORY_CHANNEL_REQUEST => 'Channel request',
|
self::CATEGORY_CHANNEL_REQUEST => (string) __('Channel request'),
|
||||||
self::CATEGORY_OTHER => 'Other',
|
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.file' => __('Attachment upload failed. Try the file again or choose a different file.'),
|
||||||
'attachment.max' => 'Attachments must be 200 MB or smaller.',
|
'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.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 { AlertCircleIcon } from 'lucide-react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
|
|
||||||
export default function AlertError({
|
export default function AlertError({
|
||||||
errors,
|
errors,
|
||||||
@@ -8,10 +9,12 @@ export default function AlertError({
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
|
<AlertTitle>{t(title || 'Something went wrong.')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul className="list-inside list-disc text-sm">
|
<ul className="list-inside list-disc text-sm">
|
||||||
{Array.from(new Set(errors)).map((error, index) => (
|
{Array.from(new Set(errors)).map((error, index) => (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { UserMenuContent } from '@/components/user-menu-content';
|
import { UserMenuContent } from '@/components/user-menu-content';
|
||||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||||
import { useInitials } from '@/hooks/use-initials';
|
import { useInitials } from '@/hooks/use-initials';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard, home } from '@/routes';
|
import { dashboard, home } from '@/routes';
|
||||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||||
@@ -56,6 +57,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
const { auth } = page.props;
|
const { auth } = page.props;
|
||||||
const getInitials = useInitials();
|
const getInitials = useInitials();
|
||||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useTranslation();
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Live',
|
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"
|
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">
|
<SheetTitle className="sr-only">
|
||||||
Navigation menu
|
{t('Navigation menu')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetHeader className="flex justify-start text-left">
|
<SheetHeader className="flex justify-start text-left">
|
||||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
<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 && (
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
<span>{item.title}</span>
|
<span>{t(item.title)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +170,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
|||||||
{item.icon && (
|
{item.icon && (
|
||||||
<item.icon className="mr-2 h-4 w-4" />
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{item.title}
|
{t(item.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{isCurrentUrl(item.href) && (
|
{isCurrentUrl(item.href) && (
|
||||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
|
<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"
|
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">
|
<span className="sr-only">
|
||||||
Live directory
|
{t('Live directory')}
|
||||||
</span>
|
</span>
|
||||||
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
|
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Live directory</p>
|
<p>{t('Live directory')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Monitor, Moon, Sun } from 'lucide-react';
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import type { Appearance } from '@/hooks/use-appearance';
|
import type { Appearance } from '@/hooks/use-appearance';
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AppearanceToggleTab({
|
export default function AppearanceToggleTab({
|
||||||
@@ -10,6 +11,7 @@ export default function AppearanceToggleTab({
|
|||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement>) {
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||||
{ value: 'light', icon: Sun, label: 'Light' },
|
{ value: 'light', icon: Sun, label: 'Light' },
|
||||||
@@ -37,7 +39,7 @@ export default function AppearanceToggleTab({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="-ml-1 h-4 w-4" />
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
|
||||||
export function Breadcrumbs({
|
export function Breadcrumbs({
|
||||||
@@ -15,6 +16,8 @@ export function Breadcrumbs({
|
|||||||
}: {
|
}: {
|
||||||
breadcrumbs: BreadcrumbItemType[];
|
breadcrumbs: BreadcrumbItemType[];
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{breadcrumbs.length > 0 && (
|
{breadcrumbs.length > 0 && (
|
||||||
@@ -28,12 +31,12 @@ export function Breadcrumbs({
|
|||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>
|
<BreadcrumbPage>
|
||||||
{item.title}
|
{t(item.title)}
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
{item.title}
|
{t(item.title)}
|
||||||
</Link>
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
|
|
||||||
export default function DeleteUser() {
|
export default function DeleteUser() {
|
||||||
const passwordInput = useRef<HTMLInputElement>(null);
|
const passwordInput = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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="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">
|
<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">
|
<p className="text-sm">
|
||||||
Please proceed with caution, this cannot be undone.
|
{t(
|
||||||
|
'Please proceed with caution, this cannot be undone.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,13 +49,12 @@ export default function DeleteUser() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Are you sure you want to delete your account?
|
{t('Are you sure you want to delete your account?')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Once your account is deleted, all of its resources
|
{t(
|
||||||
and data will also be permanently deleted. Please
|
'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.',
|
||||||
enter your password to confirm you would like to
|
)}
|
||||||
permanently delete your account.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -77,7 +80,7 @@ export default function DeleteUser() {
|
|||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
ref={passwordInput}
|
ref={passwordInput}
|
||||||
placeholder="Password"
|
placeholder={t('Password')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
|
|
||||||
export default function Heading({
|
export default function Heading({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -7,6 +9,8 @@ export default function Heading({
|
|||||||
description?: string;
|
description?: string;
|
||||||
variant?: 'default' | 'small';
|
variant?: 'default' | 'small';
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
|
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
|
||||||
<h2
|
<h2
|
||||||
@@ -16,10 +20,12 @@ export default function Heading({
|
|||||||
: 'text-xl font-semibold tracking-tight'
|
: 'text-xl font-semibold tracking-tight'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{title}
|
{t(title)}
|
||||||
</h2>
|
</h2>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(description)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,25 +7,27 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="px-2 py-0">
|
<SidebarGroup className="px-2 py-0">
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>{t('Platform')}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
isActive={isCurrentUrl(item.href)}
|
isActive={isCurrentUrl(item.href)}
|
||||||
tooltip={{ children: item.title }}
|
tooltip={{ children: t(item.title) }}
|
||||||
>
|
>
|
||||||
<Link href={item.href} prefetch>
|
<Link href={item.href} prefetch>
|
||||||
{item.icon && <item.icon />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{t(item.title)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Eye, EyeOff } from 'lucide-react';
|
|||||||
import type { ComponentProps, Ref } from 'react';
|
import type { ComponentProps, Ref } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function PasswordInput({
|
export default function PasswordInput({
|
||||||
@@ -10,6 +11,7 @@ export default function PasswordInput({
|
|||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
|
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -23,7 +25,7 @@ export default function PasswordInput({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
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"
|
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}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,6 +27,7 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
|
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
|
||||||
const codesSectionRef = useRef<HTMLDivElement | null>(null);
|
const codesSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
|
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const toggleCodesVisibility = useCallback(async () => {
|
const toggleCodesVisibility = useCallback(async () => {
|
||||||
if (!codesAreVisible && !recoveryCodesList.length) {
|
if (!codesAreVisible && !recoveryCodesList.length) {
|
||||||
@@ -57,11 +59,12 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex gap-3">
|
<CardTitle className="flex gap-3">
|
||||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||||
2FA recovery codes
|
{t('2FA recovery codes')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Recovery codes let you regain access if you lose your 2FA
|
{t(
|
||||||
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.',
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -76,7 +79,9 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
className="size-4"
|
className="size-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{codesAreVisible ? 'Hide' : 'View'} recovery codes
|
{codesAreVisible
|
||||||
|
? t('Hide recovery codes')
|
||||||
|
: t('View recovery codes')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canRegenerateCodes && (
|
{canRegenerateCodes && (
|
||||||
@@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
disabled={processing}
|
disabled={processing}
|
||||||
aria-describedby="regenerate-warning"
|
aria-describedby="regenerate-warning"
|
||||||
>
|
>
|
||||||
<RefreshCw /> Regenerate codes
|
<RefreshCw /> {t('Regenerate codes')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
@@ -112,7 +117,7 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
ref={codesSectionRef}
|
ref={codesSectionRef}
|
||||||
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||||
role="list"
|
role="list"
|
||||||
aria-label="Recovery codes"
|
aria-label={t('Recovery codes')}
|
||||||
>
|
>
|
||||||
{recoveryCodesList.length ? (
|
{recoveryCodesList.length ? (
|
||||||
recoveryCodesList.map((code, index) => (
|
recoveryCodesList.map((code, index) => (
|
||||||
@@ -127,7 +132,9 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
aria-label="Loading recovery codes"
|
aria-label={t(
|
||||||
|
'Loading recovery codes',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{Array.from(
|
{Array.from(
|
||||||
{ length: 8 },
|
{ length: 8 },
|
||||||
@@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground select-none">
|
<div className="text-xs text-muted-foreground select-none">
|
||||||
<p id="regenerate-warning">
|
<p id="regenerate-warning">
|
||||||
Each recovery code can be used once to
|
{t(
|
||||||
access your account and will be removed
|
'Each recovery code can be used once to access your account and will be removed after use. If you need more, click',
|
||||||
after use. If you need more, click{' '}
|
)}{' '}
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
Regenerate codes
|
{t('Regenerate codes')}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
above.
|
{t('above.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
import { useClipboard } from '@/hooks/use-clipboard';
|
import { useClipboard } from '@/hooks/use-clipboard';
|
||||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { confirm } from '@/routes/two-factor';
|
import { confirm } from '@/routes/two-factor';
|
||||||
|
|
||||||
function GridScanIcon() {
|
function GridScanIcon() {
|
||||||
@@ -64,6 +65,7 @@ function TwoFactorSetupStep({
|
|||||||
}) {
|
}) {
|
||||||
const { resolvedAppearance } = useAppearance();
|
const { resolvedAppearance } = useAppearance();
|
||||||
const [copiedText, copy] = useClipboard();
|
const [copiedText, copy] = useClipboard();
|
||||||
|
const { t } = useTranslation();
|
||||||
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +106,7 @@ function TwoFactorSetupStep({
|
|||||||
<div className="relative flex w-full items-center justify-center">
|
<div className="relative flex w-full items-center justify-center">
|
||||||
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
||||||
<span className="relative bg-card px-2 py-1">
|
<span className="relative bg-card px-2 py-1">
|
||||||
or, enter the code manually
|
{t('or, enter the code manually')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ function TwoFactorVerificationStep({
|
|||||||
}) {
|
}) {
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -210,7 +213,7 @@ function TwoFactorVerificationStep({
|
|||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
>
|
>
|
||||||
Back
|
{t('Back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -219,7 +222,7 @@ function TwoFactorVerificationStep({
|
|||||||
processing || code.length < OTP_MAX_LENGTH
|
processing || code.length < OTP_MAX_LENGTH
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Confirm
|
{t('Confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showVerificationStep, setShowVerificationStep] =
|
const [showVerificationStep, setShowVerificationStep] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const modalConfig = useMemo<{
|
const modalConfig = useMemo<{
|
||||||
title: string;
|
title: string;
|
||||||
@@ -262,29 +266,32 @@ export default function TwoFactorSetupModal({
|
|||||||
}>(() => {
|
}>(() => {
|
||||||
if (twoFactorEnabled) {
|
if (twoFactorEnabled) {
|
||||||
return {
|
return {
|
||||||
title: 'Two-factor authentication enabled',
|
title: t('Two-factor authentication enabled'),
|
||||||
description:
|
description: t(
|
||||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
'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) {
|
if (showVerificationStep) {
|
||||||
return {
|
return {
|
||||||
title: 'Verify authentication code',
|
title: t('Verify authentication code'),
|
||||||
description:
|
description: t(
|
||||||
'Enter the 6-digit code from your authenticator app',
|
'Enter the 6-digit code from your authenticator app',
|
||||||
buttonText: 'Continue',
|
),
|
||||||
|
buttonText: t('Continue'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Enable two-factor authentication',
|
title: t('Enable two-factor authentication'),
|
||||||
description:
|
description: t(
|
||||||
'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',
|
||||||
buttonText: 'Continue',
|
),
|
||||||
|
buttonText: t('Continue'),
|
||||||
};
|
};
|
||||||
}, [twoFactorEnabled, showVerificationStep]);
|
}, [showVerificationStep, t, twoFactorEnabled]);
|
||||||
|
|
||||||
const resetModalState = useCallback(() => {
|
const resetModalState = useCallback(() => {
|
||||||
setShowVerificationStep(false);
|
setShowVerificationStep(false);
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { Slot } from "@radix-ui/react-slot"
|
|||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useTranslation } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
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">) {
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
@@ -84,6 +87,8 @@ function BreadcrumbEllipsis({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) {
|
}: React.ComponentProps<"span">) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="breadcrumb-ellipsis"
|
data-slot="breadcrumb-ellipsis"
|
||||||
@@ -93,7 +98,7 @@ function BreadcrumbEllipsis({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
<span className="sr-only">More</span>
|
<span className="sr-only">{t("More")}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Slot } from "@radix-ui/react-slot"
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useTranslatedChildren } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
@@ -39,19 +40,23 @@ function Button({
|
|||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const translatedChildren = useTranslatedChildren(children)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{translatedChildren}
|
||||||
|
</Comp>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useTranslation } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -49,6 +50,8 @@ function DialogContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@@ -63,7 +66,7 @@ function DialogContent({
|
|||||||
{children}
|
{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">
|
<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 />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t("Close")}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useTranslatedChildren } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const translatedChildren = useTranslatedChildren(children)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot="label"
|
data-slot="label"
|
||||||
@@ -15,7 +19,9 @@ function Label({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{translatedChildren}
|
||||||
|
</LabelPrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useTranslation } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
@@ -50,6 +51,8 @@ function SheetContent({
|
|||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
@@ -72,7 +75,7 @@ function SheetContent({
|
|||||||
{children}
|
{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">
|
<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" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t("Close")}</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { useTranslation } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
@@ -160,6 +161,7 @@ function Sidebar({
|
|||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
@@ -180,8 +182,10 @@ function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>{t("Sidebar")}</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>
|
||||||
|
{t("Displays the mobile sidebar.")}
|
||||||
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
@@ -252,6 +256,7 @@ function SidebarTrigger({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar, isMobile, state } = useSidebar()
|
const { toggleSidebar, isMobile, state } = useSidebar()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -267,22 +272,23 @@ function SidebarTrigger({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isMobile || state === "collapsed" ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
|
{isMobile || state === "collapsed" ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
|
||||||
<span className="sr-only">Toggle sidebar</span>
|
<span className="sr-only">{t("Toggle sidebar")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar="rail"
|
||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
aria-label="Toggle sidebar"
|
aria-label={t("Toggle sidebar")}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle sidebar"
|
title={t("Toggle sidebar")}
|
||||||
className={cn(
|
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",
|
"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",
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Loader2Icon } from "lucide-react"
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { useTranslation } from "@/lib/translations"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Loader2Icon
|
<Loader2Icon
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading"
|
aria-label={t("Loading")}
|
||||||
className={cn("size-4 animate-spin", className)}
|
className={cn("size-4 animate-spin", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { UserInfo } from '@/components/user-info';
|
import { UserInfo } from '@/components/user-info';
|
||||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
@@ -18,6 +19,7 @@ type Props = {
|
|||||||
|
|
||||||
export function UserMenuContent({ user }: Props) {
|
export function UserMenuContent({ user }: Props) {
|
||||||
const cleanup = useMobileNavigation();
|
const cleanup = useMobileNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -41,7 +43,7 @@ export function UserMenuContent({ user }: Props) {
|
|||||||
onClick={cleanup}
|
onClick={cleanup}
|
||||||
>
|
>
|
||||||
<Settings className="mr-2" />
|
<Settings className="mr-2" />
|
||||||
Settings
|
{t('Settings')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
@@ -55,7 +57,7 @@ export function UserMenuContent({ user }: Props) {
|
|||||||
data-test="logout-button"
|
data-test="logout-button"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2" />
|
<LogOut className="mr-2" />
|
||||||
Log out
|
{t('Log out')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Hls from 'hls.js/light';
|
import Hls from 'hls.js/light';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
|
|
||||||
export function VideoPlayer({
|
export function VideoPlayer({
|
||||||
src,
|
src,
|
||||||
@@ -9,6 +10,7 @@ export function VideoPlayer({
|
|||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -39,7 +41,7 @@ export function VideoPlayer({
|
|||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
<div className="flex aspect-video w-full items-center justify-center bg-neutral-950 text-sm text-neutral-300">
|
<div className="flex aspect-video w-full items-center justify-center bg-neutral-950 text-sm text-neutral-300">
|
||||||
Offline
|
{t('Offline')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { home } from '@/routes';
|
import { home } from '@/routes';
|
||||||
|
|
||||||
export default function AuthCardLayout({
|
export default function AuthCardLayout({
|
||||||
@@ -19,6 +20,8 @@ export default function AuthCardLayout({
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}>) {
|
}>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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 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">
|
<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">
|
<div className="flex flex-col gap-6">
|
||||||
<Card className="rounded-md shadow-sm">
|
<Card className="rounded-md shadow-sm">
|
||||||
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
||||||
<CardTitle className="text-xl">{title}</CardTitle>
|
<CardTitle className="text-xl">
|
||||||
<CardDescription>{description}</CardDescription>
|
{t(title ?? '')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(description ?? '')}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-10 py-8">
|
<CardContent className="px-10 py-8">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { home } from '@/routes';
|
import { home } from '@/routes';
|
||||||
import type { AuthLayoutProps } from '@/types';
|
import type { AuthLayoutProps } from '@/types';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ export default function AuthSimpleLayout({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: AuthLayoutProps) {
|
}: AuthLayoutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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 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">
|
<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">
|
<div className="mb-1 flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||||
<AppLogoIcon className="size-6 fill-current" />
|
<AppLogoIcon className="size-6 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
<span className="sr-only">{title}</span>
|
<span className="sr-only">{t(title ?? '')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="space-y-2 text-center">
|
<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">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{description}
|
{t(description ?? '')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import AppLogoIcon from '@/components/app-logo-icon';
|
import AppLogoIcon from '@/components/app-logo-icon';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { home } from '@/routes';
|
import { home } from '@/routes';
|
||||||
import type { AuthLayoutProps } from '@/types';
|
import type { AuthLayoutProps } from '@/types';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export default function AuthSplitLayout({
|
|||||||
description,
|
description,
|
||||||
}: AuthLayoutProps) {
|
}: AuthLayoutProps) {
|
||||||
const { name } = usePage().props;
|
const { name } = usePage().props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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">
|
<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" />
|
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
<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">
|
<p className="text-sm text-balance text-muted-foreground">
|
||||||
{description}
|
{t(description ?? '')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Heading from '@/components/heading';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn, toUrl } from '@/lib/utils';
|
import { cn, toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/appearance';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
@@ -31,6 +32,7 @@ const sidebarNavItems: NavItem[] = [
|
|||||||
|
|
||||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-6">
|
<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">
|
<aside className="w-full max-w-xl lg:w-48">
|
||||||
<nav
|
<nav
|
||||||
className="flex flex-col space-y-1 space-x-0"
|
className="flex flex-col space-y-1 space-x-0"
|
||||||
aria-label="Settings"
|
aria-label={t('Settings')}
|
||||||
>
|
>
|
||||||
{sidebarNavItems.map((item, index) => (
|
{sidebarNavItems.map((item, index) => (
|
||||||
<Button
|
<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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||||
import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
|
import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
|
||||||
import { restore, suspend } from '@/routes/admin/channels';
|
import { restore, suspend } from '@/routes/admin/channels';
|
||||||
@@ -104,6 +105,7 @@ export default function AdminDashboard({
|
|||||||
users,
|
users,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [action, setAction] = useState<ModerationAction>(null);
|
const [action, setAction] = useState<ModerationAction>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
function setChannelCreationDefault(channelCreationOpen: boolean) {
|
function setChannelCreationDefault(channelCreationOpen: boolean) {
|
||||||
router.patch(
|
router.patch(
|
||||||
@@ -153,7 +155,7 @@ export default function AdminDashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Admin" />
|
<Head title={t('Admin')} />
|
||||||
<div className="grid gap-6 p-4 md:p-6">
|
<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 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">
|
<div className="flex items-center gap-3">
|
||||||
@@ -162,16 +164,19 @@ export default function AdminDashboard({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
Moderation console
|
{t('Moderation console')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Review live streams, suspended states, users,
|
{t(
|
||||||
and recordings.
|
'Review live streams, suspended states, users, and recordings.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -202,10 +207,12 @@ export default function AdminDashboard({
|
|||||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">
|
<h2 className="font-semibold">
|
||||||
Channel creation access
|
{t('Channel creation access')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Control who can create a creator channel.
|
{t(
|
||||||
|
'Control who can create a creator channel.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,10 +266,14 @@ export default function AdminDashboard({
|
|||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="text-live size-5" />
|
<Radio className="text-live size-5" />
|
||||||
<h2 className="font-semibold">Live broadcasts</h2>
|
<h2 className="font-semibold">
|
||||||
|
{t('Live broadcasts')}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{liveBroadcasts.length} rows
|
{t(':count rows', {
|
||||||
|
count: liveBroadcasts.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -277,12 +288,15 @@ export default function AdminDashboard({
|
|||||||
{broadcast.title}
|
{broadcast.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{broadcast.channel.display_name} by{' '}
|
{t(':channel by :owner', {
|
||||||
{broadcast.channel.owner}
|
channel:
|
||||||
|
broadcast.channel.display_name,
|
||||||
|
owner: broadcast.channel.owner,
|
||||||
|
})}
|
||||||
</div>
|
</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">
|
<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" />
|
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||||
LIVE
|
{t('LIVE')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<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">
|
<span className="text-sm text-muted-foreground">
|
||||||
{channels.length} rows
|
{t(':count rows', {
|
||||||
|
count: channels.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -336,18 +354,20 @@ export default function AdminDashboard({
|
|||||||
{channel.display_name}
|
{channel.display_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
@{channel.slug} owned by{' '}
|
{t('@:slug owned by :owner', {
|
||||||
{channel.owner.name}
|
slug: channel.slug,
|
||||||
|
owner: channel.owner.name,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{channel.is_live && (
|
{channel.is_live && (
|
||||||
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
|
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||||
LIVE
|
{t('LIVE')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{channel.suspended_at && (
|
{channel.suspended_at && (
|
||||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -400,9 +420,9 @@ export default function AdminDashboard({
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{dialogTitle(action)}</DialogTitle>
|
<DialogTitle>{t(dialogTitle(action))}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{dialogDescription(action)}
|
{t(dialogDescription(action))}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -449,10 +469,12 @@ function Stat({
|
|||||||
value: number;
|
value: number;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-card p-4 shadow-sm">
|
<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">
|
<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'} />
|
<Icon className={live ? 'text-live size-4' : 'size-4'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-2xl font-semibold">{value}</div>
|
<div className="mt-2 text-2xl font-semibold">{value}</div>
|
||||||
@@ -470,6 +492,7 @@ function CreatorAccessRow({
|
|||||||
onChange: (canCreateChannel: boolean) => void;
|
onChange: (canCreateChannel: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const effectiveAccess = defaultOpen || user.can_create_channel;
|
const effectiveAccess = defaultOpen || user.can_create_channel;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
<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>
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
ADMIN
|
{t('ADMIN')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{user.suspended_at && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<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'
|
: '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>
|
</span>
|
||||||
{defaultOpen && !user.can_create_channel && (
|
{defaultOpen && !user.can_create_channel && (
|
||||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
DEFAULT
|
{t('DEFAULT')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -563,9 +588,11 @@ function Avatar({ channel }: { channel: AdminChannel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ text }: { text: string }) {
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||||
{text}
|
{t(text)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { Inbox, MessageSquare, Shield, User } from 'lucide-react';
|
import { Inbox, MessageSquare, Shield, User } from 'lucide-react';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
index as adminSupportIndex,
|
index as adminSupportIndex,
|
||||||
@@ -16,9 +17,11 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminSupportIndex({ stats, conversations }: Props) {
|
export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Support inbox" />
|
<Head title={t('Support inbox')} />
|
||||||
<div className="grid gap-6 p-4 md:p-6">
|
<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 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">
|
<div className="flex items-center gap-3">
|
||||||
@@ -27,11 +30,12 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
Support inbox
|
{t('Support inbox')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Contact threads from users and channel request
|
{t(
|
||||||
notes.
|
'Contact threads from users and channel request notes.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="size-5 text-primary" />
|
<Shield className="size-5 text-primary" />
|
||||||
<h2 className="font-semibold">Conversations</h2>
|
<h2 className="font-semibold">
|
||||||
|
{t('Conversations')}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{conversations.length} rows
|
{t(':count rows', {
|
||||||
|
count: conversations.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -96,7 +104,7 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
|||||||
|
|
||||||
{conversations.length === 0 && (
|
{conversations.length === 0 && (
|
||||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,15 +124,19 @@ AdminSupportIndex.layout = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: number }) {
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-background px-3 py-2">
|
<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>
|
<span className="font-semibold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -134,7 +146,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
|||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function AdminSupportShow({ conversation }: Props) {
|
export default function AdminSupportShow({ conversation }: Props) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
body: '',
|
body: '',
|
||||||
attachments: [] as File[],
|
attachments: [] as File[],
|
||||||
@@ -84,7 +86,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
|||||||
>
|
>
|
||||||
<Link href={adminSupportIndex()}>
|
<Link href={adminSupportIndex()}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
Support inbox
|
{t('Support inbox')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<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" />
|
<Lock className="size-4" />
|
||||||
)}
|
)}
|
||||||
{isClosed ? 'Reopen' : 'Close'}
|
{isClosed ? t('Reopen') : t('Close')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,9 @@ export default function AdminSupportShow({ conversation }: Props) {
|
|||||||
{isClosed ? (
|
{isClosed ? (
|
||||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
<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" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="grid gap-4">
|
<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">
|
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<User className="size-5 text-primary" />
|
<User className="size-5 text-primary" />
|
||||||
<h2 className="font-semibold">User</h2>
|
<h2 className="font-semibold">{t('User')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm">
|
<div className="grid gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -218,12 +222,12 @@ export default function AdminSupportShow({ conversation }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{conversation.user?.can_create_channel
|
{conversation.user?.can_create_channel
|
||||||
? 'CAN CREATE CHANNEL'
|
? t('CAN CREATE CHANNEL')
|
||||||
: 'NEEDS CHANNEL GRANT'}
|
: t('NEEDS CHANNEL GRANT')}
|
||||||
</span>
|
</span>
|
||||||
{conversation.user?.suspended_at && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,12 +250,13 @@ export default function AdminSupportShow({ conversation }: Props) {
|
|||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<KeyRound className="size-5 text-primary" />
|
<KeyRound className="size-5 text-primary" />
|
||||||
<h2 className="font-semibold">
|
<h2 className="font-semibold">
|
||||||
Channel request
|
{t('Channel request')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
Creator access grants stay in the moderation
|
{t(
|
||||||
console.
|
'Creator access grants stay in the moderation console.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={adminDashboard()}>
|
<Link href={adminDashboard()}>
|
||||||
@@ -277,6 +282,7 @@ AdminSupportShow.layout = {
|
|||||||
|
|
||||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||||
const isAdmin = message.author.is_admin;
|
const isAdmin = message.author.is_admin;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -288,7 +294,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="font-medium">{message.author.name}</span>
|
<span className="font-medium">{message.author.name}</span>
|
||||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{isAdmin ? 'ADMIN' : 'USER'}
|
{isAdmin ? t('ADMIN') : t('USER')}
|
||||||
</span>
|
</span>
|
||||||
{message.created_at && (
|
{message.created_at && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -345,6 +351,8 @@ function Field({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -354,7 +362,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
|||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import PasswordInput from '@/components/password-input';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { store } from '@/routes/password/confirm';
|
import { store } from '@/routes/password/confirm';
|
||||||
|
|
||||||
export default function ConfirmPassword() {
|
export default function ConfirmPassword() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Confirm password" />
|
<Head title={t('Confirm password')} />
|
||||||
|
|
||||||
<Form {...store.form()} resetOnSuccess={['password']}>
|
<Form {...store.form()} resetOnSuccess={['password']}>
|
||||||
{({ processing, errors }) => (
|
{({ processing, errors }) => (
|
||||||
@@ -19,7 +22,7 @@ export default function ConfirmPassword() {
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Password"
|
placeholder={t('Password')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import TextLink from '@/components/text-link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { login } from '@/routes';
|
import { login } from '@/routes';
|
||||||
import { email } from '@/routes/password';
|
import { email } from '@/routes/password';
|
||||||
|
|
||||||
export default function ForgotPassword({ status }: { status?: string }) {
|
export default function ForgotPassword({ status }: { status?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Forgot password" />
|
<Head title={t('Forgot password')} />
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
<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"
|
name="email"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="email@example.com"
|
placeholder={t('email@example.com')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError message={errors.email} />
|
<InputError message={errors.email} />
|
||||||
@@ -55,8 +58,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||||
<span>Or, return to</span>
|
<span>{t('Or, return to')}</span>
|
||||||
<TextLink href={login()}>log in</TextLink>
|
<TextLink href={login()}>{t('log in')}</TextLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { register } from '@/routes';
|
import { register } from '@/routes';
|
||||||
import { store } from '@/routes/login';
|
import { store } from '@/routes/login';
|
||||||
import { request } from '@/routes/password';
|
import { request } from '@/routes/password';
|
||||||
@@ -22,9 +23,11 @@ export default function Login({
|
|||||||
canResetPassword,
|
canResetPassword,
|
||||||
canRegister,
|
canRegister,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Log in" />
|
<Head title={t('Log in')} />
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
{...store.form()}
|
{...store.form()}
|
||||||
@@ -44,7 +47,7 @@ export default function Login({
|
|||||||
autoFocus
|
autoFocus
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="email@example.com"
|
placeholder={t('email@example.com')}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.email} />
|
<InputError message={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +61,7 @@ export default function Login({
|
|||||||
className="ml-auto text-sm"
|
className="ml-auto text-sm"
|
||||||
tabIndex={5}
|
tabIndex={5}
|
||||||
>
|
>
|
||||||
Forgot password?
|
{t('Forgot password?')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +71,7 @@ export default function Login({
|
|||||||
required
|
required
|
||||||
tabIndex={2}
|
tabIndex={2}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Password"
|
placeholder={t('Password')}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.password} />
|
<InputError message={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,9 +99,9 @@ export default function Login({
|
|||||||
|
|
||||||
{canRegister && (
|
{canRegister && (
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<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}>
|
<TextLink href={register()} tabIndex={5}>
|
||||||
Sign up
|
{t('Sign up')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { login } from '@/routes';
|
import { login } from '@/routes';
|
||||||
import { store } from '@/routes/register';
|
import { store } from '@/routes/register';
|
||||||
|
|
||||||
@@ -14,9 +15,11 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Register({ passwordRules }: Props) {
|
export default function Register({ passwordRules }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Register" />
|
<Head title={t('Register')} />
|
||||||
<Form
|
<Form
|
||||||
{...store.form()}
|
{...store.form()}
|
||||||
resetOnSuccess={['password', 'password_confirmation']}
|
resetOnSuccess={['password', 'password_confirmation']}
|
||||||
@@ -36,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
|
|||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Full name"
|
placeholder={t('Full name')}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
message={errors.name}
|
message={errors.name}
|
||||||
@@ -53,7 +56,7 @@ export default function Register({ passwordRules }: Props) {
|
|||||||
tabIndex={2}
|
tabIndex={2}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="email@example.com"
|
placeholder={t('email@example.com')}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.email} />
|
<InputError message={errors.email} />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) {
|
|||||||
tabIndex={3}
|
tabIndex={3}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Password"
|
placeholder={t('Password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.password} />
|
<InputError message={errors.password} />
|
||||||
@@ -82,7 +85,7 @@ export default function Register({ passwordRules }: Props) {
|
|||||||
tabIndex={4}
|
tabIndex={4}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
placeholder="Confirm password"
|
placeholder={t('Confirm password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
@@ -102,9 +105,9 @@ export default function Register({ passwordRules }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
Already have an account?{' '}
|
{t('Already have an account?')}{' '}
|
||||||
<TextLink href={login()} tabIndex={6}>
|
<TextLink href={login()} tabIndex={6}>
|
||||||
Log in
|
{t('Log in')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { update } from '@/routes/password';
|
import { update } from '@/routes/password';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,9 +15,11 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ResetPassword({ token, email, passwordRules }: Props) {
|
export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Reset password" />
|
<Head title={t('Reset password')} />
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
{...update.form()}
|
{...update.form()}
|
||||||
@@ -50,7 +53,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Password"
|
placeholder={t('Password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.password} />
|
<InputError message={errors.password} />
|
||||||
@@ -65,7 +68,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
|||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
placeholder="Confirm password"
|
placeholder={t('Confirm password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from '@/components/ui/input-otp';
|
} from '@/components/ui/input-otp';
|
||||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { store } from '@/routes/two-factor/login';
|
import { store } from '@/routes/two-factor/login';
|
||||||
|
|
||||||
export default function TwoFactorChallenge() {
|
export default function TwoFactorChallenge() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
|
||||||
@@ -23,20 +25,22 @@ export default function TwoFactorChallenge() {
|
|||||||
}>(() => {
|
}>(() => {
|
||||||
if (showRecoveryInput) {
|
if (showRecoveryInput) {
|
||||||
return {
|
return {
|
||||||
title: 'Recovery code',
|
title: t('Recovery code'),
|
||||||
description:
|
description: t(
|
||||||
'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.',
|
||||||
toggleText: 'login using an authentication code',
|
),
|
||||||
|
toggleText: t('login using an authentication code'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Authentication code',
|
title: t('Authentication code'),
|
||||||
description:
|
description: t(
|
||||||
'Enter the authentication code provided by your authenticator application.',
|
'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({
|
setLayoutProps({
|
||||||
title: authConfigContent.title,
|
title: authConfigContent.title,
|
||||||
@@ -51,7 +55,7 @@ export default function TwoFactorChallenge() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Two-factor authentication" />
|
<Head title={t('Two-factor authentication')} />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Form
|
<Form
|
||||||
@@ -67,7 +71,7 @@ export default function TwoFactorChallenge() {
|
|||||||
<Input
|
<Input
|
||||||
name="recovery_code"
|
name="recovery_code"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter recovery code"
|
placeholder={t('Enter recovery code')}
|
||||||
autoFocus={showRecoveryInput}
|
autoFocus={showRecoveryInput}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
<span>or you can </span>
|
<span>{t('or you can')} </span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { logout } from '@/routes';
|
import { logout } from '@/routes';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
|
||||||
export default function VerifyEmail({ status }: { status?: string }) {
|
export default function VerifyEmail({ status }: { status?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Email verification" />
|
<Head title={t('Email verification')} />
|
||||||
|
|
||||||
{status === 'verification-link-sent' && (
|
{status === 'verification-link-sent' && (
|
||||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||||
A new verification link has been sent to the email address
|
{t(
|
||||||
you provided during registration.
|
'A new verification link has been sent to the email address you provided during registration.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
|
|||||||
href={logout()}
|
href={logout()}
|
||||||
className="mx-auto block text-sm"
|
className="mx-auto block text-sm"
|
||||||
>
|
>
|
||||||
Log out
|
{t('Log out')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { VideoPlayer } from '@/components/video-player';
|
import { VideoPlayer } from '@/components/video-player';
|
||||||
import { useInitials } from '@/hooks/use-initials';
|
import { useInitials } from '@/hooks/use-initials';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { home, login } from '@/routes';
|
import { home, login } from '@/routes';
|
||||||
import { follow, unfollow } from '@/routes/channels';
|
import { follow, unfollow } from '@/routes/channels';
|
||||||
@@ -84,6 +85,7 @@ export default function ChannelShow({
|
|||||||
isFollowing,
|
isFollowing,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
const chatForm = useForm({ body: '' });
|
const chatForm = useForm({ body: '' });
|
||||||
const currentViewers = viewerStats.current;
|
const currentViewers = viewerStats.current;
|
||||||
@@ -160,10 +162,14 @@ export default function ChannelShow({
|
|||||||
<span>{channel.display_name}</span>
|
<span>{channel.display_name}</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
{currentViewers} viewers
|
{t(':count viewers', {
|
||||||
|
count: currentViewers,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{channel.followers_count} followers
|
{t(':count followers', {
|
||||||
|
count: channel.followers_count,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{channel.description && (
|
{channel.description && (
|
||||||
@@ -185,7 +191,7 @@ export default function ChannelShow({
|
|||||||
className="xl:hidden"
|
className="xl:hidden"
|
||||||
>
|
>
|
||||||
<MessageSquare className="size-4" />
|
<MessageSquare className="size-4" />
|
||||||
Chat
|
{t('Chat')}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
@@ -193,7 +199,9 @@ export default function ChannelShow({
|
|||||||
className="h-[82svh] rounded-t-md"
|
className="h-[82svh] rounded-t-md"
|
||||||
>
|
>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Live chat</SheetTitle>
|
<SheetTitle>
|
||||||
|
{t('Live chat')}
|
||||||
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
channel={channel}
|
channel={channel}
|
||||||
@@ -223,13 +231,15 @@ export default function ChannelShow({
|
|||||||
'fill-current text-primary',
|
'fill-current text-primary',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{isFollowing ? 'Following' : 'Follow'}
|
{isFollowing
|
||||||
|
? t('Following')
|
||||||
|
: t('Follow')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={login()}>
|
<Link href={login()}>
|
||||||
<LogIn className="size-4" />
|
<LogIn className="size-4" />
|
||||||
Log in to follow
|
{t('Log in to follow')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -241,11 +251,13 @@ export default function ChannelShow({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Video className="size-5 text-primary" />
|
<Video className="size-5 text-primary" />
|
||||||
<h2 className="font-semibold">
|
<h2 className="font-semibold">
|
||||||
Recent VODs
|
{t('Recent VODs')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{vods.length} available
|
{t(':count available', {
|
||||||
|
count: vods.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{vods.length > 0 ? (
|
{vods.length > 0 ? (
|
||||||
@@ -256,7 +268,7 @@ export default function ChannelShow({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed bg-card p-6 text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -288,6 +300,8 @@ ChannelShow.layout = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
|
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
|
||||||
{channel.banner_url ? (
|
{channel.banner_url ? (
|
||||||
@@ -304,11 +318,14 @@ function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-semibold">
|
<div className="text-xl font-semibold">
|
||||||
{channel.display_name} is offline
|
{t(':channel is offline', {
|
||||||
|
channel: channel.display_name,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-white/70">
|
<p className="mt-1 text-sm text-white/70">
|
||||||
Recent recordings remain available below when the
|
{t(
|
||||||
creator publishes VODs.
|
'Recent recordings remain available below when the creator publishes VODs.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,6 +351,7 @@ function ChatPanel({
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const getInitials = useInitials();
|
const getInitials = useInitials();
|
||||||
|
const { t } = useTranslation();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||||
@@ -383,9 +401,11 @@ function ChatPanel({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MessageSquare className="size-5 text-primary" />
|
<MessageSquare className="size-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Live chat</h2>
|
<h2 className="font-semibold">{t('Live chat')}</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatMessageCount(messages.length)}
|
{t(':count messages', {
|
||||||
|
count: messages.length,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,13 +458,13 @@ function ChatPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isOwnMessage
|
{isOwnMessage
|
||||||
? 'You'
|
? t('You')
|
||||||
: message.user.name}
|
: message.user.name}
|
||||||
</span>
|
</span>
|
||||||
{isChannelOwner && (
|
{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">
|
<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" />
|
<Crown className="size-3" />
|
||||||
Owner
|
{t('Owner')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{message.created_at && (
|
{message.created_at && (
|
||||||
@@ -484,8 +504,10 @@ function ChatPanel({
|
|||||||
<MessageSquare className="size-5 text-primary" />
|
<MessageSquare className="size-5 text-primary" />
|
||||||
<span>
|
<span>
|
||||||
{channel.is_live
|
{channel.is_live
|
||||||
? 'No messages yet.'
|
? t('No messages yet.')
|
||||||
: 'Chat appears when the channel is live.'}
|
: t(
|
||||||
|
'Chat appears when the channel is live.',
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -504,8 +526,8 @@ function ChatPanel({
|
|||||||
}
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
channel.is_live
|
channel.is_live
|
||||||
? 'Send a message'
|
? t('Send a message')
|
||||||
: 'Channel is offline'
|
: t('Channel is offline')
|
||||||
}
|
}
|
||||||
disabled={!channel.is_live || form.processing}
|
disabled={!channel.is_live || form.processing}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
@@ -518,7 +540,7 @@ function ChatPanel({
|
|||||||
disabled={
|
disabled={
|
||||||
!channel.is_live || form.processing
|
!channel.is_live || form.processing
|
||||||
}
|
}
|
||||||
aria-label="Open emoji picker"
|
aria-label={t('Open emoji picker')}
|
||||||
aria-expanded={emojiPickerOpen}
|
aria-expanded={emojiPickerOpen}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setEmojiPickerOpen((open) => !open)
|
setEmojiPickerOpen((open) => !open)
|
||||||
@@ -532,7 +554,9 @@ function ChatPanel({
|
|||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
type="button"
|
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"
|
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={() =>
|
onClick={() =>
|
||||||
insertEmoji(emoji)
|
insertEmoji(emoji)
|
||||||
@@ -548,7 +572,7 @@ function ChatPanel({
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
aria-label="Send message"
|
aria-label={t('Send message')}
|
||||||
>
|
>
|
||||||
<Send className="size-4" />
|
<Send className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -562,7 +586,7 @@ function ChatPanel({
|
|||||||
>
|
>
|
||||||
{form.errors.body ??
|
{form.errors.body ??
|
||||||
(!channel.is_live
|
(!channel.is_live
|
||||||
? 'Channel is offline'
|
? t('Channel is offline')
|
||||||
: '')}
|
: '')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -577,7 +601,7 @@ function ChatPanel({
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<Button asChild variant="outline" className="w-full">
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -585,10 +609,6 @@ function ChatPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageCount(count: number): string {
|
|
||||||
return count === 1 ? '1 message' : `${count} messages`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatChatTime(value: string): string {
|
function formatChatTime(value: string): string {
|
||||||
return new Date(value).toLocaleTimeString([], {
|
return new Date(value).toLocaleTimeString([], {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
@@ -601,6 +621,8 @@ function formatChatDateTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VodCard({ vod }: { vod: VodSummary }) {
|
function VodCard({ vod }: { vod: VodSummary }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-md border bg-card">
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
|
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
|
||||||
@@ -610,19 +632,20 @@ function VodCard({ vod }: { vod: VodSummary }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="line-clamp-2 font-medium">{vod.title}</div>
|
<div className="line-clamp-2 font-medium">{vod.title}</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
Expires{' '}
|
{t('Expires :date', {
|
||||||
{vod.expires_at
|
date: vod.expires_at
|
||||||
? new Date(vod.expires_at).toLocaleDateString()
|
? new Date(vod.expires_at).toLocaleDateString()
|
||||||
: 'later'}
|
: t('later'),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{vod.playback_url ? (
|
{vod.playback_url ? (
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<a href={vod.playback_url}>Watch VOD</a>
|
<a href={vod.playback_url}>{t('Watch VOD')}</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Processing recording
|
{t('Processing recording')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -637,6 +660,8 @@ function LiveState({
|
|||||||
channel: ChannelDetail;
|
channel: ChannelDetail;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (channel.is_live) {
|
if (channel.is_live) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -646,14 +671,14 @@ function LiveState({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||||
LIVE
|
{t('LIVE')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||||
Offline
|
{t('Offline')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useClipboard } from '@/hooks/use-clipboard';
|
import { useClipboard } from '@/hooks/use-clipboard';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard as dashboardRoute } from '@/routes';
|
import { dashboard as dashboardRoute } from '@/routes';
|
||||||
import { show as showChannel } from '@/routes/channels';
|
import { show as showChannel } from '@/routes/channels';
|
||||||
@@ -75,6 +76,7 @@ export default function Dashboard({
|
|||||||
recentBroadcasts,
|
recentBroadcasts,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [copiedText, copy] = useClipboard();
|
const [copiedText, copy] = useClipboard();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
|
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
|
||||||
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
|
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -155,7 +157,7 @@ export default function Dashboard({
|
|||||||
|
|
||||||
return (
|
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-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 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">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
@@ -168,16 +170,17 @@ export default function Dashboard({
|
|||||||
/>
|
/>
|
||||||
{pendingBroadcast && !liveBroadcast && (
|
{pendingBroadcast && !liveBroadcast && (
|
||||||
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="truncate text-2xl font-semibold">
|
<h1 className="truncate text-2xl font-semibold">
|
||||||
{channel?.display_name ?? 'Creator Studio'}
|
{channel?.display_name ?? t('Creator Studio')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Prepare broadcasts, secure your stream key, and
|
{t(
|
||||||
monitor the current session.
|
'Prepare broadcasts, secure your stream key, and monitor the current session.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +217,7 @@ export default function Dashboard({
|
|||||||
event.target.value,
|
event.target.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="Nyone Live"
|
placeholder={t('Nyone Live')}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
@@ -229,7 +232,7 @@ export default function Dashboard({
|
|||||||
event.target.value.toLowerCase(),
|
event.target.value.toLowerCase(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="nyone-live"
|
placeholder={t('nyone-live')}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Category">
|
<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">
|
<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" />
|
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
This account can browse and follow
|
{t(
|
||||||
channels, but cannot create a channel
|
'This account can browse and follow channels, but cannot create a channel yet.',
|
||||||
yet.
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -306,10 +309,10 @@ export default function Dashboard({
|
|||||||
label="Broadcast state"
|
label="Broadcast state"
|
||||||
value={
|
value={
|
||||||
channel.is_live
|
channel.is_live
|
||||||
? 'Live'
|
? t('Live')
|
||||||
: pendingBroadcast
|
: pendingBroadcast
|
||||||
? 'Ready'
|
? t('Ready')
|
||||||
: 'Offline'
|
: t('Offline')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -323,7 +326,7 @@ export default function Dashboard({
|
|||||||
? new Date(
|
? new Date(
|
||||||
channel.stream_key_last_used_at,
|
channel.stream_key_last_used_at,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: 'Never'
|
: t('Never')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -348,7 +351,9 @@ export default function Dashboard({
|
|||||||
event.target.value,
|
event.target.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="Building Nyone live"
|
placeholder={t(
|
||||||
|
'Building Nyone live',
|
||||||
|
)}
|
||||||
disabled={
|
disabled={
|
||||||
Boolean(liveBroadcast) ||
|
Boolean(liveBroadcast) ||
|
||||||
Boolean(pendingBroadcast)
|
Boolean(pendingBroadcast)
|
||||||
@@ -373,7 +378,7 @@ export default function Dashboard({
|
|||||||
Boolean(pendingBroadcast)
|
Boolean(pendingBroadcast)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
Record VOD
|
{t('Record VOD')}
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -390,8 +395,9 @@ export default function Dashboard({
|
|||||||
</form>
|
</form>
|
||||||
{(liveBroadcast ?? pendingBroadcast) && (
|
{(liveBroadcast ?? pendingBroadcast) && (
|
||||||
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
|
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
|
||||||
Finish the current broadcast before
|
{t(
|
||||||
preparing another session.
|
'Finish the current broadcast before preparing another session.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -466,7 +472,7 @@ export default function Dashboard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid h-full place-items-center text-xs text-muted-foreground">
|
<div className="grid h-full place-items-center text-xs text-muted-foreground">
|
||||||
No thumbnail
|
{t('No thumbnail')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,8 +492,9 @@ export default function Dashboard({
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ImagePlus className="size-3.5" />
|
<ImagePlus className="size-3.5" />
|
||||||
<span>
|
<span>
|
||||||
Use a 16:9 JPG, PNG, or
|
{t(
|
||||||
WebP up to 4 MB.
|
'Use a 16:9 JPG, PNG, or WebP up to 4 MB.',
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{channelForm.data
|
{channelForm.data
|
||||||
.thumbnail && (
|
.thumbnail && (
|
||||||
@@ -584,7 +591,9 @@ export default function Dashboard({
|
|||||||
label="Stream key"
|
label="Stream key"
|
||||||
value={
|
value={
|
||||||
streaming.tokenizedPath ??
|
streaming.tokenizedPath ??
|
||||||
'Rotate the key to reveal it once.'
|
t(
|
||||||
|
'Rotate the key to reveal it once.',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
copied={
|
copied={
|
||||||
copiedText ===
|
copiedText ===
|
||||||
@@ -595,8 +604,9 @@ export default function Dashboard({
|
|||||||
/>
|
/>
|
||||||
{plainStreamKey && (
|
{plainStreamKey && (
|
||||||
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
|
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
|
||||||
This key is shown once. Store it in
|
{t(
|
||||||
OBS before leaving this page.
|
'This key is shown once. Store it in OBS before leaving this page.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -624,8 +634,8 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{liveBroadcast.recording_enabled
|
{liveBroadcast.recording_enabled
|
||||||
? 'Recording enabled'
|
? t('Recording enabled')
|
||||||
: 'Live only'}
|
: t('Live only')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -647,8 +657,9 @@ export default function Dashboard({
|
|||||||
{pendingBroadcast.title}
|
{pendingBroadcast.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Start OBS to push the session
|
{t(
|
||||||
live.
|
'Start OBS to push the session live.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -667,10 +678,11 @@ export default function Dashboard({
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Stop broadcast?</DialogTitle>
|
<DialogTitle>{t('Stop broadcast?')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This ends the live session and clears the channel's
|
{t(
|
||||||
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.",
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -709,13 +721,15 @@ function Panel({
|
|||||||
description?: string;
|
description?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-w-0 rounded-md border bg-card p-5 shadow-sm">
|
<section className="min-w-0 rounded-md border bg-card p-5 shadow-sm">
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h2 className="font-semibold">{title}</h2>
|
<h2 className="font-semibold">{t(title)}</h2>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{description}
|
{t(description)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -753,13 +767,15 @@ function CategorySelect({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.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"
|
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) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
@@ -782,10 +798,12 @@ function CopyRow({
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
onCopy: (text: string) => Promise<boolean>;
|
onCopy: (text: string) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-w-0 gap-2">
|
<div className="grid min-w-0 gap-2">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase">
|
<div className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
{label}
|
{t(label)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 gap-2">
|
<div className="flex min-w-0 gap-2">
|
||||||
<code
|
<code
|
||||||
@@ -815,9 +833,11 @@ function CopyRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-card p-4 shadow-sm">
|
<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 className="mt-1 text-2xl font-semibold">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -831,15 +851,24 @@ function BroadcastRow({
|
|||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}) {
|
}) {
|
||||||
const active = broadcast.status !== 'ended';
|
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 (
|
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="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="min-w-0">
|
||||||
<div className="truncate font-medium">{broadcast.title}</div>
|
<div className="truncate font-medium">{broadcast.title}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{broadcast.status ?? 'unknown'} -{' '}
|
{broadcast.has_vod
|
||||||
{broadcast.recording_enabled ? 'recording' : 'live only'}
|
? t(':status - :mode - VOD ready', {
|
||||||
{broadcast.has_vod ? ' - VOD ready' : ''}
|
status,
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
: t(':status - :mode', {
|
||||||
|
status,
|
||||||
|
mode,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{active && (
|
{active && (
|
||||||
@@ -861,11 +890,13 @@ function StatusPill({
|
|||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
viewers?: number;
|
viewers?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (live) {
|
if (live) {
|
||||||
return (
|
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="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" />
|
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||||
LIVE - {viewers} viewers
|
{t('LIVE - :count viewers', { count: viewers })}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -873,14 +904,14 @@ function StatusPill({
|
|||||||
if (ready) {
|
if (ready) {
|
||||||
return (
|
return (
|
||||||
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||||
OFFLINE
|
{t('OFFLINE')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -908,6 +939,8 @@ function ReadinessRow({
|
|||||||
done?: boolean;
|
done?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
|
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
|
||||||
<span
|
<span
|
||||||
@@ -918,15 +951,17 @@ function ReadinessRow({
|
|||||||
>
|
>
|
||||||
{done && <Check className="size-3" />}
|
{done && <Check className="size-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">{label}</span>
|
<span className="text-sm">{t(label)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ text }: { text: string }) {
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
|
||||||
{text}
|
{t(text)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import AppearanceTabs from '@/components/appearance-tabs';
|
import AppearanceTabs from '@/components/appearance-tabs';
|
||||||
import Heading from '@/components/heading';
|
import Heading from '@/components/heading';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { edit as editAppearance } from '@/routes/appearance';
|
import { edit as editAppearance } from '@/routes/appearance';
|
||||||
|
|
||||||
export default function Appearance() {
|
export default function Appearance() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-6">
|
||||||
<Heading
|
<Heading
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useInitials } from '@/hooks/use-initials';
|
import { useInitials } from '@/hooks/use-initials';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { edit } from '@/routes/profile';
|
import { edit } from '@/routes/profile';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function Profile({
|
|||||||
status?: string;
|
status?: string;
|
||||||
}) {
|
}) {
|
||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const user = auth.user;
|
const user = auth.user;
|
||||||
const getInitials = useInitials();
|
const getInitials = useInitials();
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -52,9 +54,9 @@ export default function Profile({
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-6">
|
||||||
<Heading
|
<Heading
|
||||||
@@ -114,7 +116,7 @@ export default function Profile({
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ImagePlus className="size-3.5" />
|
<ImagePlus className="size-3.5" />
|
||||||
<span>
|
<span>
|
||||||
JPG, PNG, or WebP up to 2 MB.
|
{t('JPG, PNG, or WebP up to 2 MB.')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{progress && (
|
{progress && (
|
||||||
@@ -138,7 +140,7 @@ export default function Profile({
|
|||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
placeholder="Full name"
|
placeholder={t('Full name')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError
|
<InputError
|
||||||
@@ -158,7 +160,7 @@ export default function Profile({
|
|||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder="Email address"
|
placeholder={t('Email address')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError
|
<InputError
|
||||||
@@ -171,22 +173,26 @@ export default function Profile({
|
|||||||
user.email_verified_at === null && (
|
user.email_verified_at === null && (
|
||||||
<div>
|
<div>
|
||||||
<p className="-mt-4 text-sm text-muted-foreground">
|
<p className="-mt-4 text-sm text-muted-foreground">
|
||||||
Your email address is unverified.{' '}
|
{t(
|
||||||
|
'Your email address is unverified.',
|
||||||
|
)}{' '}
|
||||||
<Link
|
<Link
|
||||||
href={send()}
|
href={send()}
|
||||||
as="button"
|
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"
|
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
|
{t(
|
||||||
verification email.
|
'Click here to resend the verification email.',
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{status ===
|
{status ===
|
||||||
'verification-link-sent' && (
|
'verification-link-sent' && (
|
||||||
<div className="mt-2 text-sm font-medium text-green-600">
|
<div className="mt-2 text-sm font-medium text-green-600">
|
||||||
A new verification link has been
|
{t(
|
||||||
sent to your email address.
|
'A new verification link has been sent to your email address.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
|
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { edit } from '@/routes/security';
|
import { edit } from '@/routes/security';
|
||||||
import { disable, enable } from '@/routes/two-factor';
|
import { disable, enable } from '@/routes/two-factor';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export default function Security({
|
|||||||
twoFactorEnabled = false,
|
twoFactorEnabled = false,
|
||||||
passwordRules,
|
passwordRules,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const passwordInput = useRef<HTMLInputElement>(null);
|
const passwordInput = useRef<HTMLInputElement>(null);
|
||||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -53,9 +55,9 @@ export default function Security({
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-6">
|
||||||
<Heading
|
<Heading
|
||||||
@@ -99,7 +101,7 @@ export default function Security({
|
|||||||
name="current_password"
|
name="current_password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Current password"
|
placeholder={t('Current password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError message={errors.current_password} />
|
<InputError message={errors.current_password} />
|
||||||
@@ -114,7 +116,7 @@ export default function Security({
|
|||||||
name="password"
|
name="password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="New password"
|
placeholder={t('New password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ export default function Security({
|
|||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="Confirm password"
|
placeholder={t('Confirm password')}
|
||||||
passwordrules={passwordRules}
|
passwordrules={passwordRules}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -163,9 +165,9 @@ export default function Security({
|
|||||||
{twoFactorEnabled ? (
|
{twoFactorEnabled ? (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4">
|
<div className="flex flex-col items-start justify-start space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You will be prompted for a secure, random pin
|
{t(
|
||||||
during login, which you can retrieve from the
|
'You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.',
|
||||||
TOTP-supported application on your phone.
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="relative inline">
|
<div className="relative inline">
|
||||||
@@ -191,10 +193,9 @@ export default function Security({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4">
|
<div className="flex flex-col items-start justify-start space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
When you enable two-factor authentication, you
|
{t(
|
||||||
will be prompted for a secure pin during login.
|
'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.',
|
||||||
This pin can be retrieved from a TOTP-supported
|
)}
|
||||||
application on your phone.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
index as supportIndex,
|
index as supportIndex,
|
||||||
@@ -24,6 +25,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function SupportIndex({ categories, conversations }: Props) {
|
export default function SupportIndex({ categories, conversations }: Props) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
category: categories[0]?.value ?? 'bug',
|
category: categories[0]?.value ?? 'bug',
|
||||||
subject: '',
|
subject: '',
|
||||||
@@ -53,7 +55,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
|||||||
|
|
||||||
return (
|
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]">
|
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
<section className="grid content-start gap-4">
|
<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">
|
<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>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">
|
<h1 className="text-2xl font-semibold">
|
||||||
Contact admin
|
{t('Contact admin')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Bug reports, suggestions, channel requests,
|
{t(
|
||||||
and account questions.
|
'Bug reports, suggestions, channel requests, and account questions.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||||
{conversations.length} threads
|
{t(':count threads', {
|
||||||
|
count: conversations.length,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
|||||||
|
|
||||||
{conversations.length === 0 && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,9 +127,11 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
|||||||
|
|
||||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||||
<div className="mb-5">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="grid gap-4">
|
<form onSubmit={submit} className="grid gap-4">
|
||||||
@@ -237,6 +244,8 @@ function Field({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -246,7 +255,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
|||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { index as supportIndex } from '@/routes/support';
|
import { index as supportIndex } from '@/routes/support';
|
||||||
import { show as showAttachment } from '@/routes/support/attachments';
|
import { show as showAttachment } from '@/routes/support/attachments';
|
||||||
@@ -22,6 +23,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function SupportShow({ conversation }: Props) {
|
export default function SupportShow({ conversation }: Props) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
body: '',
|
body: '',
|
||||||
attachments: [] as File[],
|
attachments: [] as File[],
|
||||||
@@ -56,7 +58,7 @@ export default function SupportShow({ conversation }: Props) {
|
|||||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||||
<Link href={supportIndex()}>
|
<Link href={supportIndex()}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
Contact admin
|
{t('Contact admin')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +91,7 @@ export default function SupportShow({ conversation }: Props) {
|
|||||||
{isClosed ? (
|
{isClosed ? (
|
||||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
<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" />
|
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||||
<span>This conversation is closed.</span>
|
<span>{t('This conversation is closed.')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} className="grid gap-4">
|
<form onSubmit={submit} className="grid gap-4">
|
||||||
@@ -161,6 +165,7 @@ SupportShow.layout = {
|
|||||||
|
|
||||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||||
const isAdmin = message.author.is_admin;
|
const isAdmin = message.author.is_admin;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -172,7 +177,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="font-medium">{message.author.name}</span>
|
<span className="font-medium">{message.author.name}</span>
|
||||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{isAdmin ? 'ADMIN' : 'YOU'}
|
{isAdmin ? t('ADMIN') : t('YOU')}
|
||||||
</span>
|
</span>
|
||||||
{message.created_at && (
|
{message.created_at && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -229,6 +234,8 @@ function Field({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -238,7 +245,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
|||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useTranslation } from '@/lib/translations';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard, home, login, register } from '@/routes';
|
import { dashboard, home, login, register } from '@/routes';
|
||||||
import { show as showChannel } from '@/routes/channels';
|
import { show as showChannel } from '@/routes/channels';
|
||||||
@@ -31,6 +32,7 @@ export default function Welcome({
|
|||||||
categories,
|
categories,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { auth } = usePage().props;
|
const { auth } = usePage().props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [category, setCategory] = useState<string>('all');
|
const [category, setCategory] = useState<string>('all');
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ export default function Welcome({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Live" />
|
<Head title={t('Live')} />
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur">
|
<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">
|
<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()}
|
href={home()}
|
||||||
className="rounded-md px-3 py-2 text-foreground"
|
className="rounded-md px-3 py-2 text-foreground"
|
||||||
>
|
>
|
||||||
Live
|
{t('Live')}
|
||||||
</Link>
|
</Link>
|
||||||
{auth.user && (
|
{auth.user && (
|
||||||
<>
|
<>
|
||||||
@@ -82,13 +84,13 @@ export default function Welcome({
|
|||||||
href={dashboard()}
|
href={dashboard()}
|
||||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
Studio
|
{t('Studio')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={supportIndex()}
|
href={supportIndex()}
|
||||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
Contact
|
{t('Contact')}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -100,26 +102,28 @@ export default function Welcome({
|
|||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href={dashboard()}>
|
<Link href={dashboard()}>
|
||||||
<LayoutDashboard className="size-4" />
|
<LayoutDashboard className="size-4" />
|
||||||
Studio
|
{t('Studio')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href={supportIndex()}>
|
<Link href={supportIndex()}>
|
||||||
<LifeBuoy className="size-4" />
|
<LifeBuoy className="size-4" />
|
||||||
Contact
|
{t('Contact')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href={login()}>Log in</Link>
|
<Link href={login()}>
|
||||||
|
{t('Log in')}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{canRegister && (
|
{canRegister && (
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href={register()}>
|
<Link href={register()}>
|
||||||
<UserPlus className="size-4" />
|
<UserPlus className="size-4" />
|
||||||
Register
|
{t('Register')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +144,7 @@ export default function Welcome({
|
|||||||
<Thumbnail
|
<Thumbnail
|
||||||
channel={featuredChannel}
|
channel={featuredChannel}
|
||||||
className="aspect-video lg:aspect-auto lg:w-[58%] lg:flex-none"
|
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="grid min-w-0 flex-1 content-between gap-6 p-5">
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
@@ -152,7 +156,14 @@ export default function Welcome({
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
|
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||||
{featuredChannel.description ??
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,17 +173,17 @@ export default function Welcome({
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 break-words">
|
<span className="min-w-0 break-words">
|
||||||
{featuredChannel.category
|
{featuredChannel.category
|
||||||
?.name ?? 'Uncategorized'}
|
?.name ??
|
||||||
|
t('Uncategorized')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Users className="size-4" />
|
<Users className="size-4" />
|
||||||
{
|
{t(':count viewers', {
|
||||||
featuredChannel.viewer_count
|
count: featuredChannel.viewer_count,
|
||||||
}{' '}
|
})}
|
||||||
viewers
|
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
|
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
|
||||||
Watch
|
{t('Watch')}
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,11 +197,12 @@ export default function Welcome({
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-w-xl space-y-2">
|
<div className="max-w-xl space-y-2">
|
||||||
<h1 className="text-2xl font-semibold md:text-4xl">
|
<h1 className="text-2xl font-semibold md:text-4xl">
|
||||||
The live floor is quiet
|
{t('The live floor is quiet')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-6 text-muted-foreground md:text-base">
|
<p className="text-sm leading-6 text-muted-foreground md:text-base">
|
||||||
Creators can prepare a broadcast in the
|
{t(
|
||||||
studio and go live from OBS when ready.
|
'Creators can prepare a broadcast in the studio and go live from OBS when ready.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,10 +212,10 @@ export default function Welcome({
|
|||||||
<div className="flex items-center justify-between gap-3 border-b pb-3">
|
<div className="flex items-center justify-between gap-3 border-b pb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
Live signal
|
{t('Live signal')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Public channels broadcasting now
|
{t('Public channels broadcasting now')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-live rounded-md px-2 py-1 text-xs font-medium text-white">
|
<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>
|
</div>
|
||||||
<Metric
|
<Metric
|
||||||
icon={Radio}
|
icon={Radio}
|
||||||
label="Live channels"
|
label={t('Live channels')}
|
||||||
value={String(liveChannels.length)}
|
value={String(liveChannels.length)}
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={Users}
|
icon={Users}
|
||||||
label="Viewers"
|
label={t('Viewers')}
|
||||||
value={String(
|
value={String(
|
||||||
liveChannels.reduce(
|
liveChannels.reduce(
|
||||||
(total, item) =>
|
(total, item) =>
|
||||||
@@ -228,7 +240,7 @@ export default function Welcome({
|
|||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={Compass}
|
icon={Compass}
|
||||||
label="Categories"
|
label={t('Categories')}
|
||||||
value={String(categories.length)}
|
value={String(categories.length)}
|
||||||
/>
|
/>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
@@ -236,8 +248,8 @@ export default function Welcome({
|
|||||||
href={auth.user ? dashboard() : register()}
|
href={auth.user ? dashboard() : register()}
|
||||||
>
|
>
|
||||||
{auth.user
|
{auth.user
|
||||||
? 'Open studio'
|
? t('Open studio')
|
||||||
: 'Start a channel'}
|
: t('Start a channel')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</aside>
|
</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 className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
Live directory
|
{t('Live directory')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Browse active channels by category or title.
|
{t(
|
||||||
|
'Browse active channels by category or title.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
@@ -261,7 +275,7 @@ export default function Welcome({
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setQuery(event.target.value)
|
setQuery(event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="Search live channels"
|
placeholder={t('Search live channels')}
|
||||||
className="pl-9 sm:w-72"
|
className="pl-9 sm:w-72"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
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) => (
|
{categories.map((item) => (
|
||||||
<option key={item.id} value={item.slug}>
|
<option key={item.id} value={item.slug}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -296,10 +312,12 @@ export default function Welcome({
|
|||||||
<Search className="size-8 text-muted-foreground" />
|
<Search className="size-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
No streams match this view
|
{t('No streams match this view')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Clear search or switch categories.
|
{t(
|
||||||
|
'Clear search or switch categories.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,6 +330,8 @@ export default function Welcome({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StreamCard({ channel }: { channel: ChannelCard }) {
|
function StreamCard({ channel }: { channel: ChannelCard }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={showChannel(channel.slug)}
|
href={showChannel(channel.slug)}
|
||||||
@@ -325,16 +345,21 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
|
|||||||
{channel.broadcast?.title ?? channel.display_name}
|
{channel.broadcast?.title ?? channel.display_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-sm text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{channel.category?.name ?? 'Uncategorized'}
|
{channel.category?.name ?? t('Uncategorized')}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{channel.viewer_count} viewers
|
{t(':count viewers', {
|
||||||
|
count: channel.viewer_count,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,6 +376,8 @@ function Thumbnail({
|
|||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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="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">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<span className="bg-live live-pulse size-2 rounded-full" />
|
<span className="bg-live live-pulse size-2 rounded-full" />
|
||||||
{label}
|
{t(label)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -380,6 +407,8 @@ function Thumbnail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LiveBadge({ compact = false }: { compact?: boolean }) {
|
function LiveBadge({ compact = false }: { compact?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -388,7 +417,7 @@ function LiveBadge({ compact = false }: { compact?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||||
LIVE
|
{t('LIVE')}
|
||||||
</span>
|
</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 { Auth } from '@/types/auth';
|
||||||
|
import type { Translations } from '@/types/localization';
|
||||||
|
|
||||||
declare module 'react' {
|
declare module 'react' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -12,7 +13,10 @@ declare module '@inertiajs/core' {
|
|||||||
sharedPageProps: {
|
sharedPageProps: {
|
||||||
name: string;
|
name: string;
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
|
fallbackLocale: string;
|
||||||
|
locale: string;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
translations: Translations;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type * from './auth';
|
export type * from './auth';
|
||||||
|
export type * from './localization';
|
||||||
export type * from './navigation';
|
export type * from './navigation';
|
||||||
export type * from './streaming';
|
export type * from './streaming';
|
||||||
export type * from './support';
|
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