Merge pull request #1 from MeghdadFadaee/feature/localization
Feature/localization
This commit is contained in:
@@ -29,8 +29,8 @@ class AdminChannelCreationController extends Controller
|
||||
return back()->with(
|
||||
'success',
|
||||
$settings->channel_creation_open
|
||||
? 'All verified users can create channels.'
|
||||
: 'Channel creation now requires admin approval.',
|
||||
? __('All verified users can create channels.')
|
||||
: __('Channel creation now requires admin approval.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ class AdminChannelCreationController extends Controller
|
||||
return back()->with(
|
||||
'success',
|
||||
$user->can_create_channel
|
||||
? 'User can create a channel.'
|
||||
: 'User channel creation access removed.',
|
||||
? __('User can create a channel.')
|
||||
: __('User channel creation access removed.'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class AdminModerationController extends Controller
|
||||
|
||||
$this->audit($request, $channel, 'channel.suspended');
|
||||
|
||||
return back()->with('success', 'Channel suspended.');
|
||||
return back()->with('success', __('Channel suspended.'));
|
||||
}
|
||||
|
||||
public function restoreChannel(Request $request, Channel $channel): RedirectResponse
|
||||
@@ -34,7 +34,7 @@ class AdminModerationController extends Controller
|
||||
|
||||
$this->audit($request, $channel, 'channel.restored');
|
||||
|
||||
return back()->with('success', 'Channel restored.');
|
||||
return back()->with('success', __('Channel restored.'));
|
||||
}
|
||||
|
||||
public function suspendUser(Request $request, User $user, ViewerCountStore $viewerCounts): RedirectResponse
|
||||
@@ -52,7 +52,7 @@ class AdminModerationController extends Controller
|
||||
|
||||
$this->audit($request, $user, 'user.suspended');
|
||||
|
||||
return back()->with('success', 'User suspended.');
|
||||
return back()->with('success', __('User suspended.'));
|
||||
}
|
||||
|
||||
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
|
||||
@@ -71,7 +71,7 @@ class AdminModerationController extends Controller
|
||||
|
||||
$this->audit($request, $broadcast, 'broadcast.stopped');
|
||||
|
||||
return back()->with('success', 'Broadcast stopped.');
|
||||
return back()->with('success', __('Broadcast stopped.'));
|
||||
}
|
||||
|
||||
public function deleteVod(Request $request, Vod $vod): RedirectResponse
|
||||
@@ -80,7 +80,7 @@ class AdminModerationController extends Controller
|
||||
|
||||
$this->audit($request, $vod, 'vod.deleted');
|
||||
|
||||
return back()->with('success', 'VOD deleted.');
|
||||
return back()->with('success', __('VOD deleted.'));
|
||||
}
|
||||
|
||||
private function audit(Request $request, object $subject, string $action): void
|
||||
|
||||
@@ -46,7 +46,7 @@ class AdminSupportConversationController extends Controller
|
||||
|
||||
public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse
|
||||
{
|
||||
abort_unless($conversation->isOpen(), 422, 'Reopen this conversation before replying.');
|
||||
abort_unless($conversation->isOpen(), 422, __('Reopen this conversation before replying.'));
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
@@ -57,7 +57,7 @@ class AdminSupportConversationController extends Controller
|
||||
$request->file('attachments', []) ?: [],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Reply sent.');
|
||||
return back()->with('success', __('Reply sent.'));
|
||||
}
|
||||
|
||||
public function update(UpdateSupportConversationStatusRequest $request, SupportConversation $conversation): RedirectResponse
|
||||
@@ -68,7 +68,7 @@ class AdminSupportConversationController extends Controller
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
$conversation->isOpen() ? 'Conversation reopened.' : 'Conversation closed.',
|
||||
$conversation->isOpen() ? __('Conversation reopened.') : __('Conversation closed.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ class AdminSupportConversationController extends Controller
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'category' => $conversation->category,
|
||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other',
|
||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'),
|
||||
'subject' => $conversation->subject,
|
||||
'status' => $conversation->status,
|
||||
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||
@@ -144,7 +144,7 @@ class AdminSupportConversationController extends Controller
|
||||
'created_at' => $message->created_at?->toIso8601String(),
|
||||
'author' => [
|
||||
'id' => $message->user?->id,
|
||||
'name' => $message->user?->name ?? 'Deleted user',
|
||||
'name' => $message->user?->name ?? __('Deleted user'),
|
||||
'is_admin' => (bool) $message->user?->is_admin,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ class ChatMessageController extends Controller
|
||||
public function store(Request $request, Channel $channel): RedirectResponse
|
||||
{
|
||||
abort_if($request->user()->isSuspended() || $channel->isSuspended(), 403);
|
||||
abort_unless($channel->is_live && $channel->live_broadcast_id, 422, 'Chat is available only while the channel is live.');
|
||||
abort_unless($channel->is_live && $channel->live_broadcast_id, 422, __('Chat is available only while the channel is live.'));
|
||||
|
||||
$validated = $request->validate([
|
||||
'body' => ['required', 'string', 'max:500'],
|
||||
|
||||
@@ -58,8 +58,8 @@ class CreatorDashboardController extends Controller
|
||||
|
||||
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
||||
{
|
||||
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
|
||||
abort_unless($request->user()->canCreateChannel(), 403, 'An admin must enable channel creation for this account.');
|
||||
abort_if($request->user()->channel()->exists(), 422, __('This account already owns a channel.'));
|
||||
abort_unless($request->user()->canCreateChannel(), 403, __('An admin must enable channel creation for this account.'));
|
||||
|
||||
$validated = $request->validate([
|
||||
'display_name' => ['required', 'string', 'max:80'],
|
||||
@@ -79,7 +79,7 @@ class CreatorDashboardController extends Controller
|
||||
return redirect()
|
||||
->route('dashboard')
|
||||
->with('plain_stream_key', $plainStreamKey)
|
||||
->with('success', 'Channel created. Save the stream key now; it will not be shown again.');
|
||||
->with('success', __('Channel created. Save the stream key now; it will not be shown again.'));
|
||||
}
|
||||
|
||||
public function updateChannel(Request $request): RedirectResponse
|
||||
@@ -112,7 +112,7 @@ class CreatorDashboardController extends Controller
|
||||
'slug' => Str::slug($validated['slug']),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Channel updated.');
|
||||
return back()->with('success', __('Channel updated.'));
|
||||
}
|
||||
|
||||
public function rotateStreamKey(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
||||
@@ -125,7 +125,7 @@ class CreatorDashboardController extends Controller
|
||||
|
||||
return back()
|
||||
->with('plain_stream_key', $plainStreamKey)
|
||||
->with('success', 'Stream key rotated. Update OBS before going live again.');
|
||||
->with('success', __('Stream key rotated. Update OBS before going live again.'));
|
||||
}
|
||||
|
||||
public function storeBroadcast(Request $request): RedirectResponse
|
||||
@@ -137,7 +137,7 @@ class CreatorDashboardController extends Controller
|
||||
abort_if(
|
||||
$channel->broadcasts()->whereIn('status', [Broadcast::STATUS_PENDING, Broadcast::STATUS_LIVE])->exists(),
|
||||
422,
|
||||
'Finish the current broadcast before creating another.',
|
||||
__('Finish the current broadcast before creating another.'),
|
||||
);
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -152,7 +152,7 @@ class CreatorDashboardController extends Controller
|
||||
'mediamtx_path' => $channel->slug,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Broadcast is ready. Start streaming from OBS.');
|
||||
return back()->with('success', __('Broadcast is ready. Start streaming from OBS.'));
|
||||
}
|
||||
|
||||
public function stopBroadcast(Request $request, Broadcast $broadcast, ViewerCountStore $viewerCounts): RedirectResponse
|
||||
@@ -171,7 +171,7 @@ class CreatorDashboardController extends Controller
|
||||
])->save();
|
||||
$viewerCounts->forget($broadcast->channel);
|
||||
|
||||
return back()->with('success', 'Broadcast ended.');
|
||||
return back()->with('success', __('Broadcast ended.'));
|
||||
}
|
||||
|
||||
private function channelPayload(Channel $channel): array
|
||||
|
||||
29
app/Http/Controllers/LanguageController.php
Normal file
29
app/Http/Controllers/LanguageController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateLanguageRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class LanguageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(UpdateLanguageRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
return back()->withCookie(cookie(
|
||||
(string) config('localization.cookie', 'locale'),
|
||||
(string) $validated['locale'],
|
||||
60 * 24 * 365,
|
||||
(string) config('session.path', '/'),
|
||||
config('session.domain'),
|
||||
config('session.secure'),
|
||||
true,
|
||||
false,
|
||||
config('session.same_site', 'lax'),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class SupportConversationController extends Controller
|
||||
|
||||
return redirect()
|
||||
->route('support.show', $conversation)
|
||||
->with('success', 'Message sent to admin.');
|
||||
->with('success', __('Message sent to admin.'));
|
||||
}
|
||||
|
||||
public function show(Request $request, SupportConversation $conversation): Response
|
||||
@@ -65,7 +65,7 @@ class SupportConversationController extends Controller
|
||||
public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse
|
||||
{
|
||||
$this->authorizeUserConversation($request, $conversation);
|
||||
abort_unless($conversation->isOpen(), 422, 'This conversation is closed.');
|
||||
abort_unless($conversation->isOpen(), 422, __('This conversation is closed.'));
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
@@ -76,7 +76,7 @@ class SupportConversationController extends Controller
|
||||
$request->file('attachments', []) ?: [],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Reply sent.');
|
||||
return back()->with('success', __('Reply sent.'));
|
||||
}
|
||||
|
||||
private function authorizeUserConversation(Request $request, SupportConversation $conversation): void
|
||||
@@ -117,7 +117,7 @@ class SupportConversationController extends Controller
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'category' => $conversation->category,
|
||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other',
|
||||
'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? __('Other'),
|
||||
'subject' => $conversation->subject,
|
||||
'status' => $conversation->status,
|
||||
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||
@@ -153,7 +153,7 @@ class SupportConversationController extends Controller
|
||||
'created_at' => $message->created_at?->toIso8601String(),
|
||||
'author' => [
|
||||
'id' => $message->user?->id,
|
||||
'name' => $message->user?->name ?? 'Deleted user',
|
||||
'name' => $message->user?->name ?? __('Deleted user'),
|
||||
'is_admin' => (bool) $message->user?->is_admin,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -35,13 +35,85 @@ class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$fallbackLocale = (string) config('app.fallback_locale');
|
||||
$locale = app()->getLocale();
|
||||
$locales = $this->locales();
|
||||
$direction = $locales[$locale]['direction'] ?? 'ltr';
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
],
|
||||
'fallbackLocale' => $fallbackLocale,
|
||||
'locale' => $locale,
|
||||
'localization' => [
|
||||
'locale' => $locale,
|
||||
'fallbackLocale' => $fallbackLocale,
|
||||
'direction' => $direction,
|
||||
'isRtl' => $direction === 'rtl',
|
||||
'locales' => $this->localeOptions($locales),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'translations' => fn () => $this->translations(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{name: string, native_name: string, direction: string}>
|
||||
*/
|
||||
private function locales(): array
|
||||
{
|
||||
$locales = config('localization.locales', []);
|
||||
|
||||
return is_array($locales) ? $locales : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{name: string, native_name: string, direction: string}> $locales
|
||||
* @return array<int, array{code: string, name: string, nativeName: string, direction: string}>
|
||||
*/
|
||||
private function localeOptions(array $locales): array
|
||||
{
|
||||
return collect($locales)
|
||||
->map(fn (array $locale, string $code): array => [
|
||||
'code' => $code,
|
||||
'name' => $locale['name'],
|
||||
'nativeName' => $locale['native_name'],
|
||||
'direction' => $locale['direction'],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
56
app/Http/Middleware/SetLocale.php
Normal file
56
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$cookieName = (string) config('localization.cookie', 'locale');
|
||||
$supportedLocales = array_keys((array) config('localization.locales', []));
|
||||
$cookieLocale = $request->cookie($cookieName);
|
||||
$configuredLocale = (string) config('app.locale', 'en');
|
||||
|
||||
$locale = $this->validLocale($cookieLocale, $supportedLocales)
|
||||
?? $this->validLocale($configuredLocale, $supportedLocales)
|
||||
?? $this->validLocale((string) config('app.fallback_locale', 'en'), $supportedLocales)
|
||||
?? 'en';
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($request->hasCookie($cookieName) && $this->validLocale($cookieLocale, $supportedLocales) === null) {
|
||||
$response->headers->setCookie(Cookie::forget(
|
||||
$cookieName,
|
||||
(string) config('session.path', '/'),
|
||||
config('session.domain'),
|
||||
));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $supportedLocales
|
||||
*/
|
||||
private function validLocale(mixed $locale, array $supportedLocales): ?string
|
||||
{
|
||||
if (! is_string($locale)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return in_array($locale, $supportedLocales, true) ? $locale : null;
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/UpdateLanguageRequest.php
Normal file
30
app/Http/Requests/UpdateLanguageRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateLanguageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'locale' => ['required', 'string', Rule::in(array_keys((array) config('localization.locales', [])))],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@ class SupportConversation extends Model
|
||||
public static function categoryLabels(): array
|
||||
{
|
||||
return [
|
||||
self::CATEGORY_BUG => 'Bug report',
|
||||
self::CATEGORY_SUGGESTION => 'Suggestion',
|
||||
self::CATEGORY_CHANNEL_REQUEST => 'Channel request',
|
||||
self::CATEGORY_OTHER => 'Other',
|
||||
self::CATEGORY_BUG => (string) __('Bug report'),
|
||||
self::CATEGORY_SUGGESTION => (string) __('Suggestion'),
|
||||
self::CATEGORY_CHANNEL_REQUEST => (string) __('Channel request'),
|
||||
self::CATEGORY_OTHER => (string) __('Other'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ class SupportAttachmentFile implements ValidationRule
|
||||
],
|
||||
],
|
||||
[
|
||||
'attachment.file' => 'Attachment upload failed. Try the file again or choose a different file.',
|
||||
'attachment.max' => 'Attachments must be 200 MB or smaller.',
|
||||
'attachment.mimetypes' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.',
|
||||
'attachment.file' => __('Attachment upload failed. Try the file again or choose a different file.'),
|
||||
'attachment.max' => __('Attachments must be 200 MB or smaller.'),
|
||||
'attachment.mimetypes' => __('Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.'),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -24,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
SetLocale::class,
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
|
||||
50
config/localization.php
Normal file
50
config/localization.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Locale Preference Cookie
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This cookie stores the visitor's selected interface language. When it is
|
||||
| missing or invalid, the application falls back to the default locale
|
||||
| configured in config/app.php via APP_LOCALE.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => 'locale',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Supported Interface Locales
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These locales are available in the language switcher and are validated
|
||||
| before being stored in the locale cookie. The direction value controls
|
||||
| the document dir attribute, RTL layout variants, and RTL font selection.
|
||||
|
|
||||
*/
|
||||
|
||||
'locales' => [
|
||||
'en' => [
|
||||
'name' => 'English',
|
||||
'native_name' => 'English',
|
||||
'direction' => 'ltr',
|
||||
],
|
||||
'es' => [
|
||||
'name' => 'Spanish',
|
||||
'native_name' => 'Español',
|
||||
'direction' => 'ltr',
|
||||
],
|
||||
'fa' => [
|
||||
'name' => 'Persian',
|
||||
'native_name' => 'فارسی',
|
||||
'direction' => 'rtl',
|
||||
],
|
||||
'ar' => [
|
||||
'name' => 'Arabic',
|
||||
'native_name' => 'العربية',
|
||||
'direction' => 'rtl',
|
||||
],
|
||||
],
|
||||
];
|
||||
333
lang/ar.json
Normal file
333
lang/ar.json
Normal file
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"2FA recovery codes": "رموز استرداد 2FA",
|
||||
":channel by :owner": ":channel بواسطة :owner",
|
||||
":channel is live now with :count viewers.": ":channel مباشر الآن مع :count مشاهدين.",
|
||||
":channel is offline": ":channel غير متصل",
|
||||
":count active broadcasts": ":count بث نشط",
|
||||
":count available": ":count متاح",
|
||||
":count followers": ":count متابعين",
|
||||
":count messages": ":count رسائل",
|
||||
":count rows": ":count صفوف",
|
||||
":count threads": ":count محادثات",
|
||||
":count viewers": ":count مشاهدين",
|
||||
":status - :mode": ":status - :mode",
|
||||
":status - :mode - VOD ready": ":status - :mode - VOD جاهز",
|
||||
"@:slug owned by :owner": "@:slug مملوك لـ :owner",
|
||||
"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.": "تم إرسال رابط تحقق جديد إلى عنوان بريدك الإلكتروني.",
|
||||
"ADMIN": "مسؤول",
|
||||
"Admin": "مسؤول",
|
||||
"All categories": "كل الفئات",
|
||||
"All verified users can create channels.": "يمكن لجميع المستخدمين الموثقين إنشاء قنوات.",
|
||||
"Already have an account?": "هل لديك حساب بالفعل؟",
|
||||
"An admin can grant creator access from the admin console.": "يمكن للمسؤول منح وصول المنشئ من لوحة الإدارة.",
|
||||
"An admin must enable channel creation for this account.": "يجب على المسؤول تمكين إنشاء القنوات لهذا الحساب.",
|
||||
"Appearance": "المظهر",
|
||||
"Appearance settings": "إعدادات المظهر",
|
||||
"Approval only": "بالموافقة فقط",
|
||||
"Are you sure you want to delete your account?": "هل أنت متأكد أنك تريد حذف حسابك؟",
|
||||
"Attach up to 3 files, 200 MB each.": "أرفق ما يصل إلى 3 ملفات، 200 ميغابايت لكل ملف.",
|
||||
"Attachment upload failed. Try the file again or choose a different file.": "فشل رفع المرفق. جرّب الملف مرة أخرى أو اختر ملفًا آخر.",
|
||||
"Attachments": "المرفقات",
|
||||
"Attachments must be 200 MB or smaller.": "يجب أن تكون المرفقات 200 ميغابايت أو أقل.",
|
||||
"Authentication code": "رمز المصادقة",
|
||||
"Avatar": "الصورة الرمزية",
|
||||
"Back": "رجوع",
|
||||
"Browse active channels by category or title.": "تصفح القنوات النشطة حسب الفئة أو العنوان.",
|
||||
"Broadcast ended.": "انتهى البث.",
|
||||
"Broadcast is ready. Start streaming from OBS.": "البث جاهز. ابدأ البث من OBS.",
|
||||
"Broadcast prep": "تحضير البث",
|
||||
"Broadcast state": "حالة البث",
|
||||
"Broadcast stopped.": "تم إيقاف البث.",
|
||||
"Bug report": "تقرير خطأ",
|
||||
"Bug reports, suggestions, channel requests, and account questions.": "تقارير الأخطاء والاقتراحات وطلبات القنوات وأسئلة الحساب.",
|
||||
"Building Nyone live": "بناء Nyone Live",
|
||||
"CAN CREATE": "يمكنه الإنشاء",
|
||||
"CAN CREATE CHANNEL": "يمكنه إنشاء قناة",
|
||||
"CLOSED": "مغلق",
|
||||
"Cancel": "إلغاء",
|
||||
"Categories": "الفئات",
|
||||
"Category": "الفئة",
|
||||
"Channel": "القناة",
|
||||
"Channel created. Save the stream key now; it will not be shown again.": "تم إنشاء القناة. احفظ مفتاح البث الآن؛ لن يظهر مرة أخرى.",
|
||||
"Channel creation access": "وصول إنشاء القنوات",
|
||||
"Channel creation now requires admin approval.": "يتطلب إنشاء القنوات الآن موافقة المسؤول.",
|
||||
"Channel creation requires approval": "إنشاء القنوات يتطلب موافقة",
|
||||
"Channel is offline": "القناة غير متصلة",
|
||||
"Channel metadata": "بيانات القناة الوصفية",
|
||||
"Channel request": "طلب قناة",
|
||||
"Channel restored.": "تمت استعادة القناة.",
|
||||
"Channel setup": "إعداد القناة",
|
||||
"Channel suspended.": "تم تعليق القناة.",
|
||||
"Channel updated.": "تم تحديث القناة.",
|
||||
"Chat": "الدردشة",
|
||||
"Chat appears when the channel is live.": "تظهر الدردشة عندما تكون القناة مباشرة.",
|
||||
"Chat is available only while the channel is live.": "الدردشة متاحة فقط أثناء البث المباشر للقناة.",
|
||||
"Clear search or switch categories.": "امسح البحث أو بدّل الفئات.",
|
||||
"Click here to resend the verification email.": "انقر هنا لإعادة إرسال بريد التحقق.",
|
||||
"Close": "إغلاق",
|
||||
"Closed": "مغلق",
|
||||
"Confirm": "تأكيد",
|
||||
"Confirm password": "تأكيد كلمة المرور",
|
||||
"Confirm your password": "أكد كلمة المرور",
|
||||
"Contact": "اتصال",
|
||||
"Contact admin": "اتصل بالمسؤول",
|
||||
"Contact threads from users and channel request notes.": "محادثات الاتصال من المستخدمين وملاحظات طلبات القنوات.",
|
||||
"Continue": "متابعة",
|
||||
"Continue setup": "متابعة الإعداد",
|
||||
"Control who can create a creator channel.": "تحكم في من يمكنه إنشاء قناة منشئ.",
|
||||
"Conversations": "المحادثات",
|
||||
"Create a pending session before starting OBS.": "أنشئ جلسة معلقة قبل بدء OBS.",
|
||||
"Create account": "إنشاء حساب",
|
||||
"Create an account": "إنشاء حساب",
|
||||
"Create channel": "إنشاء قناة",
|
||||
"Create your channel": "أنشئ قناتك",
|
||||
"Creator Studio": "استوديو المنشئ",
|
||||
"Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.",
|
||||
"Creator grants": "أذونات المنشئ",
|
||||
"Creators can prepare a broadcast in the studio and go live from OBS when ready.": "يمكن للمنشئين تحضير بث في الاستوديو والبدء مباشرة من OBS عند الجاهزية.",
|
||||
"Current password": "كلمة المرور الحالية",
|
||||
"Current session": "الجلسة الحالية",
|
||||
"Current viewers": "المشاهدون الحاليون",
|
||||
"DEFAULT": "افتراضي",
|
||||
"Dark": "داكن",
|
||||
"Delete account": "حذف الحساب",
|
||||
"Delete your account and all of its resources": "احذف حسابك وكل موارده",
|
||||
"Deleted user": "مستخدم محذوف",
|
||||
"Description": "الوصف",
|
||||
"Disable 2FA": "تعطيل 2FA",
|
||||
"Displays the mobile sidebar.": "يعرض الشريط الجانبي للجوال.",
|
||||
"Display name": "اسم العرض",
|
||||
"Don't have an account?": "ليس لديك حساب؟",
|
||||
"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": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر",
|
||||
"Email": "البريد الإلكتروني",
|
||||
"Email address": "عنوان البريد الإلكتروني",
|
||||
"Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور",
|
||||
"Email verification": "التحقق من البريد الإلكتروني",
|
||||
"Enable 2FA": "تفعيل 2FA",
|
||||
"Enable two-factor authentication": "تفعيل المصادقة الثنائية",
|
||||
"Ensure your account is using a long, random password to stay secure": "تأكد من أن حسابك يستخدم كلمة مرور طويلة وعشوائية ليبقى آمنًا",
|
||||
"Enter recovery code": "أدخل رمز الاسترداد",
|
||||
"Enter the 6-digit code from your authenticator app": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
|
||||
"Enter the authentication code provided by your authenticator application.": "أدخل رمز المصادقة الذي يوفره تطبيق المصادقة.",
|
||||
"Enter your details below to create your account": "أدخل بياناتك أدناه لإنشاء حسابك",
|
||||
"Enter your email and password below to log in": "أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول",
|
||||
"Enter your email to receive a password reset link": "أدخل بريدك الإلكتروني لتلقي رابط إعادة تعيين كلمة المرور",
|
||||
"Expires :date": "ينتهي في :date",
|
||||
"Featured live": "بث مباشر مميز",
|
||||
"Finish the current broadcast before creating another.": "أنهِ البث الحالي قبل إنشاء بث آخر.",
|
||||
"Finish the current broadcast before preparing another session.": "أنهِ البث الحالي قبل تحضير جلسة أخرى.",
|
||||
"Follow": "متابعة",
|
||||
"Following": "تتابع",
|
||||
"Forgot password": "نسيت كلمة المرور",
|
||||
"Forgot password?": "هل نسيت كلمة المرور؟",
|
||||
"Full name": "الاسم الكامل",
|
||||
"Grant": "منح",
|
||||
"Hide password": "إخفاء كلمة المرور",
|
||||
"Hide recovery codes": "إخفاء رموز الاسترداد",
|
||||
"Insert :emoji": "إدراج :emoji",
|
||||
"JPG, PNG, or WebP up to 2 MB.": "JPG أو PNG أو WebP حتى 2 ميغابايت.",
|
||||
"Light": "فاتح",
|
||||
"LIVE": "مباشر",
|
||||
"LIVE - :count viewers": "مباشر - :count مشاهدين",
|
||||
"Live": "مباشر",
|
||||
"Live broadcasts": "البثوث المباشرة",
|
||||
"Live channels": "القنوات المباشرة",
|
||||
"Live directory": "دليل البث المباشر",
|
||||
"Live ingest signal": "إشارة إدخال البث المباشر",
|
||||
"Live only": "المباشر فقط",
|
||||
"Live preview": "معاينة مباشرة",
|
||||
"Live signal": "إشارة مباشرة",
|
||||
"Loading": "جارٍ التحميل",
|
||||
"Loading recovery codes": "جارٍ تحميل رموز الاسترداد",
|
||||
"Log in": "تسجيل الدخول",
|
||||
"Log in to chat": "سجّل الدخول للدردشة",
|
||||
"Log in to follow": "سجّل الدخول للمتابعة",
|
||||
"Log in to your account": "سجّل الدخول إلى حسابك",
|
||||
"Log out": "تسجيل الخروج",
|
||||
"Manage your profile and account settings": "أدر ملفك الشخصي وإعدادات حسابك",
|
||||
"Manage your two-factor authentication settings": "أدر إعدادات المصادقة الثنائية",
|
||||
"Message": "رسالة",
|
||||
"Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.",
|
||||
"Moderation console": "وحدة الإشراف",
|
||||
"More": "المزيد",
|
||||
"NEEDS APPROVAL": "يتطلب موافقة",
|
||||
"NEEDS CHANNEL GRANT": "يتطلب إذن قناة",
|
||||
"Name": "الاسم",
|
||||
"Navigation menu": "قائمة التنقل",
|
||||
"Never": "أبدًا",
|
||||
"New conversation": "محادثة جديدة",
|
||||
"New password": "كلمة مرور جديدة",
|
||||
"No active session.": "لا توجد جلسة نشطة.",
|
||||
"No broadcasts yet.": "لا توجد بثوث بعد.",
|
||||
"No category": "بلا فئة",
|
||||
"No channels are live.": "لا توجد قنوات مباشرة.",
|
||||
"No conversations yet.": "لا توجد محادثات بعد.",
|
||||
"No messages yet.": "لا توجد رسائل بعد.",
|
||||
"No public recordings yet.": "لا توجد تسجيلات عامة بعد.",
|
||||
"No streams match this view": "لا توجد بثوث تطابق هذا العرض",
|
||||
"No support conversations found.": "لم يتم العثور على محادثات دعم.",
|
||||
"No thumbnail": "بلا صورة مصغرة",
|
||||
"No users found.": "لم يتم العثور على مستخدمين.",
|
||||
"Nyone Live": "Nyone Live",
|
||||
"OBS readiness": "جاهزية OBS",
|
||||
"OBS stream key": "مفتاح بث OBS",
|
||||
"OFFLINE": "غير متصل",
|
||||
"OPEN": "مفتوح",
|
||||
"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.": "بمجرد حذف حسابك، سيتم حذف كل موارده وبياناته نهائيًا أيضًا. يرجى إدخال كلمة المرور لتأكيد رغبتك في حذف حسابك نهائيًا.",
|
||||
"Open": "فتح",
|
||||
"Open channel": "فتح القناة",
|
||||
"Open emoji picker": "فتح منتقي الرموز التعبيرية",
|
||||
"Open moderation": "فتح الإشراف",
|
||||
"Open studio": "فتح الاستوديو",
|
||||
"Open to all": "مفتوح للجميع",
|
||||
"Or, return to": "أو ارجع إلى",
|
||||
"Other": "أخرى",
|
||||
"Owner": "المالك",
|
||||
"Password": "كلمة المرور",
|
||||
"Password updated.": "تم تحديث كلمة المرور.",
|
||||
"Platform": "المنصة",
|
||||
"Please confirm access to your account by entering one of your emergency recovery codes.": "يرجى تأكيد الوصول إلى حسابك بإدخال أحد رموز الاسترداد الطارئة.",
|
||||
"Please enter your new password below": "يرجى إدخال كلمة المرور الجديدة أدناه",
|
||||
"Please proceed with caution, this cannot be undone.": "يرجى المتابعة بحذر، لا يمكن التراجع عن هذا.",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "يرجى التحقق من عنوان بريدك الإلكتروني بالنقر على الرابط الذي أرسلناه إليك للتو.",
|
||||
"Prepare": "تحضير",
|
||||
"Prepare broadcasts, secure your stream key, and monitor the current session.": "حضّر البثوث، أمّن مفتاح البث، وراقب الجلسة الحالية.",
|
||||
"Prepared broadcast": "بث محضّر",
|
||||
"Private stream key": "مفتاح بث خاص",
|
||||
"Processing recording": "جارٍ معالجة التسجيل",
|
||||
"Profile": "الملف الشخصي",
|
||||
"Profile information": "معلومات الملف الشخصي",
|
||||
"Profile settings": "إعدادات الملف الشخصي",
|
||||
"Profile updated.": "تم تحديث الملف الشخصي.",
|
||||
"Public channels broadcasting now": "قنوات عامة تبث الآن",
|
||||
"Public metadata used in the directory and watch page.": "بيانات وصفية عامة مستخدمة في الدليل وصفحة المشاهدة.",
|
||||
"READY": "جاهز",
|
||||
"Recent VODs": "أحدث VOD",
|
||||
"Recent broadcasts": "البثوث الأخيرة",
|
||||
"Recent channels": "القنوات الأخيرة",
|
||||
"Recent recordings remain available below when the creator publishes VODs.": "تبقى التسجيلات الأخيرة متاحة أدناه عندما ينشر المنشئ VOD.",
|
||||
"Record VOD": "تسجيل VOD",
|
||||
"Recording enabled": "التسجيل مفعّل",
|
||||
"Recovery code": "رمز الاسترداد",
|
||||
"Recovery codes": "رموز الاسترداد",
|
||||
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "تتيح لك رموز الاسترداد استعادة الوصول إذا فقدت جهاز 2FA. خزّنها في مدير كلمات مرور آمن.",
|
||||
"Regenerate codes": "إعادة إنشاء الرموز",
|
||||
"Register": "تسجيل",
|
||||
"Remember me": "تذكرني",
|
||||
"Remove grant": "إزالة الإذن",
|
||||
"Reopen": "إعادة فتح",
|
||||
"Reopen the conversation to reply.": "أعد فتح المحادثة للرد.",
|
||||
"Reopen this conversation before replying.": "أعد فتح هذه المحادثة قبل الرد.",
|
||||
"Reply": "رد",
|
||||
"Reply sent.": "تم إرسال الرد.",
|
||||
"Resend verification email": "إعادة إرسال بريد التحقق",
|
||||
"Reset password": "إعادة تعيين كلمة المرور",
|
||||
"Restore": "استعادة",
|
||||
"Restore channel?": "استعادة القناة؟",
|
||||
"Review live streams, suspended states, users, and recordings.": "راجع البثوث المباشرة وحالات التعليق والمستخدمين والتسجيلات.",
|
||||
"Rotate immediately if it was exposed.": "قم بتدويره فورًا إذا تم كشفه.",
|
||||
"Rotate key": "تدوير المفتاح",
|
||||
"Rotate the key to reveal it once.": "دوّر المفتاح لكشفه مرة واحدة.",
|
||||
"Save": "حفظ",
|
||||
"Save channel": "حفظ القناة",
|
||||
"Save password": "حفظ كلمة المرور",
|
||||
"Search live channels": "البحث في القنوات المباشرة",
|
||||
"Security": "الأمان",
|
||||
"Security settings": "إعدادات الأمان",
|
||||
"Send": "إرسال",
|
||||
"Send a message": "إرسال رسالة",
|
||||
"Send message": "إرسال رسالة",
|
||||
"Send reply": "إرسال رد",
|
||||
"Server": "الخادم",
|
||||
"Settings": "الإعدادات",
|
||||
"Show password": "إظهار كلمة المرور",
|
||||
"Sidebar": "الشريط الجانبي",
|
||||
"Sign up": "إنشاء حساب",
|
||||
"Slug": "المعرّف",
|
||||
"Something went wrong.": "حدث خطأ ما.",
|
||||
"Start OBS to push the session live.": "ابدأ OBS لدفع الجلسة إلى البث المباشر.",
|
||||
"Start a channel": "بدء قناة",
|
||||
"Stop": "إيقاف",
|
||||
"Stop broadcast": "إيقاف البث",
|
||||
"Stop broadcast?": "إيقاف البث؟",
|
||||
"Stop live broadcast?": "إيقاف البث المباشر؟",
|
||||
"Stream key": "مفتاح البث",
|
||||
"Stream key rotated. Update OBS before going live again.": "تم تدوير مفتاح البث. حدّث OBS قبل البث المباشر مرة أخرى.",
|
||||
"Stream key used": "تم استخدام مفتاح البث",
|
||||
"Stream keys can publish to this channel. Rotate immediately if it was exposed.": "يمكن لمفاتيح البث النشر إلى هذه القناة. قم بتدويرها فورًا إذا تم كشفها.",
|
||||
"Stream title": "عنوان البث",
|
||||
"Studio": "الاستوديو",
|
||||
"Subject": "الموضوع",
|
||||
"Suggestion": "اقتراح",
|
||||
"Support": "الدعم",
|
||||
"Support inbox": "صندوق الدعم",
|
||||
"SUSPENDED": "معلّق",
|
||||
"Suspend": "تعليق",
|
||||
"Suspend channel?": "تعليق القناة؟",
|
||||
"System": "النظام",
|
||||
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
|
||||
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
|
||||
"The live floor is quiet": "ساحة البث هادئة",
|
||||
"The live session will end immediately and the channel will move offline.": "ستنتهي الجلسة المباشرة فورًا وستصبح القناة غير متصلة.",
|
||||
"This account already owns a channel.": "هذا الحساب يمتلك قناة بالفعل.",
|
||||
"This account can browse and follow channels, but cannot create a channel yet.": "يمكن لهذا الحساب تصفح القنوات ومتابعتها، لكنه لا يستطيع إنشاء قناة بعد.",
|
||||
"This conversation is closed.": "هذه المحادثة مغلقة.",
|
||||
"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.": "يظهر هذا المفتاح مرة واحدة. خزّنه في OBS قبل مغادرة هذه الصفحة.",
|
||||
"To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app": "لإنهاء تفعيل المصادقة الثنائية، امسح رمز QR أو أدخل مفتاح الإعداد في تطبيق المصادقة",
|
||||
"Toggle sidebar": "تبديل الشريط الجانبي",
|
||||
"Two-factor authentication": "المصادقة الثنائية",
|
||||
"Two-factor authentication enabled": "تم تفعيل المصادقة الثنائية",
|
||||
"Uncategorized": "غير مصنف",
|
||||
"Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.": "نوع المرفق غير مدعوم. ارفع ملفات وسائط أو PDF أو نص/بيانات أو Office أو OpenDocument أو ملفات أرشيف.",
|
||||
"Update password": "تحديث كلمة المرور",
|
||||
"Update your account's appearance settings": "حدّث إعدادات مظهر حسابك",
|
||||
"Update your name, email address, and avatar": "حدّث اسمك وعنوان بريدك الإلكتروني وصورتك الرمزية",
|
||||
"Use a 16:9 JPG, PNG, or WebP up to 4 MB.": "استخدم JPG أو PNG أو WebP بنسبة 16:9 حتى 4 ميغابايت.",
|
||||
"USER": "مستخدم",
|
||||
"User": "مستخدم",
|
||||
"User can create a channel.": "يمكن للمستخدم إنشاء قناة.",
|
||||
"User channel creation access removed.": "تمت إزالة وصول إنشاء القنوات للمستخدم.",
|
||||
"User suspended.": "تم تعليق المستخدم.",
|
||||
"Users": "المستخدمون",
|
||||
"Verify email": "تحقق من البريد الإلكتروني",
|
||||
"View channel": "عرض القناة",
|
||||
"View recovery codes": "عرض رموز الاسترداد",
|
||||
"Viewers": "المشاهدون",
|
||||
"VOD deleted.": "تم حذف VOD.",
|
||||
"VOD ready": "VOD جاهز",
|
||||
"VODs": "VOD",
|
||||
"Warning": "تحذير",
|
||||
"Watch": "مشاهدة",
|
||||
"Watch VOD": "مشاهدة 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.": "عند تفعيل المصادقة الثنائية، سيُطلب منك رقم PIN آمن أثناء تسجيل الدخول. يمكن الحصول على هذا الرقم من تطبيق يدعم TOTP على هاتفك.",
|
||||
"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.": "سيُطلب منك رقم PIN آمن وعشوائي أثناء تسجيل الدخول، ويمكنك الحصول عليه من تطبيق يدعم TOTP على هاتفك.",
|
||||
"Your email address is unverified.": "عنوان بريدك الإلكتروني غير موثق.",
|
||||
"above.": "أعلاه.",
|
||||
"breadcrumb": "مسار التنقل",
|
||||
"email@example.com": "email@example.com",
|
||||
"later": "لاحقًا",
|
||||
"live only": "المباشر فقط",
|
||||
"log in": "تسجيل الدخول",
|
||||
"login using a recovery code": "تسجيل الدخول باستخدام رمز استرداد",
|
||||
"login using an authentication code": "تسجيل الدخول باستخدام رمز مصادقة",
|
||||
"nyone-live": "nyone-live",
|
||||
"or you can": "أو يمكنك",
|
||||
"or, enter the code manually": "أو أدخل الرمز يدويًا",
|
||||
"pending": "معلّق",
|
||||
"recording": "جارٍ التسجيل",
|
||||
"Arabic": "العربية",
|
||||
"Change language": "تغيير اللغة",
|
||||
"Choose the language used across Nyone.": "اختر اللغة المستخدمة في Nyone.",
|
||||
"English": "الإنجليزية",
|
||||
"Interface language": "لغة الواجهة",
|
||||
"Language": "اللغة",
|
||||
"Persian": "الفارسية",
|
||||
"Spanish": "الإسبانية",
|
||||
"unknown": "غير معروف"
|
||||
}
|
||||
20
lang/ar/auth.php
Normal file
20
lang/ar/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' => 'بيانات الاعتماد هذه لا تطابق سجلاتنا.',
|
||||
'password' => 'كلمة المرور المقدمة غير صحيحة.',
|
||||
'throttle' => 'عدد محاولات تسجيل الدخول كبير جدًا. يرجى المحاولة مرة أخرى بعد :seconds ثوانٍ.',
|
||||
|
||||
];
|
||||
19
lang/ar/pagination.php
Normal file
19
lang/ar/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' => '« السابق',
|
||||
'next' => 'التالي »',
|
||||
|
||||
];
|
||||
22
lang/ar/passwords.php
Normal file
22
lang/ar/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' => 'تمت إعادة تعيين كلمة المرور.',
|
||||
'sent' => 'لقد أرسلنا رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني.',
|
||||
'throttled' => 'يرجى الانتظار قبل المحاولة مرة أخرى.',
|
||||
'token' => 'رمز إعادة تعيين كلمة المرور هذا غير صالح.',
|
||||
'user' => 'لا يمكننا العثور على مستخدم بهذا البريد الإلكتروني.',
|
||||
|
||||
];
|
||||
200
lang/ar/validation.php
Normal file
200
lang/ar/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' => 'يجب قبول حقل :attribute.',
|
||||
'accepted_if' => 'يجب قبول حقل :attribute عندما يكون :other هو :value.',
|
||||
'active_url' => 'يجب أن يكون حقل :attribute رابطًا صالحًا.',
|
||||
'after' => 'يجب أن يكون حقل :attribute تاريخًا بعد :date.',
|
||||
'after_or_equal' => 'يجب أن يكون حقل :attribute تاريخًا بعد أو يساوي :date.',
|
||||
'alpha' => 'يجب أن يحتوي حقل :attribute على أحرف فقط.',
|
||||
'alpha_dash' => 'يجب أن يحتوي حقل :attribute على أحرف وأرقام وشرطات وشرطات سفلية فقط.',
|
||||
'alpha_num' => 'يجب أن يحتوي حقل :attribute على أحرف وأرقام فقط.',
|
||||
'any_of' => 'حقل :attribute غير صالح.',
|
||||
'array' => 'يجب أن يكون حقل :attribute مصفوفة.',
|
||||
'ascii' => 'يجب أن يحتوي حقل :attribute على أحرف وأرقام ورموز أحادية البايت فقط.',
|
||||
'before' => 'يجب أن يكون حقل :attribute تاريخًا قبل :date.',
|
||||
'before_or_equal' => 'يجب أن يكون حقل :attribute تاريخًا قبل أو يساوي :date.',
|
||||
'between' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على ما بين :min و :max عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute بين :min و :max كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute بين :min و :max.',
|
||||
'string' => 'يجب أن يكون حقل :attribute بين :min و :max أحرف.',
|
||||
],
|
||||
'boolean' => 'يجب أن يكون حقل :attribute صحيحًا أو خطأ.',
|
||||
'can' => 'يحتوي حقل :attribute على قيمة غير مصرح بها.',
|
||||
'confirmed' => 'تأكيد حقل :attribute غير مطابق.',
|
||||
'contains' => 'يفتقد حقل :attribute قيمة مطلوبة.',
|
||||
'current_password' => 'كلمة المرور غير صحيحة.',
|
||||
'date' => 'يجب أن يكون حقل :attribute تاريخًا صالحًا.',
|
||||
'date_equals' => 'يجب أن يكون حقل :attribute تاريخًا يساوي :date.',
|
||||
'date_format' => 'يجب أن يطابق حقل :attribute التنسيق :format.',
|
||||
'decimal' => 'يجب أن يحتوي حقل :attribute على :decimal منازل عشرية.',
|
||||
'declined' => 'يجب رفض حقل :attribute.',
|
||||
'declined_if' => 'يجب رفض حقل :attribute عندما يكون :other هو :value.',
|
||||
'different' => 'يجب أن يكون حقل :attribute و :other مختلفين.',
|
||||
'digits' => 'يجب أن يكون حقل :attribute مكونًا من :digits أرقام.',
|
||||
'digits_between' => 'يجب أن يكون حقل :attribute بين :min و :max أرقام.',
|
||||
'dimensions' => 'أبعاد صورة حقل :attribute غير صالحة.',
|
||||
'distinct' => 'يحتوي حقل :attribute على قيمة مكررة.',
|
||||
'doesnt_contain' => 'يجب ألا يحتوي حقل :attribute على أي من القيم التالية: :values.',
|
||||
'doesnt_end_with' => 'يجب ألا ينتهي حقل :attribute بإحدى القيم التالية: :values.',
|
||||
'doesnt_start_with' => 'يجب ألا يبدأ حقل :attribute بإحدى القيم التالية: :values.',
|
||||
'email' => 'يجب أن يكون حقل :attribute عنوان بريد إلكتروني صالحًا.',
|
||||
'encoding' => 'يجب أن يكون حقل :attribute مشفرًا باستخدام :encoding.',
|
||||
'ends_with' => 'يجب أن ينتهي حقل :attribute بإحدى القيم التالية: :values.',
|
||||
'enum' => ':attribute المحدد غير صالح.',
|
||||
'exists' => ':attribute المحدد غير صالح.',
|
||||
'extensions' => 'يجب أن يحتوي حقل :attribute على إحدى الامتدادات التالية: :values.',
|
||||
'file' => 'يجب أن يكون حقل :attribute ملفًا.',
|
||||
'filled' => 'يجب أن يحتوي حقل :attribute على قيمة.',
|
||||
'gt' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على أكثر من :value عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute أكبر من :value كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute أكبر من :value.',
|
||||
'string' => 'يجب أن يكون حقل :attribute أكبر من :value أحرف.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على :value عناصر أو أكثر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute أكبر من أو يساوي :value كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute أكبر من أو يساوي :value.',
|
||||
'string' => 'يجب أن يكون حقل :attribute :value أحرف أو أكثر.',
|
||||
],
|
||||
'hex_color' => 'يجب أن يكون حقل :attribute لونًا سداسيًا صالحًا.',
|
||||
'image' => 'يجب أن يكون حقل :attribute صورة.',
|
||||
'in' => ':attribute المحدد غير صالح.',
|
||||
'in_array' => 'يجب أن يوجد حقل :attribute في :other.',
|
||||
'in_array_keys' => 'يجب أن يحتوي حقل :attribute على واحد على الأقل من المفاتيح التالية: :values.',
|
||||
'integer' => 'يجب أن يكون حقل :attribute عددًا صحيحًا.',
|
||||
'ip' => 'يجب أن يكون حقل :attribute عنوان IP صالحًا.',
|
||||
'ipv4' => 'يجب أن يكون حقل :attribute عنوان IPv4 صالحًا.',
|
||||
'ipv6' => 'يجب أن يكون حقل :attribute عنوان IPv6 صالحًا.',
|
||||
'json' => 'يجب أن يكون حقل :attribute سلسلة JSON صالحة.',
|
||||
'list' => 'يجب أن يكون حقل :attribute قائمة.',
|
||||
'lowercase' => 'يجب أن يكون حقل :attribute بأحرف صغيرة.',
|
||||
'lt' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على أقل من :value عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute أقل من :value كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute أقل من :value.',
|
||||
'string' => 'يجب أن يكون حقل :attribute أقل من :value أحرف.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'يجب ألا يحتوي حقل :attribute على أكثر من :value عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute أقل من أو يساوي :value كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute أقل من أو يساوي :value.',
|
||||
'string' => 'يجب أن يكون حقل :attribute :value أحرف أو أقل.',
|
||||
],
|
||||
'mac_address' => 'يجب أن يكون حقل :attribute عنوان MAC صالحًا.',
|
||||
'max' => [
|
||||
'array' => 'يجب ألا يحتوي حقل :attribute على أكثر من :max عناصر.',
|
||||
'file' => 'يجب ألا يكون حقل :attribute أكبر من :max كيلوبايت.',
|
||||
'numeric' => 'يجب ألا يكون حقل :attribute أكبر من :max.',
|
||||
'string' => 'يجب ألا يكون حقل :attribute أكبر من :max أحرف.',
|
||||
],
|
||||
'max_digits' => 'يجب ألا يحتوي حقل :attribute على أكثر من :max أرقام.',
|
||||
'mimes' => 'يجب أن يكون حقل :attribute ملفًا من النوع: :values.',
|
||||
'mimetypes' => 'يجب أن يكون حقل :attribute ملفًا من النوع: :values.',
|
||||
'min' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على الأقل على :min عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute على الأقل :min كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute على الأقل :min.',
|
||||
'string' => 'يجب أن يكون حقل :attribute على الأقل :min أحرف.',
|
||||
],
|
||||
'min_digits' => 'يجب أن يحتوي حقل :attribute على الأقل على :min أرقام.',
|
||||
'missing' => 'يجب أن يكون حقل :attribute غير موجود.',
|
||||
'missing_if' => 'يجب أن يكون حقل :attribute غير موجود عندما يكون :other هو :value.',
|
||||
'missing_unless' => 'يجب أن يكون حقل :attribute غير موجود ما لم يكن :other هو :value.',
|
||||
'missing_with' => 'يجب أن يكون حقل :attribute غير موجود عندما تكون :values موجودة.',
|
||||
'missing_with_all' => 'يجب أن يكون حقل :attribute غير موجود عندما تكون :values موجودة.',
|
||||
'multiple_of' => 'يجب أن يكون حقل :attribute مضاعفًا لـ :value.',
|
||||
'not_in' => ':attribute المحدد غير صالح.',
|
||||
'not_regex' => 'تنسيق حقل :attribute غير صالح.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute رقمًا.',
|
||||
'password' => [
|
||||
'letters' => 'يجب أن يحتوي حقل :attribute على حرف واحد على الأقل.',
|
||||
'mixed' => 'يجب أن يحتوي حقل :attribute على حرف كبير وحرف صغير على الأقل.',
|
||||
'numbers' => 'يجب أن يحتوي حقل :attribute على رقم واحد على الأقل.',
|
||||
'symbols' => 'يجب أن يحتوي حقل :attribute على رمز واحد على الأقل.',
|
||||
'uncompromised' => 'ظهر :attribute المحدد في تسريب بيانات. يرجى اختيار :attribute مختلف.',
|
||||
],
|
||||
'present' => 'يجب أن يكون حقل :attribute موجودًا.',
|
||||
'present_if' => 'يجب أن يكون حقل :attribute موجودًا عندما يكون :other هو :value.',
|
||||
'present_unless' => 'يجب أن يكون حقل :attribute موجودًا ما لم يكن :other هو :value.',
|
||||
'present_with' => 'يجب أن يكون حقل :attribute موجودًا عندما تكون :values موجودة.',
|
||||
'present_with_all' => 'يجب أن يكون حقل :attribute موجودًا عندما تكون :values موجودة.',
|
||||
'prohibited' => 'حقل :attribute محظور.',
|
||||
'prohibited_if' => 'حقل :attribute محظور عندما يكون :other هو :value.',
|
||||
'prohibited_if_accepted' => 'حقل :attribute محظور عندما يكون :other مقبولًا.',
|
||||
'prohibited_if_declined' => 'حقل :attribute محظور عندما يكون :other مرفوضًا.',
|
||||
'prohibited_unless' => 'حقل :attribute محظور ما لم يكن :other ضمن :values.',
|
||||
'prohibits' => 'حقل :attribute يمنع :other من الوجود.',
|
||||
'regex' => 'تنسيق حقل :attribute غير صالح.',
|
||||
'required' => 'حقل :attribute مطلوب.',
|
||||
'required_array_keys' => 'يجب أن يحتوي حقل :attribute على مدخلات لـ: :values.',
|
||||
'required_if' => 'حقل :attribute مطلوب عندما يكون :other هو :value.',
|
||||
'required_if_accepted' => 'حقل :attribute مطلوب عندما يكون :other مقبولًا.',
|
||||
'required_if_declined' => 'حقل :attribute مطلوب عندما يكون :other مرفوضًا.',
|
||||
'required_unless' => 'حقل :attribute مطلوب ما لم يكن :other ضمن :values.',
|
||||
'required_with' => 'حقل :attribute مطلوب عندما تكون :values موجودة.',
|
||||
'required_with_all' => 'حقل :attribute مطلوب عندما تكون :values موجودة.',
|
||||
'required_without' => 'حقل :attribute مطلوب عندما لا تكون :values موجودة.',
|
||||
'required_without_all' => 'حقل :attribute مطلوب عندما لا تكون أي من :values موجودة.',
|
||||
'same' => 'يجب أن يطابق حقل :attribute حقل :other.',
|
||||
'size' => [
|
||||
'array' => 'يجب أن يحتوي حقل :attribute على :size عناصر.',
|
||||
'file' => 'يجب أن يكون حقل :attribute :size كيلوبايت.',
|
||||
'numeric' => 'يجب أن يكون حقل :attribute :size.',
|
||||
'string' => 'يجب أن يكون حقل :attribute :size أحرف.',
|
||||
],
|
||||
'starts_with' => 'يجب أن يبدأ حقل :attribute بإحدى القيم التالية: :values.',
|
||||
'string' => 'يجب أن يكون حقل :attribute سلسلة نصية.',
|
||||
'timezone' => 'يجب أن يكون حقل :attribute منطقة زمنية صالحة.',
|
||||
'unique' => 'قيمة :attribute مستخدمة بالفعل.',
|
||||
'uploaded' => 'فشل رفع :attribute.',
|
||||
'uppercase' => 'يجب أن يكون حقل :attribute بأحرف كبيرة.',
|
||||
'url' => 'يجب أن يكون حقل :attribute رابطًا صالحًا.',
|
||||
'ulid' => 'يجب أن يكون حقل :attribute ULID صالحًا.',
|
||||
'uuid' => 'يجب أن يكون حقل :attribute 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 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' => [],
|
||||
|
||||
];
|
||||
333
lang/en.json
Normal file
333
lang/en.json
Normal file
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"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",
|
||||
"Browse active channels by category or title.": "Browse active channels by category or title.",
|
||||
"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",
|
||||
"Closed": "Closed",
|
||||
"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",
|
||||
"Arabic": "Arabic",
|
||||
"Change language": "Change language",
|
||||
"Choose the language used across Nyone.": "Choose the language used across Nyone.",
|
||||
"English": "English",
|
||||
"Interface language": "Interface language",
|
||||
"Language": "Language",
|
||||
"Persian": "Persian",
|
||||
"Spanish": "Spanish",
|
||||
"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' => [],
|
||||
|
||||
];
|
||||
333
lang/es.json
Normal file
333
lang/es.json
Normal file
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"2FA recovery codes": "Códigos de recuperación 2FA",
|
||||
":channel by :owner": ":channel de :owner",
|
||||
":channel is live now with :count viewers.": ":channel está en vivo ahora con :count espectadores.",
|
||||
":channel is offline": ":channel está sin conexión",
|
||||
":count active broadcasts": ":count transmisiones activas",
|
||||
":count available": ":count disponibles",
|
||||
":count followers": ":count seguidores",
|
||||
":count messages": ":count mensajes",
|
||||
":count rows": ":count filas",
|
||||
":count threads": ":count conversaciones",
|
||||
":count viewers": ":count espectadores",
|
||||
":status - :mode": ":status - :mode",
|
||||
":status - :mode - VOD ready": ":status - :mode - VOD listo",
|
||||
"@:slug owned by :owner": "@:slug pertenece a :owner",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Se ha enviado un nuevo enlace de verificación a la dirección de correo electrónico que proporcionaste durante el registro.",
|
||||
"A new verification link has been sent to your email address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.",
|
||||
"ADMIN": "ADMINISTRADOR",
|
||||
"Admin": "Administrador",
|
||||
"All categories": "Todas las categorías",
|
||||
"All verified users can create channels.": "Todos los usuarios verificados pueden crear canales.",
|
||||
"Already have an account?": "¿Ya tienes una cuenta?",
|
||||
"An admin can grant creator access from the admin console.": "Un administrador puede conceder acceso de creador desde la consola de administración.",
|
||||
"An admin must enable channel creation for this account.": "Un administrador debe habilitar la creación de canales para esta cuenta.",
|
||||
"Appearance": "Apariencia",
|
||||
"Appearance settings": "Configuración de apariencia",
|
||||
"Approval only": "Solo con aprobación",
|
||||
"Are you sure you want to delete your account?": "¿Seguro que quieres eliminar tu cuenta?",
|
||||
"Attach up to 3 files, 200 MB each.": "Adjunta hasta 3 archivos, de 200 MB cada uno.",
|
||||
"Attachment upload failed. Try the file again or choose a different file.": "No se pudo subir el archivo adjunto. Intenta con el archivo de nuevo o elige otro.",
|
||||
"Attachments": "Archivos adjuntos",
|
||||
"Attachments must be 200 MB or smaller.": "Los archivos adjuntos deben ser de 200 MB o menos.",
|
||||
"Authentication code": "Código de autenticación",
|
||||
"Avatar": "Avatar",
|
||||
"Back": "Volver",
|
||||
"Browse active channels by category or title.": "Explora canales activos por categoría o título.",
|
||||
"Broadcast ended.": "La transmisión terminó.",
|
||||
"Broadcast is ready. Start streaming from OBS.": "La transmisión está lista. Empieza a transmitir desde OBS.",
|
||||
"Broadcast prep": "Preparación de transmisión",
|
||||
"Broadcast state": "Estado de la transmisión",
|
||||
"Broadcast stopped.": "Transmisión detenida.",
|
||||
"Bug report": "Informe de error",
|
||||
"Bug reports, suggestions, channel requests, and account questions.": "Informes de errores, sugerencias, solicitudes de canal y preguntas sobre cuentas.",
|
||||
"Building Nyone live": "Creando Nyone en vivo",
|
||||
"CAN CREATE": "PUEDE CREAR",
|
||||
"CAN CREATE CHANNEL": "PUEDE CREAR CANAL",
|
||||
"CLOSED": "CERRADO",
|
||||
"Cancel": "Cancelar",
|
||||
"Categories": "Categorías",
|
||||
"Category": "Categoría",
|
||||
"Channel": "Canal",
|
||||
"Channel created. Save the stream key now; it will not be shown again.": "Canal creado. Guarda la clave de transmisión ahora; no se volverá a mostrar.",
|
||||
"Channel creation access": "Acceso para crear canales",
|
||||
"Channel creation now requires admin approval.": "La creación de canales ahora requiere aprobación de un administrador.",
|
||||
"Channel creation requires approval": "La creación de canales requiere aprobación",
|
||||
"Channel is offline": "El canal está sin conexión",
|
||||
"Channel metadata": "Metadatos del canal",
|
||||
"Channel request": "Solicitud de canal",
|
||||
"Channel restored.": "Canal restaurado.",
|
||||
"Channel setup": "Configuración del canal",
|
||||
"Channel suspended.": "Canal suspendido.",
|
||||
"Channel updated.": "Canal actualizado.",
|
||||
"Chat": "Chat",
|
||||
"Chat appears when the channel is live.": "El chat aparece cuando el canal está en vivo.",
|
||||
"Chat is available only while the channel is live.": "El chat solo está disponible mientras el canal está en vivo.",
|
||||
"Clear search or switch categories.": "Borra la búsqueda o cambia de categoría.",
|
||||
"Click here to resend the verification email.": "Haz clic aquí para reenviar el correo de verificación.",
|
||||
"Close": "Cerrar",
|
||||
"Closed": "Cerrado",
|
||||
"Confirm": "Confirmar",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Confirm your password": "Confirma tu contraseña",
|
||||
"Contact": "Contacto",
|
||||
"Contact admin": "Contactar al administrador",
|
||||
"Contact threads from users and channel request notes.": "Conversaciones de contacto de usuarios y notas de solicitudes de canal.",
|
||||
"Continue": "Continuar",
|
||||
"Continue setup": "Continuar configuración",
|
||||
"Control who can create a creator channel.": "Controla quién puede crear un canal de creador.",
|
||||
"Conversations": "Conversaciones",
|
||||
"Create a pending session before starting OBS.": "Crea una sesión pendiente antes de iniciar OBS.",
|
||||
"Create account": "Crear cuenta",
|
||||
"Create an account": "Crear una cuenta",
|
||||
"Create channel": "Crear canal",
|
||||
"Create your channel": "Crea tu canal",
|
||||
"Creator Studio": "Estudio de creador",
|
||||
"Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.",
|
||||
"Creator grants": "Permisos de creador",
|
||||
"Creators can prepare a broadcast in the studio and go live from OBS when ready.": "Los creadores pueden preparar una transmisión en el estudio y salir en vivo desde OBS cuando esté lista.",
|
||||
"Current password": "Contraseña actual",
|
||||
"Current session": "Sesión actual",
|
||||
"Current viewers": "Espectadores actuales",
|
||||
"DEFAULT": "PREDETERMINADO",
|
||||
"Dark": "Oscuro",
|
||||
"Delete account": "Eliminar cuenta",
|
||||
"Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos",
|
||||
"Deleted user": "Usuario eliminado",
|
||||
"Description": "Descripción",
|
||||
"Disable 2FA": "Desactivar 2FA",
|
||||
"Displays the mobile sidebar.": "Muestra la barra lateral móvil.",
|
||||
"Display name": "Nombre visible",
|
||||
"Don't have an account?": "¿No tienes una cuenta?",
|
||||
"Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.",
|
||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic",
|
||||
"Email": "Correo electrónico",
|
||||
"Email address": "Dirección de correo electrónico",
|
||||
"Email password reset link": "Enviar enlace para restablecer contraseña",
|
||||
"Email verification": "Verificación de correo electrónico",
|
||||
"Enable 2FA": "Activar 2FA",
|
||||
"Enable two-factor authentication": "Activar autenticación de dos factores",
|
||||
"Ensure your account is using a long, random password to stay secure": "Asegúrate de que tu cuenta use una contraseña larga y aleatoria para mantenerse segura",
|
||||
"Enter recovery code": "Introduce el código de recuperación",
|
||||
"Enter the 6-digit code from your authenticator app": "Introduce el código de 6 dígitos de tu aplicación de autenticación",
|
||||
"Enter the authentication code provided by your authenticator application.": "Introduce el código de autenticación proporcionado por tu aplicación de autenticación.",
|
||||
"Enter your details below to create your account": "Introduce tus datos a continuación para crear tu cuenta",
|
||||
"Enter your email and password below to log in": "Introduce tu correo electrónico y contraseña a continuación para iniciar sesión",
|
||||
"Enter your email to receive a password reset link": "Introduce tu correo electrónico para recibir un enlace de restablecimiento de contraseña",
|
||||
"Expires :date": "Caduca el :date",
|
||||
"Featured live": "En vivo destacado",
|
||||
"Finish the current broadcast before creating another.": "Finaliza la transmisión actual antes de crear otra.",
|
||||
"Finish the current broadcast before preparing another session.": "Finaliza la transmisión actual antes de preparar otra sesión.",
|
||||
"Follow": "Seguir",
|
||||
"Following": "Siguiendo",
|
||||
"Forgot password": "Olvidé mi contraseña",
|
||||
"Forgot password?": "¿Olvidaste tu contraseña?",
|
||||
"Full name": "Nombre completo",
|
||||
"Grant": "Conceder",
|
||||
"Hide password": "Ocultar contraseña",
|
||||
"Hide recovery codes": "Ocultar códigos de recuperación",
|
||||
"Insert :emoji": "Insertar :emoji",
|
||||
"JPG, PNG, or WebP up to 2 MB.": "JPG, PNG o WebP de hasta 2 MB.",
|
||||
"Light": "Claro",
|
||||
"LIVE": "EN VIVO",
|
||||
"LIVE - :count viewers": "EN VIVO - :count espectadores",
|
||||
"Live": "En vivo",
|
||||
"Live broadcasts": "Transmisiones en vivo",
|
||||
"Live channels": "Canales en vivo",
|
||||
"Live directory": "Directorio en vivo",
|
||||
"Live ingest signal": "Señal de ingesta en vivo",
|
||||
"Live only": "Solo en vivo",
|
||||
"Live preview": "Vista previa en vivo",
|
||||
"Live signal": "Señal en vivo",
|
||||
"Loading": "Cargando",
|
||||
"Loading recovery codes": "Cargando códigos de recuperación",
|
||||
"Log in": "Iniciar sesión",
|
||||
"Log in to chat": "Inicia sesión para chatear",
|
||||
"Log in to follow": "Inicia sesión para seguir",
|
||||
"Log in to your account": "Inicia sesión en tu cuenta",
|
||||
"Log out": "Cerrar sesión",
|
||||
"Manage your profile and account settings": "Gestiona tu perfil y la configuración de tu cuenta",
|
||||
"Manage your two-factor authentication settings": "Gestiona la configuración de tu autenticación de dos factores",
|
||||
"Message": "Mensaje",
|
||||
"Message sent to admin.": "Mensaje enviado al administrador.",
|
||||
"Moderation console": "Consola de moderación",
|
||||
"More": "Más",
|
||||
"NEEDS APPROVAL": "REQUIERE APROBACIÓN",
|
||||
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
|
||||
"Name": "Nombre",
|
||||
"Navigation menu": "Menú de navegación",
|
||||
"Never": "Nunca",
|
||||
"New conversation": "Nueva conversación",
|
||||
"New password": "Nueva contraseña",
|
||||
"No active session.": "No hay sesión activa.",
|
||||
"No broadcasts yet.": "Aún no hay transmisiones.",
|
||||
"No category": "Sin categoría",
|
||||
"No channels are live.": "No hay canales en vivo.",
|
||||
"No conversations yet.": "Aún no hay conversaciones.",
|
||||
"No messages yet.": "Aún no hay mensajes.",
|
||||
"No public recordings yet.": "Aún no hay grabaciones públicas.",
|
||||
"No streams match this view": "Ninguna transmisión coincide con esta vista",
|
||||
"No support conversations found.": "No se encontraron conversaciones de soporte.",
|
||||
"No thumbnail": "Sin miniatura",
|
||||
"No users found.": "No se encontraron usuarios.",
|
||||
"Nyone Live": "Nyone Live",
|
||||
"OBS readiness": "Preparación de OBS",
|
||||
"OBS stream key": "Clave de transmisión de OBS",
|
||||
"OFFLINE": "SIN CONEXIÓN",
|
||||
"OPEN": "ABIERTO",
|
||||
"Offline": "Sin conexión",
|
||||
"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.": "Cuando se elimine tu cuenta, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar que quieres eliminar permanentemente tu cuenta.",
|
||||
"Open": "Abrir",
|
||||
"Open channel": "Abrir canal",
|
||||
"Open emoji picker": "Abrir selector de emojis",
|
||||
"Open moderation": "Abrir moderación",
|
||||
"Open studio": "Abrir estudio",
|
||||
"Open to all": "Abierto para todos",
|
||||
"Or, return to": "O vuelve a",
|
||||
"Other": "Otro",
|
||||
"Owner": "Propietario",
|
||||
"Password": "Contraseña",
|
||||
"Password updated.": "Contraseña actualizada.",
|
||||
"Platform": "Plataforma",
|
||||
"Please confirm access to your account by entering one of your emergency recovery codes.": "Confirma el acceso a tu cuenta introduciendo uno de tus códigos de recuperación de emergencia.",
|
||||
"Please enter your new password below": "Introduce tu nueva contraseña a continuación",
|
||||
"Please proceed with caution, this cannot be undone.": "Procede con cuidado; esto no se puede deshacer.",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Verifica tu dirección de correo electrónico haciendo clic en el enlace que acabamos de enviarte.",
|
||||
"Prepare": "Preparar",
|
||||
"Prepare broadcasts, secure your stream key, and monitor the current session.": "Prepara transmisiones, protege tu clave de transmisión y supervisa la sesión actual.",
|
||||
"Prepared broadcast": "Transmisión preparada",
|
||||
"Private stream key": "Clave de transmisión privada",
|
||||
"Processing recording": "Procesando grabación",
|
||||
"Profile": "Perfil",
|
||||
"Profile information": "Información del perfil",
|
||||
"Profile settings": "Configuración del perfil",
|
||||
"Profile updated.": "Perfil actualizado.",
|
||||
"Public channels broadcasting now": "Canales públicos transmitiendo ahora",
|
||||
"Public metadata used in the directory and watch page.": "Metadatos públicos usados en el directorio y la página de visualización.",
|
||||
"READY": "LISTO",
|
||||
"Recent VODs": "VOD recientes",
|
||||
"Recent broadcasts": "Transmisiones recientes",
|
||||
"Recent channels": "Canales recientes",
|
||||
"Recent recordings remain available below when the creator publishes VODs.": "Las grabaciones recientes estarán disponibles abajo cuando el creador publique VOD.",
|
||||
"Record VOD": "Grabar VOD",
|
||||
"Recording enabled": "Grabación activada",
|
||||
"Recovery code": "Código de recuperación",
|
||||
"Recovery codes": "Códigos de recuperación",
|
||||
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar el acceso si pierdes tu dispositivo 2FA. Guárdalos en un gestor de contraseñas seguro.",
|
||||
"Regenerate codes": "Regenerar códigos",
|
||||
"Register": "Registrarse",
|
||||
"Remember me": "Recordarme",
|
||||
"Remove grant": "Quitar permiso",
|
||||
"Reopen": "Reabrir",
|
||||
"Reopen the conversation to reply.": "Reabre la conversación para responder.",
|
||||
"Reopen this conversation before replying.": "Reabre esta conversación antes de responder.",
|
||||
"Reply": "Responder",
|
||||
"Reply sent.": "Respuesta enviada.",
|
||||
"Resend verification email": "Reenviar correo de verificación",
|
||||
"Reset password": "Restablecer contraseña",
|
||||
"Restore": "Restaurar",
|
||||
"Restore channel?": "¿Restaurar canal?",
|
||||
"Review live streams, suspended states, users, and recordings.": "Revisa transmisiones en vivo, estados suspendidos, usuarios y grabaciones.",
|
||||
"Rotate immediately if it was exposed.": "Rótala de inmediato si fue expuesta.",
|
||||
"Rotate key": "Rotar clave",
|
||||
"Rotate the key to reveal it once.": "Rota la clave para mostrarla una vez.",
|
||||
"Save": "Guardar",
|
||||
"Save channel": "Guardar canal",
|
||||
"Save password": "Guardar contraseña",
|
||||
"Search live channels": "Buscar canales en vivo",
|
||||
"Security": "Seguridad",
|
||||
"Security settings": "Configuración de seguridad",
|
||||
"Send": "Enviar",
|
||||
"Send a message": "Enviar un mensaje",
|
||||
"Send message": "Enviar mensaje",
|
||||
"Send reply": "Enviar respuesta",
|
||||
"Server": "Servidor",
|
||||
"Settings": "Configuración",
|
||||
"Show password": "Mostrar contraseña",
|
||||
"Sidebar": "Barra lateral",
|
||||
"Sign up": "Registrarse",
|
||||
"Slug": "Slug",
|
||||
"Something went wrong.": "Algo salió mal.",
|
||||
"Start OBS to push the session live.": "Inicia OBS para poner la sesión en vivo.",
|
||||
"Start a channel": "Crear un canal",
|
||||
"Stop": "Detener",
|
||||
"Stop broadcast": "Detener transmisión",
|
||||
"Stop broadcast?": "¿Detener transmisión?",
|
||||
"Stop live broadcast?": "¿Detener transmisión en vivo?",
|
||||
"Stream key": "Clave de transmisión",
|
||||
"Stream key rotated. Update OBS before going live again.": "Clave de transmisión rotada. Actualiza OBS antes de volver a transmitir en vivo.",
|
||||
"Stream key used": "Clave de transmisión usada",
|
||||
"Stream keys can publish to this channel. Rotate immediately if it was exposed.": "Las claves de transmisión pueden publicar en este canal. Rótalas de inmediato si fueron expuestas.",
|
||||
"Stream title": "Título de la transmisión",
|
||||
"Studio": "Estudio",
|
||||
"Subject": "Asunto",
|
||||
"Suggestion": "Sugerencia",
|
||||
"Support": "Soporte",
|
||||
"Support inbox": "Bandeja de soporte",
|
||||
"SUSPENDED": "SUSPENDIDO",
|
||||
"Suspend": "Suspender",
|
||||
"Suspend channel?": "¿Suspender canal?",
|
||||
"System": "Sistema",
|
||||
"The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.",
|
||||
"The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.",
|
||||
"The live floor is quiet": "No hay actividad en vivo",
|
||||
"The live session will end immediately and the channel will move offline.": "La sesión en vivo terminará de inmediato y el canal pasará a estar sin conexión.",
|
||||
"This account already owns a channel.": "Esta cuenta ya tiene un canal.",
|
||||
"This account can browse and follow channels, but cannot create a channel yet.": "Esta cuenta puede explorar y seguir canales, pero todavía no puede crear un canal.",
|
||||
"This conversation is closed.": "Esta conversación está cerrada.",
|
||||
"This ends the live session and clears the channel's live state. Viewers will see the offline screen.": "Esto finaliza la sesión en vivo y borra el estado en vivo del canal. Los espectadores verán la pantalla sin conexión.",
|
||||
"This key is shown once. Store it in OBS before leaving this page.": "Esta clave se muestra una sola vez. Guárdala en OBS antes de salir de esta página.",
|
||||
"To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app": "Para terminar de activar la autenticación de dos factores, escanea el código QR o introduce la clave de configuración en tu aplicación de autenticación",
|
||||
"Toggle sidebar": "Alternar barra lateral",
|
||||
"Two-factor authentication": "Autenticación de dos factores",
|
||||
"Two-factor authentication enabled": "Autenticación de dos factores activada",
|
||||
"Uncategorized": "Sin categoría",
|
||||
"Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.": "Tipo de archivo adjunto no compatible. Sube archivos multimedia, PDF, texto/datos, Office, OpenDocument o archivos comprimidos.",
|
||||
"Update password": "Actualizar contraseña",
|
||||
"Update your account's appearance settings": "Actualiza la configuración de apariencia de tu cuenta",
|
||||
"Update your name, email address, and avatar": "Actualiza tu nombre, dirección de correo electrónico y avatar",
|
||||
"Use a 16:9 JPG, PNG, or WebP up to 4 MB.": "Usa un JPG, PNG o WebP 16:9 de hasta 4 MB.",
|
||||
"USER": "USUARIO",
|
||||
"User": "Usuario",
|
||||
"User can create a channel.": "El usuario puede crear un canal.",
|
||||
"User channel creation access removed.": "Se quitó el acceso del usuario para crear canales.",
|
||||
"User suspended.": "Usuario suspendido.",
|
||||
"Users": "Usuarios",
|
||||
"Verify email": "Verificar correo electrónico",
|
||||
"View channel": "Ver canal",
|
||||
"View recovery codes": "Ver códigos de recuperación",
|
||||
"Viewers": "Espectadores",
|
||||
"VOD deleted.": "VOD eliminado.",
|
||||
"VOD ready": "VOD listo",
|
||||
"VODs": "VOD",
|
||||
"Warning": "Advertencia",
|
||||
"Watch": "Ver",
|
||||
"Watch VOD": "Ver 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.": "Cuando actives la autenticación de dos factores, se te pedirá un PIN seguro durante el inicio de sesión. Puedes obtener este PIN desde una aplicación compatible con TOTP en tu teléfono.",
|
||||
"You": "Tú",
|
||||
"YOU": "TÚ",
|
||||
"You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Se te pedirá un PIN seguro y aleatorio durante el inicio de sesión, que puedes obtener desde la aplicación compatible con TOTP en tu teléfono.",
|
||||
"Your email address is unverified.": "Tu dirección de correo electrónico no está verificada.",
|
||||
"above.": "arriba.",
|
||||
"breadcrumb": "ruta de navegación",
|
||||
"email@example.com": "email@example.com",
|
||||
"later": "más tarde",
|
||||
"live only": "solo en vivo",
|
||||
"log in": "iniciar sesión",
|
||||
"login using a recovery code": "iniciar sesión con un código de recuperación",
|
||||
"login using an authentication code": "iniciar sesión con un código de autenticación",
|
||||
"nyone-live": "nyone-live",
|
||||
"or you can": "o puedes",
|
||||
"or, enter the code manually": "o introduce el código manualmente",
|
||||
"pending": "pendiente",
|
||||
"recording": "grabando",
|
||||
"Arabic": "Árabe",
|
||||
"Change language": "Cambiar idioma",
|
||||
"Choose the language used across Nyone.": "Elige el idioma usado en Nyone.",
|
||||
"English": "Inglés",
|
||||
"Interface language": "Idioma de la interfaz",
|
||||
"Language": "Idioma",
|
||||
"Persian": "Persa",
|
||||
"Spanish": "Español",
|
||||
"unknown": "desconocido"
|
||||
}
|
||||
20
lang/es/auth.php
Normal file
20
lang/es/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' => 'Estas credenciales no coinciden con nuestros registros.',
|
||||
'password' => 'La contraseña proporcionada es incorrecta.',
|
||||
'throttle' => 'Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.',
|
||||
|
||||
];
|
||||
19
lang/es/pagination.php
Normal file
19
lang/es/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' => '« Anterior',
|
||||
'next' => 'Siguiente »',
|
||||
|
||||
];
|
||||
22
lang/es/passwords.php
Normal file
22
lang/es/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' => 'Tu contraseña ha sido restablecida.',
|
||||
'sent' => 'Te hemos enviado por correo electrónico el enlace para restablecer tu contraseña.',
|
||||
'throttled' => 'Espera antes de intentarlo de nuevo.',
|
||||
'token' => 'Este token de restablecimiento de contraseña no es válido.',
|
||||
'user' => 'No encontramos ningún usuario con esa dirección de correo electrónico.',
|
||||
|
||||
];
|
||||
200
lang/es/validation.php
Normal file
200
lang/es/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' => 'El campo :attribute debe ser aceptado.',
|
||||
'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other es :value.',
|
||||
'active_url' => 'El campo :attribute debe ser una URL válida.',
|
||||
'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
|
||||
'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
|
||||
'alpha' => 'El campo :attribute solo debe contener letras.',
|
||||
'alpha_dash' => 'El campo :attribute solo debe contener letras, números, guiones y guiones bajos.',
|
||||
'alpha_num' => 'El campo :attribute solo debe contener letras y números.',
|
||||
'any_of' => 'El campo :attribute no es válido.',
|
||||
'array' => 'El campo :attribute debe ser un arreglo.',
|
||||
'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos y símbolos de un solo byte.',
|
||||
'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
|
||||
'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
|
||||
'between' => [
|
||||
'array' => 'El campo :attribute debe tener entre :min y :max elementos.',
|
||||
'file' => 'El campo :attribute debe tener entre :min y :max kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe estar entre :min y :max.',
|
||||
'string' => 'El campo :attribute debe tener entre :min y :max caracteres.',
|
||||
],
|
||||
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
|
||||
'can' => 'El campo :attribute contiene un valor no autorizado.',
|
||||
'confirmed' => 'La confirmación del campo :attribute no coincide.',
|
||||
'contains' => 'Al campo :attribute le falta un valor requerido.',
|
||||
'current_password' => 'La contraseña es incorrecta.',
|
||||
'date' => 'El campo :attribute debe ser una fecha válida.',
|
||||
'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
|
||||
'date_format' => 'El campo :attribute debe coincidir con el formato :format.',
|
||||
'decimal' => 'El campo :attribute debe tener :decimal decimales.',
|
||||
'declined' => 'El campo :attribute debe ser rechazado.',
|
||||
'declined_if' => 'El campo :attribute debe ser rechazado cuando :other es :value.',
|
||||
'different' => 'El campo :attribute y :other deben ser diferentes.',
|
||||
'digits' => 'El campo :attribute debe tener :digits dígitos.',
|
||||
'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
|
||||
'dimensions' => 'El campo :attribute tiene dimensiones de imagen no válidas.',
|
||||
'distinct' => 'El campo :attribute tiene un valor duplicado.',
|
||||
'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes valores: :values.',
|
||||
'doesnt_end_with' => 'El campo :attribute no debe terminar con uno de los siguientes valores: :values.',
|
||||
'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes valores: :values.',
|
||||
'email' => 'El campo :attribute debe ser una dirección de correo electrónico válida.',
|
||||
'encoding' => 'El campo :attribute debe estar codificado en :encoding.',
|
||||
'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes valores: :values.',
|
||||
'enum' => 'El :attribute seleccionado no es válido.',
|
||||
'exists' => 'El :attribute seleccionado no es válido.',
|
||||
'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.',
|
||||
'file' => 'El campo :attribute debe ser un archivo.',
|
||||
'filled' => 'El campo :attribute debe tener un valor.',
|
||||
'gt' => [
|
||||
'array' => 'El campo :attribute debe tener más de :value elementos.',
|
||||
'file' => 'El campo :attribute debe ser mayor que :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser mayor que :value.',
|
||||
'string' => 'El campo :attribute debe tener más de :value caracteres.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'El campo :attribute debe tener :value elementos o más.',
|
||||
'file' => 'El campo :attribute debe ser mayor o igual que :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser mayor o igual que :value.',
|
||||
'string' => 'El campo :attribute debe tener :value caracteres o más.',
|
||||
],
|
||||
'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.',
|
||||
'image' => 'El campo :attribute debe ser una imagen.',
|
||||
'in' => 'El :attribute seleccionado no es válido.',
|
||||
'in_array' => 'El campo :attribute debe existir en :other.',
|
||||
'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.',
|
||||
'integer' => 'El campo :attribute debe ser un entero.',
|
||||
'ip' => 'El campo :attribute debe ser una dirección IP válida.',
|
||||
'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
|
||||
'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
|
||||
'json' => 'El campo :attribute debe ser una cadena JSON válida.',
|
||||
'list' => 'El campo :attribute debe ser una lista.',
|
||||
'lowercase' => 'El campo :attribute debe estar en minúsculas.',
|
||||
'lt' => [
|
||||
'array' => 'El campo :attribute debe tener menos de :value elementos.',
|
||||
'file' => 'El campo :attribute debe ser menor que :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser menor que :value.',
|
||||
'string' => 'El campo :attribute debe tener menos de :value caracteres.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'El campo :attribute no debe tener más de :value elementos.',
|
||||
'file' => 'El campo :attribute debe ser menor o igual que :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser menor o igual que :value.',
|
||||
'string' => 'El campo :attribute debe tener :value caracteres o menos.',
|
||||
],
|
||||
'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
|
||||
'max' => [
|
||||
'array' => 'El campo :attribute no debe tener más de :max elementos.',
|
||||
'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
|
||||
'numeric' => 'El campo :attribute no debe ser mayor que :max.',
|
||||
'string' => 'El campo :attribute no debe tener más de :max caracteres.',
|
||||
],
|
||||
'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.',
|
||||
'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
|
||||
'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.',
|
||||
'min' => [
|
||||
'array' => 'El campo :attribute debe tener al menos :min elementos.',
|
||||
'file' => 'El campo :attribute debe tener al menos :min kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser al menos :min.',
|
||||
'string' => 'El campo :attribute debe tener al menos :min caracteres.',
|
||||
],
|
||||
'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
|
||||
'missing' => 'El campo :attribute debe faltar.',
|
||||
'missing_if' => 'El campo :attribute debe faltar cuando :other es :value.',
|
||||
'missing_unless' => 'El campo :attribute debe faltar a menos que :other sea :value.',
|
||||
'missing_with' => 'El campo :attribute debe faltar cuando :values está presente.',
|
||||
'missing_with_all' => 'El campo :attribute debe faltar cuando :values están presentes.',
|
||||
'multiple_of' => 'El campo :attribute debe ser múltiplo de :value.',
|
||||
'not_in' => 'El :attribute seleccionado no es válido.',
|
||||
'not_regex' => 'El formato del campo :attribute no es válido.',
|
||||
'numeric' => 'El campo :attribute debe ser un número.',
|
||||
'password' => [
|
||||
'letters' => 'El campo :attribute debe contener al menos una letra.',
|
||||
'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.',
|
||||
'numbers' => 'El campo :attribute debe contener al menos un número.',
|
||||
'symbols' => 'El campo :attribute debe contener al menos un símbolo.',
|
||||
'uncompromised' => 'El :attribute proporcionado apareció en una filtración de datos. Elige otro :attribute.',
|
||||
],
|
||||
'present' => 'El campo :attribute debe estar presente.',
|
||||
'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.',
|
||||
'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
|
||||
'present_with' => 'El campo :attribute debe estar presente cuando :values está presente.',
|
||||
'present_with_all' => 'El campo :attribute debe estar presente cuando :values están presentes.',
|
||||
'prohibited' => 'El campo :attribute está prohibido.',
|
||||
'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.',
|
||||
'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other está aceptado.',
|
||||
'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other está rechazado.',
|
||||
'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.',
|
||||
'prohibits' => 'El campo :attribute impide que :other esté presente.',
|
||||
'regex' => 'El formato del campo :attribute no es válido.',
|
||||
'required' => 'El campo :attribute es obligatorio.',
|
||||
'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
|
||||
'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.',
|
||||
'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other está aceptado.',
|
||||
'required_if_declined' => 'El campo :attribute es obligatorio cuando :other está rechazado.',
|
||||
'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
|
||||
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
|
||||
'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.',
|
||||
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
|
||||
'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.',
|
||||
'same' => 'El campo :attribute debe coincidir con :other.',
|
||||
'size' => [
|
||||
'array' => 'El campo :attribute debe contener :size elementos.',
|
||||
'file' => 'El campo :attribute debe tener :size kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser :size.',
|
||||
'string' => 'El campo :attribute debe tener :size caracteres.',
|
||||
],
|
||||
'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values.',
|
||||
'string' => 'El campo :attribute debe ser una cadena.',
|
||||
'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
|
||||
'unique' => 'El valor de :attribute ya está en uso.',
|
||||
'uploaded' => 'No se pudo subir :attribute.',
|
||||
'uppercase' => 'El campo :attribute debe estar en mayúsculas.',
|
||||
'url' => 'El campo :attribute debe ser una URL válida.',
|
||||
'ulid' => 'El campo :attribute debe ser un ULID válido.',
|
||||
'uuid' => 'El campo :attribute debe ser un UUID válido.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => 'mensaje-personalizado',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => [],
|
||||
|
||||
];
|
||||
333
lang/fa.json
Normal file
333
lang/fa.json
Normal file
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"2FA recovery codes": "کدهای بازیابی 2FA",
|
||||
":channel by :owner": ":channel توسط :owner",
|
||||
":channel is live now with :count viewers.": ":channel اکنون با :count بیننده زنده است.",
|
||||
":channel is offline": ":channel آفلاین است",
|
||||
":count active broadcasts": ":count پخش فعال",
|
||||
":count available": ":count موجود",
|
||||
":count followers": ":count دنبالکننده",
|
||||
":count messages": ":count پیام",
|
||||
":count rows": ":count ردیف",
|
||||
":count threads": ":count گفتگو",
|
||||
":count viewers": ":count بیننده",
|
||||
":status - :mode": ":status - :mode",
|
||||
":status - :mode - VOD ready": ":status - :mode - VOD آماده",
|
||||
"@:slug owned by :owner": "@:slug متعلق به :owner است",
|
||||
"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.": "یک لینک تأیید جدید به آدرس ایمیل شما ارسال شد.",
|
||||
"ADMIN": "مدیر",
|
||||
"Admin": "مدیر",
|
||||
"All categories": "همه دستهبندیها",
|
||||
"All verified users can create channels.": "همه کاربران تأییدشده میتوانند کانال ایجاد کنند.",
|
||||
"Already have an account?": "از قبل حساب دارید؟",
|
||||
"An admin can grant creator access from the admin console.": "مدیر میتواند از کنسول مدیریت دسترسی سازنده را اعطا کند.",
|
||||
"An admin must enable channel creation for this account.": "مدیر باید ایجاد کانال را برای این حساب فعال کند.",
|
||||
"Appearance": "ظاهر",
|
||||
"Appearance settings": "تنظیمات ظاهر",
|
||||
"Approval only": "فقط با تأیید",
|
||||
"Are you sure you want to delete your account?": "آیا مطمئن هستید که میخواهید حساب خود را حذف کنید؟",
|
||||
"Attach up to 3 files, 200 MB each.": "حداکثر ۳ فایل، هرکدام تا ۲۰۰ مگابایت، پیوست کنید.",
|
||||
"Attachment upload failed. Try the file again or choose a different file.": "بارگذاری پیوست ناموفق بود. فایل را دوباره امتحان کنید یا فایل دیگری انتخاب کنید.",
|
||||
"Attachments": "پیوستها",
|
||||
"Attachments must be 200 MB or smaller.": "پیوستها باید ۲۰۰ مگابایت یا کمتر باشند.",
|
||||
"Authentication code": "کد احراز هویت",
|
||||
"Avatar": "آواتار",
|
||||
"Back": "بازگشت",
|
||||
"Browse active channels by category or title.": "کانالهای فعال را بر اساس دستهبندی یا عنوان مرور کنید.",
|
||||
"Broadcast ended.": "پخش پایان یافت.",
|
||||
"Broadcast is ready. Start streaming from OBS.": "پخش آماده است. از OBS شروع به پخش کنید.",
|
||||
"Broadcast prep": "آمادهسازی پخش",
|
||||
"Broadcast state": "وضعیت پخش",
|
||||
"Broadcast stopped.": "پخش متوقف شد.",
|
||||
"Bug report": "گزارش خطا",
|
||||
"Bug reports, suggestions, channel requests, and account questions.": "گزارش خطا، پیشنهادها، درخواستهای کانال و پرسشهای حساب.",
|
||||
"Building Nyone live": "در حال ساخت Nyone Live",
|
||||
"CAN CREATE": "میتواند ایجاد کند",
|
||||
"CAN CREATE CHANNEL": "میتواند کانال ایجاد کند",
|
||||
"CLOSED": "بسته",
|
||||
"Cancel": "لغو",
|
||||
"Categories": "دستهبندیها",
|
||||
"Category": "دستهبندی",
|
||||
"Channel": "کانال",
|
||||
"Channel created. Save the stream key now; it will not be shown again.": "کانال ایجاد شد. کلید پخش را اکنون ذخیره کنید؛ دوباره نمایش داده نخواهد شد.",
|
||||
"Channel creation access": "دسترسی ایجاد کانال",
|
||||
"Channel creation now requires admin approval.": "ایجاد کانال اکنون به تأیید مدیر نیاز دارد.",
|
||||
"Channel creation requires approval": "ایجاد کانال نیاز به تأیید دارد",
|
||||
"Channel is offline": "کانال آفلاین است",
|
||||
"Channel metadata": "فراداده کانال",
|
||||
"Channel request": "درخواست کانال",
|
||||
"Channel restored.": "کانال بازیابی شد.",
|
||||
"Channel setup": "راهاندازی کانال",
|
||||
"Channel suspended.": "کانال تعلیق شد.",
|
||||
"Channel updated.": "کانال بهروزرسانی شد.",
|
||||
"Chat": "گفتگو",
|
||||
"Chat appears when the channel is live.": "گفتگو زمانی نمایش داده میشود که کانال زنده باشد.",
|
||||
"Chat is available only while the channel is live.": "گفتگو فقط هنگام زنده بودن کانال در دسترس است.",
|
||||
"Clear search or switch categories.": "جستجو را پاک کنید یا دستهبندی را تغییر دهید.",
|
||||
"Click here to resend the verification email.": "برای ارسال دوباره ایمیل تأیید اینجا کلیک کنید.",
|
||||
"Close": "بستن",
|
||||
"Closed": "بسته",
|
||||
"Confirm": "تأیید",
|
||||
"Confirm password": "تأیید رمز عبور",
|
||||
"Confirm your password": "رمز عبور خود را تأیید کنید",
|
||||
"Contact": "تماس",
|
||||
"Contact admin": "تماس با مدیر",
|
||||
"Contact threads from users and channel request notes.": "گفتگوهای تماس کاربران و یادداشتهای درخواست کانال.",
|
||||
"Continue": "ادامه",
|
||||
"Continue setup": "ادامه راهاندازی",
|
||||
"Control who can create a creator channel.": "کنترل کنید چه کسی میتواند کانال سازنده ایجاد کند.",
|
||||
"Conversations": "گفتگوها",
|
||||
"Create a pending session before starting OBS.": "قبل از شروع OBS یک نشست در انتظار ایجاد کنید.",
|
||||
"Create account": "ایجاد حساب",
|
||||
"Create an account": "ایجاد یک حساب",
|
||||
"Create channel": "ایجاد کانال",
|
||||
"Create your channel": "کانال خود را ایجاد کنید",
|
||||
"Creator Studio": "استودیوی سازنده",
|
||||
"Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی میمانند.",
|
||||
"Creator grants": "مجوزهای سازنده",
|
||||
"Creators can prepare a broadcast in the studio and go live from OBS when ready.": "سازندگان میتوانند پخش را در استودیو آماده کنند و هنگام آماده بودن از OBS زنده شوند.",
|
||||
"Current password": "رمز عبور فعلی",
|
||||
"Current session": "نشست فعلی",
|
||||
"Current viewers": "بینندگان فعلی",
|
||||
"DEFAULT": "پیشفرض",
|
||||
"Dark": "تیره",
|
||||
"Delete account": "حذف حساب",
|
||||
"Delete your account and all of its resources": "حساب خود و همه منابع آن را حذف کنید",
|
||||
"Deleted user": "کاربر حذفشده",
|
||||
"Description": "توضیحات",
|
||||
"Disable 2FA": "غیرفعال کردن 2FA",
|
||||
"Displays the mobile sidebar.": "نوار کناری موبایل را نمایش میدهد.",
|
||||
"Display name": "نام نمایشی",
|
||||
"Don't have an account?": "حساب ندارید؟",
|
||||
"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": "هر کد بازیابی فقط یکبار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف میشود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید",
|
||||
"Email": "ایمیل",
|
||||
"Email address": "آدرس ایمیل",
|
||||
"Email password reset link": "ارسال لینک بازنشانی رمز عبور",
|
||||
"Email verification": "تأیید ایمیل",
|
||||
"Enable 2FA": "فعال کردن 2FA",
|
||||
"Enable two-factor authentication": "فعال کردن احراز هویت دومرحلهای",
|
||||
"Ensure your account is using a long, random password to stay secure": "برای امنیت بیشتر مطمئن شوید حساب شما از رمز عبور طولانی و تصادفی استفاده میکند",
|
||||
"Enter recovery code": "کد بازیابی را وارد کنید",
|
||||
"Enter the 6-digit code from your authenticator app": "کد ۶ رقمی برنامه احراز هویت خود را وارد کنید",
|
||||
"Enter the authentication code provided by your authenticator application.": "کد احراز هویتی را که برنامه احراز هویت ارائه کرده وارد کنید.",
|
||||
"Enter your details below to create your account": "برای ایجاد حساب، اطلاعات خود را در زیر وارد کنید",
|
||||
"Enter your email and password below to log in": "برای ورود، ایمیل و رمز عبور خود را در زیر وارد کنید",
|
||||
"Enter your email to receive a password reset link": "برای دریافت لینک بازنشانی رمز عبور، ایمیل خود را وارد کنید",
|
||||
"Expires :date": "انقضا در :date",
|
||||
"Featured live": "زنده ویژه",
|
||||
"Finish the current broadcast before creating another.": "قبل از ایجاد پخش دیگر، پخش فعلی را تمام کنید.",
|
||||
"Finish the current broadcast before preparing another session.": "قبل از آمادهسازی نشست دیگر، پخش فعلی را تمام کنید.",
|
||||
"Follow": "دنبال کردن",
|
||||
"Following": "دنبال میکنید",
|
||||
"Forgot password": "فراموشی رمز عبور",
|
||||
"Forgot password?": "رمز عبور را فراموش کردهاید؟",
|
||||
"Full name": "نام کامل",
|
||||
"Grant": "اعطا",
|
||||
"Hide password": "پنهان کردن رمز عبور",
|
||||
"Hide recovery codes": "پنهان کردن کدهای بازیابی",
|
||||
"Insert :emoji": "درج :emoji",
|
||||
"JPG, PNG, or WebP up to 2 MB.": "JPG، PNG یا WebP تا ۲ مگابایت.",
|
||||
"Light": "روشن",
|
||||
"LIVE": "زنده",
|
||||
"LIVE - :count viewers": "زنده - :count بیننده",
|
||||
"Live": "زنده",
|
||||
"Live broadcasts": "پخشهای زنده",
|
||||
"Live channels": "کانالهای زنده",
|
||||
"Live directory": "فهرست زنده",
|
||||
"Live ingest signal": "سیگنال ورودی زنده",
|
||||
"Live only": "فقط زنده",
|
||||
"Live preview": "پیشنمایش زنده",
|
||||
"Live signal": "سیگنال زنده",
|
||||
"Loading": "در حال بارگذاری",
|
||||
"Loading recovery codes": "در حال بارگذاری کدهای بازیابی",
|
||||
"Log in": "ورود",
|
||||
"Log in to chat": "برای گفتگو وارد شوید",
|
||||
"Log in to follow": "برای دنبال کردن وارد شوید",
|
||||
"Log in to your account": "به حساب خود وارد شوید",
|
||||
"Log out": "خروج",
|
||||
"Manage your profile and account settings": "پروفایل و تنظیمات حساب خود را مدیریت کنید",
|
||||
"Manage your two-factor authentication settings": "تنظیمات احراز هویت دومرحلهای خود را مدیریت کنید",
|
||||
"Message": "پیام",
|
||||
"Message sent to admin.": "پیام برای مدیر ارسال شد.",
|
||||
"Moderation console": "کنسول مدیریت",
|
||||
"More": "بیشتر",
|
||||
"NEEDS APPROVAL": "نیازمند تأیید",
|
||||
"NEEDS CHANNEL GRANT": "نیازمند مجوز کانال",
|
||||
"Name": "نام",
|
||||
"Navigation menu": "منوی ناوبری",
|
||||
"Never": "هرگز",
|
||||
"New conversation": "گفتگوی جدید",
|
||||
"New password": "رمز عبور جدید",
|
||||
"No active session.": "نشست فعالی وجود ندارد.",
|
||||
"No broadcasts yet.": "هنوز پخشی وجود ندارد.",
|
||||
"No category": "بدون دستهبندی",
|
||||
"No channels are live.": "هیچ کانالی زنده نیست.",
|
||||
"No conversations yet.": "هنوز گفتگویی وجود ندارد.",
|
||||
"No messages yet.": "هنوز پیامی وجود ندارد.",
|
||||
"No public recordings yet.": "هنوز ضبط عمومی وجود ندارد.",
|
||||
"No streams match this view": "هیچ پخشی با این نما مطابقت ندارد",
|
||||
"No support conversations found.": "هیچ گفتگوی پشتیبانی یافت نشد.",
|
||||
"No thumbnail": "بدون تصویر بندانگشتی",
|
||||
"No users found.": "هیچ کاربری یافت نشد.",
|
||||
"Nyone Live": "Nyone Live",
|
||||
"OBS readiness": "آمادگی OBS",
|
||||
"OBS stream key": "کلید پخش OBS",
|
||||
"OFFLINE": "آفلاین",
|
||||
"OPEN": "باز",
|
||||
"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.": "پس از حذف حساب، همه منابع و دادههای آن نیز برای همیشه حذف خواهند شد. برای تأیید حذف دائمی حساب، رمز عبور خود را وارد کنید.",
|
||||
"Open": "باز کردن",
|
||||
"Open channel": "باز کردن کانال",
|
||||
"Open emoji picker": "باز کردن انتخابگر ایموجی",
|
||||
"Open moderation": "باز کردن مدیریت",
|
||||
"Open studio": "باز کردن استودیو",
|
||||
"Open to all": "باز برای همه",
|
||||
"Or, return to": "یا بازگشت به",
|
||||
"Other": "دیگر",
|
||||
"Owner": "مالک",
|
||||
"Password": "رمز عبور",
|
||||
"Password updated.": "رمز عبور بهروزرسانی شد.",
|
||||
"Platform": "پلتفرم",
|
||||
"Please confirm access to your account by entering one of your emergency recovery codes.": "برای تأیید دسترسی به حساب خود، یکی از کدهای بازیابی اضطراری را وارد کنید.",
|
||||
"Please enter your new password below": "رمز عبور جدید خود را در زیر وارد کنید",
|
||||
"Please proceed with caution, this cannot be undone.": "با احتیاط ادامه دهید؛ این کار قابل بازگشت نیست.",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "لطفاً با کلیک روی لینکی که برایتان ایمیل کردیم، آدرس ایمیل خود را تأیید کنید.",
|
||||
"Prepare": "آمادهسازی",
|
||||
"Prepare broadcasts, secure your stream key, and monitor the current session.": "پخشها را آماده کنید، کلید پخش خود را امن نگه دارید و نشست فعلی را پایش کنید.",
|
||||
"Prepared broadcast": "پخش آمادهشده",
|
||||
"Private stream key": "کلید پخش خصوصی",
|
||||
"Processing recording": "در حال پردازش ضبط",
|
||||
"Profile": "پروفایل",
|
||||
"Profile information": "اطلاعات پروفایل",
|
||||
"Profile settings": "تنظیمات پروفایل",
|
||||
"Profile updated.": "پروفایل بهروزرسانی شد.",
|
||||
"Public channels broadcasting now": "کانالهای عمومی که اکنون در حال پخش هستند",
|
||||
"Public metadata used in the directory and watch page.": "فراداده عمومی استفادهشده در فهرست و صفحه تماشا.",
|
||||
"READY": "آماده",
|
||||
"Recent VODs": "VODهای اخیر",
|
||||
"Recent broadcasts": "پخشهای اخیر",
|
||||
"Recent channels": "کانالهای اخیر",
|
||||
"Recent recordings remain available below when the creator publishes VODs.": "وقتی سازنده VODها را منتشر کند، ضبطهای اخیر در پایین در دسترس میمانند.",
|
||||
"Record VOD": "ضبط VOD",
|
||||
"Recording enabled": "ضبط فعال است",
|
||||
"Recovery code": "کد بازیابی",
|
||||
"Recovery codes": "کدهای بازیابی",
|
||||
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "کدهای بازیابی در صورت از دست دادن دستگاه 2FA به شما کمک میکنند دوباره دسترسی بگیرید. آنها را در یک مدیر رمز عبور امن نگهداری کنید.",
|
||||
"Regenerate codes": "بازسازی کدها",
|
||||
"Register": "ثبتنام",
|
||||
"Remember me": "مرا به خاطر بسپار",
|
||||
"Remove grant": "حذف مجوز",
|
||||
"Reopen": "بازگشایی",
|
||||
"Reopen the conversation to reply.": "برای پاسخ دادن، گفتگو را دوباره باز کنید.",
|
||||
"Reopen this conversation before replying.": "قبل از پاسخ دادن، این گفتگو را دوباره باز کنید.",
|
||||
"Reply": "پاسخ",
|
||||
"Reply sent.": "پاسخ ارسال شد.",
|
||||
"Resend verification email": "ارسال دوباره ایمیل تأیید",
|
||||
"Reset password": "بازنشانی رمز عبور",
|
||||
"Restore": "بازیابی",
|
||||
"Restore channel?": "کانال بازیابی شود؟",
|
||||
"Review live streams, suspended states, users, and recordings.": "پخشهای زنده، وضعیتهای تعلیق، کاربران و ضبطها را بررسی کنید.",
|
||||
"Rotate immediately if it was exposed.": "اگر افشا شده است فوراً آن را بچرخانید.",
|
||||
"Rotate key": "چرخاندن کلید",
|
||||
"Rotate the key to reveal it once.": "کلید را بچرخانید تا یکبار نمایش داده شود.",
|
||||
"Save": "ذخیره",
|
||||
"Save channel": "ذخیره کانال",
|
||||
"Save password": "ذخیره رمز عبور",
|
||||
"Search live channels": "جستجوی کانالهای زنده",
|
||||
"Security": "امنیت",
|
||||
"Security settings": "تنظیمات امنیتی",
|
||||
"Send": "ارسال",
|
||||
"Send a message": "ارسال پیام",
|
||||
"Send message": "ارسال پیام",
|
||||
"Send reply": "ارسال پاسخ",
|
||||
"Server": "سرور",
|
||||
"Settings": "تنظیمات",
|
||||
"Show password": "نمایش رمز عبور",
|
||||
"Sidebar": "نوار کناری",
|
||||
"Sign up": "ثبتنام",
|
||||
"Slug": "اسلاگ",
|
||||
"Something went wrong.": "مشکلی پیش آمد.",
|
||||
"Start OBS to push the session live.": "OBS را شروع کنید تا نشست زنده شود.",
|
||||
"Start a channel": "شروع یک کانال",
|
||||
"Stop": "توقف",
|
||||
"Stop broadcast": "توقف پخش",
|
||||
"Stop broadcast?": "پخش متوقف شود؟",
|
||||
"Stop live broadcast?": "پخش زنده متوقف شود؟",
|
||||
"Stream key": "کلید پخش",
|
||||
"Stream key rotated. Update OBS before going live again.": "کلید پخش چرخانده شد. پیش از پخش زنده دوباره، OBS را بهروزرسانی کنید.",
|
||||
"Stream key used": "کلید پخش استفاده شد",
|
||||
"Stream keys can publish to this channel. Rotate immediately if it was exposed.": "کلیدهای پخش میتوانند در این کانال منتشر کنند. اگر افشا شدند فوراً آنها را بچرخانید.",
|
||||
"Stream title": "عنوان پخش",
|
||||
"Studio": "استودیو",
|
||||
"Subject": "موضوع",
|
||||
"Suggestion": "پیشنهاد",
|
||||
"Support": "پشتیبانی",
|
||||
"Support inbox": "صندوق پشتیبانی",
|
||||
"SUSPENDED": "تعلیقشده",
|
||||
"Suspend": "تعلیق",
|
||||
"Suspend channel?": "کانال تعلیق شود؟",
|
||||
"System": "سیستم",
|
||||
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
|
||||
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
|
||||
"The live floor is quiet": "فضای زنده آرام است",
|
||||
"The live session will end immediately and the channel will move offline.": "نشست زنده فوراً پایان مییابد و کانال آفلاین میشود.",
|
||||
"This account already owns a channel.": "این حساب از قبل یک کانال دارد.",
|
||||
"This account can browse and follow channels, but cannot create a channel yet.": "این حساب میتواند کانالها را مرور و دنبال کند، اما هنوز نمیتواند کانال ایجاد کند.",
|
||||
"This conversation is closed.": "این گفتگو بسته است.",
|
||||
"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.": "این کلید فقط یکبار نمایش داده میشود. قبل از خروج از این صفحه آن را در OBS ذخیره کنید.",
|
||||
"To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app": "برای تکمیل فعالسازی احراز هویت دومرحلهای، کد QR را اسکن کنید یا کلید راهاندازی را در برنامه احراز هویت خود وارد کنید",
|
||||
"Toggle sidebar": "تغییر وضعیت نوار کناری",
|
||||
"Two-factor authentication": "احراز هویت دومرحلهای",
|
||||
"Two-factor authentication enabled": "احراز هویت دومرحلهای فعال شد",
|
||||
"Uncategorized": "بدون دستهبندی",
|
||||
"Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.": "نوع پیوست پشتیبانی نمیشود. فایل رسانهای، PDF، متن/داده، Office، OpenDocument یا فایل فشرده بارگذاری کنید.",
|
||||
"Update password": "بهروزرسانی رمز عبور",
|
||||
"Update your account's appearance settings": "تنظیمات ظاهر حساب خود را بهروزرسانی کنید",
|
||||
"Update your name, email address, and avatar": "نام، آدرس ایمیل و آواتار خود را بهروزرسانی کنید",
|
||||
"Use a 16:9 JPG, PNG, or WebP up to 4 MB.": "از JPG، PNG یا WebP با نسبت ۱۶:۹ تا ۴ مگابایت استفاده کنید.",
|
||||
"USER": "کاربر",
|
||||
"User": "کاربر",
|
||||
"User can create a channel.": "کاربر میتواند کانال ایجاد کند.",
|
||||
"User channel creation access removed.": "دسترسی ایجاد کانال کاربر حذف شد.",
|
||||
"User suspended.": "کاربر تعلیق شد.",
|
||||
"Users": "کاربران",
|
||||
"Verify email": "تأیید ایمیل",
|
||||
"View channel": "مشاهده کانال",
|
||||
"View recovery codes": "مشاهده کدهای بازیابی",
|
||||
"Viewers": "بینندگان",
|
||||
"VOD deleted.": "VOD حذف شد.",
|
||||
"VOD ready": "VOD آماده است",
|
||||
"VODs": "VODها",
|
||||
"Warning": "هشدار",
|
||||
"Watch": "تماشا",
|
||||
"Watch VOD": "تماشای 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.": "وقتی احراز هویت دومرحلهای را فعال کنید، هنگام ورود از شما یک PIN امن درخواست میشود. این PIN را میتوانید از یک برنامه پشتیبانیکننده TOTP روی تلفن خود دریافت کنید.",
|
||||
"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.": "هنگام ورود از شما یک PIN امن و تصادفی درخواست میشود که میتوانید آن را از برنامه پشتیبانیکننده TOTP روی تلفن خود دریافت کنید.",
|
||||
"Your email address is unverified.": "آدرس ایمیل شما تأیید نشده است.",
|
||||
"above.": "بالا.",
|
||||
"breadcrumb": "مسیر راهنما",
|
||||
"email@example.com": "email@example.com",
|
||||
"later": "بعداً",
|
||||
"live only": "فقط زنده",
|
||||
"log in": "ورود",
|
||||
"login using a recovery code": "ورود با کد بازیابی",
|
||||
"login using an authentication code": "ورود با کد احراز هویت",
|
||||
"nyone-live": "nyone-live",
|
||||
"or you can": "یا میتوانید",
|
||||
"or, enter the code manually": "یا کد را دستی وارد کنید",
|
||||
"pending": "در انتظار",
|
||||
"recording": "در حال ضبط",
|
||||
"Arabic": "عربی",
|
||||
"Change language": "تغییر زبان",
|
||||
"Choose the language used across Nyone.": "زبان استفادهشده در Nyone را انتخاب کنید.",
|
||||
"English": "انگلیسی",
|
||||
"Interface language": "زبان رابط کاربری",
|
||||
"Language": "زبان",
|
||||
"Persian": "فارسی",
|
||||
"Spanish": "اسپانیایی",
|
||||
"unknown": "نامشخص"
|
||||
}
|
||||
20
lang/fa/auth.php
Normal file
20
lang/fa/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' => 'این اطلاعات با سوابق ما مطابقت ندارد.',
|
||||
'password' => 'رمز عبور وارد شده نادرست است.',
|
||||
'throttle' => 'تلاشهای ورود بیش از حد مجاز است. لطفاً پس از :seconds ثانیه دوباره تلاش کنید.',
|
||||
|
||||
];
|
||||
19
lang/fa/pagination.php
Normal file
19
lang/fa/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' => '« قبلی',
|
||||
'next' => 'بعدی »',
|
||||
|
||||
];
|
||||
22
lang/fa/passwords.php
Normal file
22
lang/fa/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' => 'رمز عبور شما بازنشانی شد.',
|
||||
'sent' => 'لینک بازنشانی رمز عبور را به ایمیل شما ارسال کردیم.',
|
||||
'throttled' => 'لطفاً قبل از تلاش دوباره کمی صبر کنید.',
|
||||
'token' => 'این توکن بازنشانی رمز عبور نامعتبر است.',
|
||||
'user' => 'کاربری با این آدرس ایمیل پیدا نشد.',
|
||||
|
||||
];
|
||||
200
lang/fa/validation.php
Normal file
200
lang/fa/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' => 'فیلد :attribute باید پذیرفته شود.',
|
||||
'accepted_if' => 'فیلد :attribute وقتی :other برابر :value است باید پذیرفته شود.',
|
||||
'active_url' => 'فیلد :attribute باید یک نشانی اینترنتی معتبر باشد.',
|
||||
'after' => 'فیلد :attribute باید تاریخی بعد از :date باشد.',
|
||||
'after_or_equal' => 'فیلد :attribute باید تاریخی بعد از یا برابر با :date باشد.',
|
||||
'alpha' => 'فیلد :attribute فقط باید شامل حروف باشد.',
|
||||
'alpha_dash' => 'فیلد :attribute فقط باید شامل حروف، اعداد، خط تیره و زیرخط باشد.',
|
||||
'alpha_num' => 'فیلد :attribute فقط باید شامل حروف و اعداد باشد.',
|
||||
'any_of' => 'فیلد :attribute نامعتبر است.',
|
||||
'array' => 'فیلد :attribute باید آرایه باشد.',
|
||||
'ascii' => 'فیلد :attribute فقط باید شامل حروف، اعداد و نمادهای تکبایتی باشد.',
|
||||
'before' => 'فیلد :attribute باید تاریخی قبل از :date باشد.',
|
||||
'before_or_equal' => 'فیلد :attribute باید تاریخی قبل از یا برابر با :date باشد.',
|
||||
'between' => [
|
||||
'array' => 'فیلد :attribute باید بین :min و :max آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید بین :min و :max کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید بین :min و :max باشد.',
|
||||
'string' => 'فیلد :attribute باید بین :min و :max کاراکتر باشد.',
|
||||
],
|
||||
'boolean' => 'فیلد :attribute باید درست یا نادرست باشد.',
|
||||
'can' => 'فیلد :attribute شامل مقدار غیرمجاز است.',
|
||||
'confirmed' => 'تأیید فیلد :attribute مطابقت ندارد.',
|
||||
'contains' => 'فیلد :attribute یک مقدار الزامی را ندارد.',
|
||||
'current_password' => 'رمز عبور نادرست است.',
|
||||
'date' => 'فیلد :attribute باید یک تاریخ معتبر باشد.',
|
||||
'date_equals' => 'فیلد :attribute باید تاریخی برابر با :date باشد.',
|
||||
'date_format' => 'فیلد :attribute باید با قالب :format مطابقت داشته باشد.',
|
||||
'decimal' => 'فیلد :attribute باید :decimal رقم اعشار داشته باشد.',
|
||||
'declined' => 'فیلد :attribute باید رد شود.',
|
||||
'declined_if' => 'فیلد :attribute وقتی :other برابر :value است باید رد شود.',
|
||||
'different' => 'فیلد :attribute و :other باید متفاوت باشند.',
|
||||
'digits' => 'فیلد :attribute باید :digits رقم باشد.',
|
||||
'digits_between' => 'فیلد :attribute باید بین :min و :max رقم باشد.',
|
||||
'dimensions' => 'فیلد :attribute ابعاد تصویر نامعتبر دارد.',
|
||||
'distinct' => 'فیلد :attribute مقدار تکراری دارد.',
|
||||
'doesnt_contain' => 'فیلد :attribute نباید شامل هیچیک از این مقادیر باشد: :values.',
|
||||
'doesnt_end_with' => 'فیلد :attribute نباید با یکی از این مقادیر پایان یابد: :values.',
|
||||
'doesnt_start_with' => 'فیلد :attribute نباید با یکی از این مقادیر شروع شود: :values.',
|
||||
'email' => 'فیلد :attribute باید یک آدرس ایمیل معتبر باشد.',
|
||||
'encoding' => 'فیلد :attribute باید با :encoding کدگذاری شده باشد.',
|
||||
'ends_with' => 'فیلد :attribute باید با یکی از این مقادیر پایان یابد: :values.',
|
||||
'enum' => ':attribute انتخابشده نامعتبر است.',
|
||||
'exists' => ':attribute انتخابشده نامعتبر است.',
|
||||
'extensions' => 'فیلد :attribute باید یکی از این پسوندها را داشته باشد: :values.',
|
||||
'file' => 'فیلد :attribute باید یک فایل باشد.',
|
||||
'filled' => 'فیلد :attribute باید مقدار داشته باشد.',
|
||||
'gt' => [
|
||||
'array' => 'فیلد :attribute باید بیشتر از :value آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید بزرگتر از :value کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید بزرگتر از :value باشد.',
|
||||
'string' => 'فیلد :attribute باید بیشتر از :value کاراکتر باشد.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'فیلد :attribute باید :value آیتم یا بیشتر داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید بزرگتر یا برابر با :value کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید بزرگتر یا برابر با :value باشد.',
|
||||
'string' => 'فیلد :attribute باید :value کاراکتر یا بیشتر باشد.',
|
||||
],
|
||||
'hex_color' => 'فیلد :attribute باید یک رنگ هگزادسیمال معتبر باشد.',
|
||||
'image' => 'فیلد :attribute باید یک تصویر باشد.',
|
||||
'in' => ':attribute انتخابشده نامعتبر است.',
|
||||
'in_array' => 'فیلد :attribute باید در :other وجود داشته باشد.',
|
||||
'in_array_keys' => 'فیلد :attribute باید حداقل یکی از این کلیدها را داشته باشد: :values.',
|
||||
'integer' => 'فیلد :attribute باید یک عدد صحیح باشد.',
|
||||
'ip' => 'فیلد :attribute باید یک آدرس IP معتبر باشد.',
|
||||
'ipv4' => 'فیلد :attribute باید یک آدرس IPv4 معتبر باشد.',
|
||||
'ipv6' => 'فیلد :attribute باید یک آدرس IPv6 معتبر باشد.',
|
||||
'json' => 'فیلد :attribute باید یک رشته JSON معتبر باشد.',
|
||||
'list' => 'فیلد :attribute باید یک فهرست باشد.',
|
||||
'lowercase' => 'فیلد :attribute باید با حروف کوچک باشد.',
|
||||
'lt' => [
|
||||
'array' => 'فیلد :attribute باید کمتر از :value آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید کمتر از :value کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید کمتر از :value باشد.',
|
||||
'string' => 'فیلد :attribute باید کمتر از :value کاراکتر باشد.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'فیلد :attribute نباید بیشتر از :value آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید کمتر یا برابر با :value کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید کمتر یا برابر با :value باشد.',
|
||||
'string' => 'فیلد :attribute باید :value کاراکتر یا کمتر باشد.',
|
||||
],
|
||||
'mac_address' => 'فیلد :attribute باید یک آدرس MAC معتبر باشد.',
|
||||
'max' => [
|
||||
'array' => 'فیلد :attribute نباید بیشتر از :max آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute نباید بزرگتر از :max کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute نباید بزرگتر از :max باشد.',
|
||||
'string' => 'فیلد :attribute نباید بیشتر از :max کاراکتر باشد.',
|
||||
],
|
||||
'max_digits' => 'فیلد :attribute نباید بیشتر از :max رقم داشته باشد.',
|
||||
'mimes' => 'فیلد :attribute باید فایلی از نوع :values باشد.',
|
||||
'mimetypes' => 'فیلد :attribute باید فایلی از نوع :values باشد.',
|
||||
'min' => [
|
||||
'array' => 'فیلد :attribute باید حداقل :min آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید حداقل :min کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید حداقل :min باشد.',
|
||||
'string' => 'فیلد :attribute باید حداقل :min کاراکتر باشد.',
|
||||
],
|
||||
'min_digits' => 'فیلد :attribute باید حداقل :min رقم داشته باشد.',
|
||||
'missing' => 'فیلد :attribute باید وجود نداشته باشد.',
|
||||
'missing_if' => 'فیلد :attribute وقتی :other برابر :value است باید وجود نداشته باشد.',
|
||||
'missing_unless' => 'فیلد :attribute باید وجود نداشته باشد مگر اینکه :other برابر :value باشد.',
|
||||
'missing_with' => 'فیلد :attribute وقتی :values حاضر است باید وجود نداشته باشد.',
|
||||
'missing_with_all' => 'فیلد :attribute وقتی :values حاضر هستند باید وجود نداشته باشد.',
|
||||
'multiple_of' => 'فیلد :attribute باید مضربی از :value باشد.',
|
||||
'not_in' => ':attribute انتخابشده نامعتبر است.',
|
||||
'not_regex' => 'قالب فیلد :attribute نامعتبر است.',
|
||||
'numeric' => 'فیلد :attribute باید عدد باشد.',
|
||||
'password' => [
|
||||
'letters' => 'فیلد :attribute باید حداقل یک حرف داشته باشد.',
|
||||
'mixed' => 'فیلد :attribute باید حداقل یک حرف بزرگ و یک حرف کوچک داشته باشد.',
|
||||
'numbers' => 'فیلد :attribute باید حداقل یک عدد داشته باشد.',
|
||||
'symbols' => 'فیلد :attribute باید حداقل یک نماد داشته باشد.',
|
||||
'uncompromised' => ':attribute واردشده در یک نشت داده دیده شده است. لطفاً :attribute دیگری انتخاب کنید.',
|
||||
],
|
||||
'present' => 'فیلد :attribute باید حاضر باشد.',
|
||||
'present_if' => 'فیلد :attribute وقتی :other برابر :value است باید حاضر باشد.',
|
||||
'present_unless' => 'فیلد :attribute باید حاضر باشد مگر اینکه :other برابر :value باشد.',
|
||||
'present_with' => 'فیلد :attribute وقتی :values حاضر است باید حاضر باشد.',
|
||||
'present_with_all' => 'فیلد :attribute وقتی :values حاضر هستند باید حاضر باشد.',
|
||||
'prohibited' => 'فیلد :attribute ممنوع است.',
|
||||
'prohibited_if' => 'فیلد :attribute وقتی :other برابر :value است ممنوع است.',
|
||||
'prohibited_if_accepted' => 'فیلد :attribute وقتی :other پذیرفته شده است ممنوع است.',
|
||||
'prohibited_if_declined' => 'فیلد :attribute وقتی :other رد شده است ممنوع است.',
|
||||
'prohibited_unless' => 'فیلد :attribute ممنوع است مگر اینکه :other در :values باشد.',
|
||||
'prohibits' => 'فیلد :attribute اجازه حضور :other را نمیدهد.',
|
||||
'regex' => 'قالب فیلد :attribute نامعتبر است.',
|
||||
'required' => 'فیلد :attribute الزامی است.',
|
||||
'required_array_keys' => 'فیلد :attribute باید ورودیهایی برای :values داشته باشد.',
|
||||
'required_if' => 'فیلد :attribute وقتی :other برابر :value است الزامی است.',
|
||||
'required_if_accepted' => 'فیلد :attribute وقتی :other پذیرفته شده است الزامی است.',
|
||||
'required_if_declined' => 'فیلد :attribute وقتی :other رد شده است الزامی است.',
|
||||
'required_unless' => 'فیلد :attribute الزامی است مگر اینکه :other در :values باشد.',
|
||||
'required_with' => 'فیلد :attribute وقتی :values حاضر است الزامی است.',
|
||||
'required_with_all' => 'فیلد :attribute وقتی :values حاضر هستند الزامی است.',
|
||||
'required_without' => 'فیلد :attribute وقتی :values حاضر نیست الزامی است.',
|
||||
'required_without_all' => 'فیلد :attribute وقتی هیچیک از :values حاضر نیستند الزامی است.',
|
||||
'same' => 'فیلد :attribute باید با :other مطابقت داشته باشد.',
|
||||
'size' => [
|
||||
'array' => 'فیلد :attribute باید :size آیتم داشته باشد.',
|
||||
'file' => 'فیلد :attribute باید :size کیلوبایت باشد.',
|
||||
'numeric' => 'فیلد :attribute باید :size باشد.',
|
||||
'string' => 'فیلد :attribute باید :size کاراکتر باشد.',
|
||||
],
|
||||
'starts_with' => 'فیلد :attribute باید با یکی از این مقادیر شروع شود: :values.',
|
||||
'string' => 'فیلد :attribute باید رشته باشد.',
|
||||
'timezone' => 'فیلد :attribute باید یک منطقه زمانی معتبر باشد.',
|
||||
'unique' => 'مقدار :attribute قبلاً استفاده شده است.',
|
||||
'uploaded' => 'بارگذاری :attribute ناموفق بود.',
|
||||
'uppercase' => 'فیلد :attribute باید با حروف بزرگ باشد.',
|
||||
'url' => 'فیلد :attribute باید یک نشانی اینترنتی معتبر باشد.',
|
||||
'ulid' => 'فیلد :attribute باید یک ULID معتبر باشد.',
|
||||
'uuid' => 'فیلد :attribute باید یک 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 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' => [],
|
||||
|
||||
];
|
||||
BIN
public/fonts/IRANSansWeb/IRANSansWeb.eot
Normal file
BIN
public/fonts/IRANSansWeb/IRANSansWeb.eot
Normal file
Binary file not shown.
1575
public/fonts/IRANSansWeb/IRANSansWeb.svg
Normal file
1575
public/fonts/IRANSansWeb/IRANSansWeb.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 237 KiB |
BIN
public/fonts/IRANSansWeb/IRANSansWeb.ttf
Normal file
BIN
public/fonts/IRANSansWeb/IRANSansWeb.ttf
Normal file
Binary file not shown.
BIN
public/fonts/IRANSansWeb/IRANSansWeb.woff
Normal file
BIN
public/fonts/IRANSansWeb/IRANSansWeb.woff
Normal file
Binary file not shown.
BIN
public/fonts/IRANSansWeb/IRANSansWeb.woff2
Normal file
BIN
public/fonts/IRANSansWeb/IRANSansWeb.woff2
Normal file
Binary file not shown.
14
public/fonts/IRANSansWeb/stylesheet.css
Normal file
14
public/fonts/IRANSansWeb/stylesheet.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb';
|
||||
src: url('IRANSansWeb.eot');
|
||||
src: local('IRANSansWeb'),
|
||||
url('IRANSansWeb.eot?#iefix') format('embedded-opentype'),
|
||||
url('IRANSansWeb.woff2') format('woff2'),
|
||||
url('IRANSansWeb.woff') format('woff'),
|
||||
url('IRANSansWeb.ttf') format('truetype'),
|
||||
url('IRANSansWeb.svg#IRANSansWeb') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSansWeb';
|
||||
src:
|
||||
url('/fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'),
|
||||
url('/fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'),
|
||||
url('/fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||
@@ -61,6 +72,13 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
html[dir='rtl'] {
|
||||
--font-sans:
|
||||
'IRANSansWeb', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.982 0.012 315);
|
||||
--foreground: oklch(0.18 0.045 312);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { createInertiaApp, router } from '@inertiajs/react';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { applyDocumentLocalization } from '@/lib/localization';
|
||||
import type { Localization } from '@/types';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Nyone';
|
||||
|
||||
@@ -38,3 +40,15 @@ createInertiaApp({
|
||||
|
||||
// This will set light / dark mode on load...
|
||||
initializeTheme();
|
||||
|
||||
router.on('navigate', (event) => {
|
||||
applyDocumentLocalization(
|
||||
event.detail.page.props.localization as Localization | undefined,
|
||||
);
|
||||
});
|
||||
|
||||
router.on('success', (event) => {
|
||||
applyDocumentLocalization(
|
||||
event.detail.page.props.localization as Localization | undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function AlertError({
|
||||
errors,
|
||||
@@ -8,10 +9,12 @@ export default function AlertError({
|
||||
errors: string[];
|
||||
title?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
|
||||
<AlertTitle>{t(title || 'Something went wrong.')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{Array.from(new Set(errors)).map((error, index) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard, home } from '@/routes';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
@@ -53,9 +55,10 @@ const activeItemStyles = 'bg-accent text-accent-foreground';
|
||||
|
||||
export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
const page = usePage();
|
||||
const { auth } = page.props;
|
||||
const { auth, localization } = page.props;
|
||||
const getInitials = useInitials();
|
||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Live',
|
||||
@@ -68,7 +71,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
title: 'Contact admin',
|
||||
href: supportIndex(),
|
||||
icon: LifeBuoy,
|
||||
},
|
||||
@@ -98,19 +101,19 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 h-[34px] w-[34px]"
|
||||
className="me-2 h-[34px] w-[34px]"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
side={localization.isRtl ? 'right' : 'left'}
|
||||
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation menu
|
||||
{t('Navigation menu')}
|
||||
</SheetTitle>
|
||||
<SheetHeader className="flex justify-start text-left">
|
||||
<SheetHeader className="flex justify-start text-start">
|
||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
||||
@@ -120,12 +123,12 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon className="h-5 w-5" />
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -140,15 +143,15 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
<Link
|
||||
href={home()}
|
||||
prefetch
|
||||
className="flex items-center space-x-2"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
|
||||
<div className="ms-6 hidden h-full items-center gap-6 lg:flex">
|
||||
<NavigationMenu className="flex h-full items-stretch">
|
||||
<NavigationMenuList className="flex h-full items-stretch space-x-2">
|
||||
<NavigationMenuList className="flex h-full items-stretch gap-2">
|
||||
{mainNavItems.map((item, index) => (
|
||||
<NavigationMenuItem
|
||||
key={index}
|
||||
@@ -166,9 +169,9 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
<item.icon className="me-2 h-4 w-4" />
|
||||
)}
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
{isCurrentUrl(item.href) && (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
|
||||
@@ -179,8 +182,9 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<div className="relative flex items-center space-x-1">
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<LanguageSwitcher className="hidden sm:inline-flex" />
|
||||
<div className="relative flex items-center gap-1">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -191,7 +195,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="ml-1 hidden gap-1 lg:flex">
|
||||
<div className="ms-1 hidden gap-1 lg:flex">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
@@ -199,13 +203,13 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Live directory
|
||||
{t('Live directory')}
|
||||
</span>
|
||||
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Live directory</p>
|
||||
<p>{t('Live directory')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function AppLogo() {
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground shadow-sm">
|
||||
<AppLogoIcon className="size-5 fill-current" />
|
||||
</div>
|
||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
||||
<div className="ms-1 grid flex-1 text-start text-sm">
|
||||
<span className="mb-0.5 truncate leading-tight font-semibold">
|
||||
Nyone
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function AppSidebarHeader({
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<SidebarTrigger className="-ms-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid, LifeBuoy, Radio, Shield } from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
@@ -19,7 +20,7 @@ import { index as supportIndex } from '@/routes/support';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth } = usePage().props;
|
||||
const { auth, localization } = usePage().props;
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Live',
|
||||
@@ -32,7 +33,7 @@ export function AppSidebar() {
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Contact',
|
||||
title: 'Contact admin',
|
||||
href: supportIndex(),
|
||||
icon: LifeBuoy,
|
||||
},
|
||||
@@ -52,7 +53,11 @@ export function AppSidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
side={localization.isRtl ? 'right' : 'left'}
|
||||
variant="inset"
|
||||
>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -70,6 +75,7 @@ export function AppSidebar() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<LanguageSwitcher variant="sidebar" align="start" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { Appearance } from '@/hooks/use-appearance';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AppearanceToggleTab({
|
||||
@@ -10,6 +11,7 @@ export default function AppearanceToggleTab({
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||
{ value: 'light', icon: Sun, label: 'Light' },
|
||||
@@ -36,8 +38,8 @@ export default function AppearanceToggleTab({
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
)}
|
||||
>
|
||||
<Icon className="-ml-1 h-4 w-4" />
|
||||
<span className="ml-1.5 text-sm">{label}</span>
|
||||
<Icon className="-ms-1 h-4 w-4" />
|
||||
<span className="ms-1.5 text-sm">{t(label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
export function Breadcrumbs({
|
||||
@@ -15,6 +16,8 @@ export function Breadcrumbs({
|
||||
}: {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs.length > 0 && (
|
||||
@@ -28,12 +31,12 @@ export function Breadcrumbs({
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={item.href}>
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function DeleteUser() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -28,9 +30,11 @@ export default function DeleteUser() {
|
||||
/>
|
||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p className="font-medium">Warning</p>
|
||||
<p className="font-medium">{t('Warning')}</p>
|
||||
<p className="text-sm">
|
||||
Please proceed with caution, this cannot be undone.
|
||||
{t(
|
||||
'Please proceed with caution, this cannot be undone.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +49,12 @@ export default function DeleteUser() {
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Are you sure you want to delete your account?
|
||||
{t('Are you sure you want to delete your account?')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its resources
|
||||
and data will also be permanently deleted. Please
|
||||
enter your password to confirm you would like to
|
||||
permanently delete your account.
|
||||
{t(
|
||||
'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<Form
|
||||
@@ -77,7 +80,7 @@ export default function DeleteUser() {
|
||||
id="password"
|
||||
name="password"
|
||||
ref={passwordInput}
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function Heading({
|
||||
title,
|
||||
description,
|
||||
@@ -7,6 +9,8 @@ export default function Heading({
|
||||
description?: string;
|
||||
variant?: 'default' | 'small';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
|
||||
<h2
|
||||
@@ -16,10 +20,12 @@ export default function Heading({
|
||||
: 'text-xl font-semibold tracking-tight'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
{t(title)}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(description)}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
224
resources/js/components/language-switcher.tsx
Normal file
224
resources/js/components/language-switcher.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { Check, Globe2, Languages } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { applyDocumentLocalization } from '@/lib/localization';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { update as updateLanguage } from '@/routes/language';
|
||||
import type { Localization, LocaleOption } from '@/types';
|
||||
|
||||
type LanguageSwitcherProps = {
|
||||
align?: 'center' | 'end' | 'start';
|
||||
className?: string;
|
||||
variant?: 'compact' | 'settings' | 'sidebar';
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({
|
||||
align = 'end',
|
||||
className,
|
||||
variant = 'compact',
|
||||
}: LanguageSwitcherProps) {
|
||||
const { localization } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
|
||||
const currentLocale =
|
||||
localization.locales.find(
|
||||
(locale) => locale.code === localization.locale,
|
||||
) ?? localization.locales[0];
|
||||
|
||||
if (!currentLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateLocale = (locale: string): void => {
|
||||
if (locale === localization.locale || pendingLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLocale = localization.locales.find(
|
||||
(option) => option.code === locale,
|
||||
);
|
||||
|
||||
if (!nextLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingLocale(locale);
|
||||
applyDocumentLocalization({
|
||||
direction: nextLocale.direction,
|
||||
locale: nextLocale.code,
|
||||
});
|
||||
|
||||
router
|
||||
.optimistic((props) => ({
|
||||
locale: nextLocale.code,
|
||||
localization: {
|
||||
...(props as { localization: Localization }).localization,
|
||||
direction: nextLocale.direction,
|
||||
isRtl: nextLocale.direction === 'rtl',
|
||||
locale: nextLocale.code,
|
||||
},
|
||||
}))
|
||||
.post(
|
||||
updateLanguage.url(),
|
||||
{ locale },
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: false,
|
||||
onCancel: () => applyDocumentLocalization(localization),
|
||||
onError: () => applyDocumentLocalization(localization),
|
||||
onFinish: () => setPendingLocale(null),
|
||||
onNetworkError: () =>
|
||||
applyDocumentLocalization(localization),
|
||||
onSuccess: (page) =>
|
||||
applyDocumentLocalization(
|
||||
page.props.localization as Localization | undefined,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (variant === 'settings') {
|
||||
return (
|
||||
<section className={cn('space-y-4', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Languages className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">
|
||||
{t('Interface language')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Choose the language used across Nyone.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{localization.locales.map((locale) => (
|
||||
<LanguageOptionButton
|
||||
key={locale.code}
|
||||
locale={locale}
|
||||
isActive={locale.code === localization.locale}
|
||||
isPending={pendingLocale === locale.code}
|
||||
onSelect={updateLocale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={variant === 'sidebar' ? 'ghost' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-9 gap-2 px-2.5',
|
||||
variant === 'sidebar' &&
|
||||
'w-full justify-start border-transparent bg-transparent text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-label={t('Change language')}
|
||||
>
|
||||
<Globe2 className="size-4" />
|
||||
<span className="text-xs font-semibold tracking-normal">
|
||||
{currentLocale.code.toUpperCase()}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-56">
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Globe2 className="size-4 text-muted-foreground" />
|
||||
{t('Language')}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{localization.locales.map((locale) => (
|
||||
<DropdownMenuItem
|
||||
key={locale.code}
|
||||
className="items-center gap-3 p-2"
|
||||
disabled={pendingLocale !== null}
|
||||
onSelect={() => updateLocale(locale.code)}
|
||||
>
|
||||
<LocaleBadge locale={locale} />
|
||||
<span
|
||||
className="min-w-0 flex-1 text-start"
|
||||
dir={locale.direction}
|
||||
>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{locale.nativeName}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{t(locale.name)}
|
||||
</span>
|
||||
</span>
|
||||
{locale.code === localization.locale && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type LanguageOptionButtonProps = {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
locale: LocaleOption;
|
||||
onSelect: (locale: string) => void;
|
||||
};
|
||||
|
||||
function LanguageOptionButton({
|
||||
isActive,
|
||||
isPending,
|
||||
locale,
|
||||
onSelect,
|
||||
}: LanguageOptionButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-start transition-colors',
|
||||
isActive
|
||||
? 'border-primary bg-primary/10 text-foreground shadow-sm'
|
||||
: 'border-border bg-background hover:border-primary/50 hover:bg-accent',
|
||||
)}
|
||||
dir={locale.direction}
|
||||
disabled={isPending}
|
||||
onClick={() => onSelect(locale.code)}
|
||||
>
|
||||
<LocaleBadge locale={locale} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{locale.nativeName}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{t(locale.name)}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && <Check className="size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LocaleBadge({ locale }: { locale: LocaleOption }) {
|
||||
return (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground uppercase">
|
||||
{locale.code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t('Platform')}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrentUrl(item.href)}
|
||||
tooltip={{ children: item.title }}
|
||||
tooltip={{ children: t(item.title) }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function NavUser() {
|
||||
data-test="sidebar-menu-button"
|
||||
>
|
||||
<UserInfo user={auth.user} />
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
<ChevronsUpDown className="ms-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Eye, EyeOff } from 'lucide-react';
|
||||
import type { ComponentProps, Ref } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function PasswordInput({
|
||||
@@ -10,20 +11,21 @@ export default function PasswordInput({
|
||||
...props
|
||||
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={cn('pr-10', className)}
|
||||
className={cn('pe-10', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className="absolute inset-y-0 end-0 flex items-center rounded-e-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-label={t(showPassword ? 'Hide password' : 'Show password')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
type Props = {
|
||||
@@ -26,6 +27,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
|
||||
const codesSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleCodesVisibility = useCallback(async () => {
|
||||
if (!codesAreVisible && !recoveryCodesList.length) {
|
||||
@@ -57,11 +59,12 @@ export default function TwoFactorRecoveryCodes({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-3">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
2FA recovery codes
|
||||
{t('2FA recovery codes')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
{t(
|
||||
'Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -76,7 +79,9 @@ export default function TwoFactorRecoveryCodes({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{codesAreVisible ? 'Hide' : 'View'} recovery codes
|
||||
{codesAreVisible
|
||||
? t('Hide recovery codes')
|
||||
: t('View recovery codes')}
|
||||
</Button>
|
||||
|
||||
{canRegenerateCodes && (
|
||||
@@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
disabled={processing}
|
||||
aria-describedby="regenerate-warning"
|
||||
>
|
||||
<RefreshCw /> Regenerate codes
|
||||
<RefreshCw /> {t('Regenerate codes')}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
@@ -112,7 +117,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
ref={codesSectionRef}
|
||||
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||
role="list"
|
||||
aria-label="Recovery codes"
|
||||
aria-label={t('Recovery codes')}
|
||||
>
|
||||
{recoveryCodesList.length ? (
|
||||
recoveryCodesList.map((code, index) => (
|
||||
@@ -127,7 +132,9 @@ export default function TwoFactorRecoveryCodes({
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label="Loading recovery codes"
|
||||
aria-label={t(
|
||||
'Loading recovery codes',
|
||||
)}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: 8 },
|
||||
@@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({
|
||||
|
||||
<div className="text-xs text-muted-foreground select-none">
|
||||
<p id="regenerate-warning">
|
||||
Each recovery code can be used once to
|
||||
access your account and will be removed
|
||||
after use. If you need more, click{' '}
|
||||
{t(
|
||||
'Each recovery code can be used once to access your account and will be removed after use. If you need more, click',
|
||||
)}{' '}
|
||||
<span className="font-bold">
|
||||
Regenerate codes
|
||||
{t('Regenerate codes')}
|
||||
</span>{' '}
|
||||
above.
|
||||
{t('above.')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useClipboard } from '@/hooks/use-clipboard';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
|
||||
function GridScanIcon() {
|
||||
@@ -31,7 +32,7 @@ function GridScanIcon() {
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div
|
||||
key={`col-${i + 1}`}
|
||||
className="border-r border-border last:border-r-0"
|
||||
className="border-e border-border last:border-e-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -64,6 +65,7 @@ function TwoFactorSetupStep({
|
||||
}) {
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
const [copiedText, copy] = useClipboard();
|
||||
const { t } = useTranslation();
|
||||
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
||||
|
||||
return (
|
||||
@@ -95,7 +97,7 @@ function TwoFactorSetupStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-5">
|
||||
<div className="flex w-full gap-5">
|
||||
<Button className="w-full" onClick={onNextStep}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
@@ -104,11 +106,11 @@ function TwoFactorSetupStep({
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
||||
<span className="relative bg-card px-2 py-1">
|
||||
or, enter the code manually
|
||||
{t('or, enter the code manually')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
|
||||
{!manualSetupKey ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted p-3">
|
||||
@@ -124,7 +126,7 @@ function TwoFactorSetupStep({
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(manualSetupKey)}
|
||||
className="border-l border-border px-3 hover:bg-muted"
|
||||
className="border-s border-border px-3 hover:bg-muted"
|
||||
>
|
||||
<IconComponent className="w-4" />
|
||||
</button>
|
||||
@@ -147,6 +149,7 @@ function TwoFactorVerificationStep({
|
||||
}) {
|
||||
const [code, setCode] = useState<string>('');
|
||||
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
@@ -202,7 +205,7 @@ function TwoFactorVerificationStep({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-5">
|
||||
<div className="flex w-full gap-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -210,7 +213,7 @@ function TwoFactorVerificationStep({
|
||||
onClick={onBack}
|
||||
disabled={processing}
|
||||
>
|
||||
Back
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -219,7 +222,7 @@ function TwoFactorVerificationStep({
|
||||
processing || code.length < OTP_MAX_LENGTH
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({
|
||||
}: Props) {
|
||||
const [showVerificationStep, setShowVerificationStep] =
|
||||
useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modalConfig = useMemo<{
|
||||
title: string;
|
||||
@@ -262,29 +266,32 @@ export default function TwoFactorSetupModal({
|
||||
}>(() => {
|
||||
if (twoFactorEnabled) {
|
||||
return {
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
title: t('Two-factor authentication enabled'),
|
||||
description: t(
|
||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
||||
buttonText: 'Close',
|
||||
),
|
||||
buttonText: t('Close'),
|
||||
};
|
||||
}
|
||||
|
||||
if (showVerificationStep) {
|
||||
return {
|
||||
title: 'Verify authentication code',
|
||||
description:
|
||||
title: t('Verify authentication code'),
|
||||
description: t(
|
||||
'Enter the 6-digit code from your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
),
|
||||
buttonText: t('Continue'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Enable two-factor authentication',
|
||||
description:
|
||||
title: t('Enable two-factor authentication'),
|
||||
description: t(
|
||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
),
|
||||
buttonText: t('Continue'),
|
||||
};
|
||||
}, [twoFactorEnabled, showVerificationStep]);
|
||||
}, [showVerificationStep, t, twoFactorEnabled]);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
setShowVerificationStep(false);
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <nav aria-label={t("breadcrumb")} data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
@@ -84,6 +87,8 @@ function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
@@ -93,7 +98,7 @@ function BreadcrumbEllipsis({
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
<span className="sr-only">{t("More")}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslatedChildren } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
@@ -39,19 +40,23 @@ function Button({
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const translatedChildren = useTranslatedChildren(children)
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{translatedChildren}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
@@ -49,6 +50,8 @@ function DialogContent({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -61,9 +64,9 @@ function DialogContent({
|
||||
{...props}
|
||||
>
|
||||
{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 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("Close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
@@ -74,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -72,7 +72,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -90,13 +90,13 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
@@ -126,12 +126,12 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
@@ -153,7 +153,7 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -182,7 +182,7 @@ function DropdownMenuShortcut({
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
"text-muted-foreground ms-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -209,13 +209,13 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ms-auto size-4 rtl:rotate-180" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const InputOTPSlot = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-all first:rounded-s-md first:border-s last:rounded-e-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslatedChildren } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const translatedChildren = useTranslatedChildren(children)
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
@@ -15,7 +19,9 @@ function Label({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{translatedChildren}
|
||||
</LabelPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ function NavigationMenuTrigger({
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
className="relative top-[1px] ms-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
|
||||
@@ -112,14 +112,14 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
className="absolute end-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
@@ -50,6 +51,8 @@ function SheetContent({
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -70,9 +73,9 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{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 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("Close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
@@ -160,6 +161,7 @@ function Sidebar({
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -180,8 +182,10 @@ function Sidebar({
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>{t("Sidebar")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("Displays the mobile sidebar.")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
@@ -252,6 +256,7 @@ function SidebarTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar, isMobile, state } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -267,22 +272,23 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
{isMobile || state === "collapsed" ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
<span className="sr-only">{t("Toggle sidebar")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={t("Toggle sidebar")}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle sidebar"
|
||||
title={t("Toggle sidebar")}
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
@@ -303,7 +309,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex max-w-full min-h-svh flex-1 flex-col",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -419,7 +425,7 @@ function SidebarGroupAction({
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@@ -467,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -554,7 +560,7 @@ function SidebarMenuAction({
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
@@ -579,7 +585,7 @@ function SidebarMenuBadge({
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"text-sidebar-foreground pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
@@ -636,7 +642,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
aria-label={t("Loading")}
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,7 @@ function ToggleGroupItem({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
"min-w-0 shrink-0 rounded-none shadow-none first:rounded-s-md last:rounded-e-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-s-0 data-[variant=outline]:first:border-s",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function UserInfo({
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
{showEmail && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
@@ -18,6 +19,7 @@ type Props = {
|
||||
|
||||
export function UserMenuContent({ user }: Props) {
|
||||
const cleanup = useMobileNavigation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = () => {
|
||||
cleanup();
|
||||
@@ -27,7 +29,7 @@ export function UserMenuContent({ user }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<UserInfo user={user} showEmail={true} />
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -40,8 +42,8 @@ export function UserMenuContent({ user }: Props) {
|
||||
prefetch
|
||||
onClick={cleanup}
|
||||
>
|
||||
<Settings className="mr-2" />
|
||||
Settings
|
||||
<Settings className="me-2" />
|
||||
{t('Settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -54,8 +56,8 @@ export function UserMenuContent({ user }: Props) {
|
||||
onClick={handleLogout}
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
<LogOut className="me-2" />
|
||||
{t('Log out')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hls from 'hls.js/light';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
@@ -9,6 +10,7 @@ export function VideoPlayer({
|
||||
title: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -39,7 +41,7 @@ export function VideoPlayer({
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="flex aspect-video w-full items-center justify-center bg-neutral-950 text-sm text-neutral-300">
|
||||
Offline
|
||||
{t('Offline')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
|
||||
export default function AuthCardLayout({
|
||||
@@ -19,6 +20,8 @@ export default function AuthCardLayout({
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
@@ -34,8 +37,12 @@ export default function AuthCardLayout({
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="rounded-md shadow-sm">
|
||||
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t(title ?? '')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(description ?? '')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-10 py-8">
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
@@ -8,8 +10,11 @@ export default function AuthSimpleLayout({
|
||||
title,
|
||||
description,
|
||||
}: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div className="relative flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<LanguageSwitcher className="absolute end-4 top-4" />
|
||||
<div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
@@ -20,13 +25,15 @@ export default function AuthSimpleLayout({
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<AppLogoIcon className="size-6 fill-current" />
|
||||
</div>
|
||||
<span className="sr-only">{title}</span>
|
||||
<span className="sr-only">{t(title ?? '')}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t(title ?? '')}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{description}
|
||||
{t(description ?? '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
@@ -9,16 +10,17 @@ export default function AuthSplitLayout({
|
||||
description,
|
||||
}: AuthLayoutProps) {
|
||||
const { name } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-e">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<Link
|
||||
href={home()}
|
||||
className="relative z-20 flex items-center text-lg font-medium"
|
||||
>
|
||||
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
|
||||
<AppLogoIcon className="me-2 size-8 fill-current text-white" />
|
||||
{name}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -30,10 +32,12 @@ export default function AuthSplitLayout({
|
||||
>
|
||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<div className="flex flex-col items-start gap-2 text-start sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">
|
||||
{t(title ?? '')}
|
||||
</h1>
|
||||
<p className="text-sm text-balance text-muted-foreground">
|
||||
{description}
|
||||
{t(description ?? '')}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn, toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit } from '@/routes/profile';
|
||||
@@ -31,6 +32,7 @@ const sidebarNavItems: NavItem[] = [
|
||||
|
||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
@@ -42,8 +44,8 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<aside className="w-full max-w-xl lg:w-48">
|
||||
<nav
|
||||
className="flex flex-col space-y-1 space-x-0"
|
||||
aria-label="Settings"
|
||||
className="flex flex-col gap-1"
|
||||
aria-label={t('Settings')}
|
||||
>
|
||||
{sidebarNavItems.map((item, index) => (
|
||||
<Button
|
||||
|
||||
17
resources/js/lib/localization.ts
Normal file
17
resources/js/lib/localization.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Localization, LocaleDirection } from '@/types';
|
||||
|
||||
type DocumentLocalization = {
|
||||
direction: LocaleDirection;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export function applyDocumentLocalization(
|
||||
localization?: DocumentLocalization | Localization | null,
|
||||
): void {
|
||||
if (!localization || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = localization.locale.replaceAll('_', '-');
|
||||
document.documentElement.dir = localization.direction;
|
||||
}
|
||||
99
resources/js/lib/translations.tsx
Normal file
99
resources/js/lib/translations.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { 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 {
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child) => translateChildren(child, translate));
|
||||
}
|
||||
|
||||
if (typeof children === 'string') {
|
||||
return translateTextNode(children, translate);
|
||||
}
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const element = children as ReactElement<{ children?: ReactNode }>;
|
||||
|
||||
if (!element.props.children) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return cloneElement(
|
||||
element,
|
||||
undefined,
|
||||
translateChildren(element.props.children, translate),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation(): {
|
||||
locale: string;
|
||||
t: Translate;
|
||||
translateNode: (children: ReactNode) => ReactNode;
|
||||
} {
|
||||
const { locale, translations } = usePage().props;
|
||||
|
||||
const t = useCallback<Translate>(
|
||||
(key, replacements = {}) =>
|
||||
translateString(translations, key, replacements),
|
||||
[translations],
|
||||
);
|
||||
|
||||
const translateNode = useCallback(
|
||||
(children: ReactNode) => translateChildren(children, t),
|
||||
[t],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
t,
|
||||
translateNode,
|
||||
}),
|
||||
[locale, t, translateNode],
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslatedChildren(children: ReactNode): ReactNode {
|
||||
const { translateNode } = useTranslation();
|
||||
|
||||
return translateNode(children);
|
||||
}
|
||||
|
||||
function translateTextNode(value: string, translate: Translate): string {
|
||||
const text = value.trim();
|
||||
|
||||
if (text === '' || !/[A-Za-z]/.test(text)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
|
||||
const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
|
||||
|
||||
return `${leadingWhitespace}${translate(text)}${trailingWhitespace}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
|
||||
import { restore, suspend } from '@/routes/admin/channels';
|
||||
@@ -104,6 +105,7 @@ export default function AdminDashboard({
|
||||
users,
|
||||
}: Props) {
|
||||
const [action, setAction] = useState<ModerationAction>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
function setChannelCreationDefault(channelCreationOpen: boolean) {
|
||||
router.patch(
|
||||
@@ -153,7 +155,7 @@ export default function AdminDashboard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Admin" />
|
||||
<Head title={t('Admin')} />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -162,16 +164,19 @@ export default function AdminDashboard({
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Moderation console
|
||||
{t('Moderation console')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review live streams, suspended states, users,
|
||||
and recordings.
|
||||
{t(
|
||||
'Review live streams, suspended states, users, and recordings.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
{liveBroadcasts.length} active broadcasts
|
||||
{t(':count active broadcasts', {
|
||||
count: liveBroadcasts.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,10 +207,12 @@ export default function AdminDashboard({
|
||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
Channel creation access
|
||||
{t('Channel creation access')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control who can create a creator channel.
|
||||
{t(
|
||||
'Control who can create a creator channel.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,10 +266,14 @@ export default function AdminDashboard({
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="text-live size-5" />
|
||||
<h2 className="font-semibold">Live broadcasts</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Live broadcasts')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{liveBroadcasts.length} rows
|
||||
{t(':count rows', {
|
||||
count: liveBroadcasts.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -277,12 +288,15 @@ export default function AdminDashboard({
|
||||
{broadcast.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{broadcast.channel.display_name} by{' '}
|
||||
{broadcast.channel.owner}
|
||||
{t(':channel by :owner', {
|
||||
channel:
|
||||
broadcast.channel.display_name,
|
||||
owner: broadcast.channel.owner,
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-live mt-2 inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -319,9 +333,13 @@ export default function AdminDashboard({
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">Recent channels</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Recent channels')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{channels.length} rows
|
||||
{t(':count rows', {
|
||||
count: channels.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -336,18 +354,20 @@ export default function AdminDashboard({
|
||||
{channel.display_name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@{channel.slug} owned by{' '}
|
||||
{channel.owner.name}
|
||||
{t('@:slug owned by :owner', {
|
||||
slug: channel.slug,
|
||||
owner: channel.owner.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{channel.is_live && (
|
||||
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</span>
|
||||
)}
|
||||
{channel.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -400,9 +420,9 @@ export default function AdminDashboard({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle(action)}</DialogTitle>
|
||||
<DialogTitle>{t(dialogTitle(action))}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dialogDescription(action)}
|
||||
{t(dialogDescription(action))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -449,10 +469,12 @@ function Stat({
|
||||
value: number;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span>{t(label)}</span>
|
||||
<Icon className={live ? 'text-live size-4' : 'size-4'} />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{value}</div>
|
||||
@@ -470,6 +492,7 @@ function CreatorAccessRow({
|
||||
onChange: (canCreateChannel: boolean) => void;
|
||||
}) {
|
||||
const effectiveAccess = defaultOpen || user.can_create_channel;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
@@ -478,12 +501,12 @@ function CreatorAccessRow({
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
{user.is_admin && (
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
ADMIN
|
||||
{t('ADMIN')}
|
||||
</span>
|
||||
)}
|
||||
{user.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
@@ -493,11 +516,13 @@ function CreatorAccessRow({
|
||||
: 'border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-0.5 text-xs font-medium'
|
||||
}
|
||||
>
|
||||
{effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'}
|
||||
{effectiveAccess
|
||||
? t('CAN CREATE')
|
||||
: t('NEEDS APPROVAL')}
|
||||
</span>
|
||||
{defaultOpen && !user.can_create_channel && (
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
DEFAULT
|
||||
{t('DEFAULT')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -563,9 +588,11 @@ function Avatar({ channel }: { channel: AdminChannel }) {
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||
{text}
|
||||
{t(text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Inbox, MessageSquare, Shield, User } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
index as adminSupportIndex,
|
||||
@@ -16,9 +17,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Support inbox" />
|
||||
<Head title={t('Support inbox')} />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -27,11 +30,12 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Support inbox
|
||||
{t('Support inbox')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contact threads from users and channel request
|
||||
notes.
|
||||
{t(
|
||||
'Contact threads from users and channel request notes.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,10 +49,14 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">Conversations</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Conversations')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{conversations.length} rows
|
||||
{t(':count rows', {
|
||||
count: conversations.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -96,7 +104,7 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||
No support conversations found.
|
||||
{t('No support conversations found.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -116,15 +124,19 @@ AdminSupportIndex.layout = {
|
||||
};
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>{' '}
|
||||
<span className="text-muted-foreground">{t(label)}</span>{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -134,7 +146,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ type Props = {
|
||||
|
||||
export default function AdminSupportShow({ conversation }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
@@ -84,7 +86,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
>
|
||||
<Link href={adminSupportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Support inbox
|
||||
{t('Support inbox')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
@@ -111,7 +113,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
) : (
|
||||
<Lock className="size-4" />
|
||||
)}
|
||||
{isClosed ? 'Reopen' : 'Close'}
|
||||
{isClosed ? t('Reopen') : t('Close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +128,9 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Reopen the conversation to reply.</span>
|
||||
<span>
|
||||
{t('Reopen the conversation to reply.')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -197,7 +201,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">User</h2>
|
||||
<h2 className="font-semibold">{t('User')}</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
@@ -218,12 +222,12 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
)}
|
||||
>
|
||||
{conversation.user?.can_create_channel
|
||||
? 'CAN CREATE CHANNEL'
|
||||
: 'NEEDS CHANNEL GRANT'}
|
||||
? t('CAN CREATE CHANNEL')
|
||||
: t('NEEDS CHANNEL GRANT')}
|
||||
</span>
|
||||
{conversation.user?.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,12 +250,13 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
Channel request
|
||||
{t('Channel request')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Creator access grants stay in the moderation
|
||||
console.
|
||||
{t(
|
||||
'Creator access grants stay in the moderation console.',
|
||||
)}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={adminDashboard()}>
|
||||
@@ -277,6 +282,7 @@ AdminSupportShow.layout = {
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -288,7 +294,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'USER'}
|
||||
{isAdmin ? t('ADMIN') : t('USER')}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -345,6 +351,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -354,7 +362,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Confirm password" />
|
||||
<Head title={t('Confirm password')} />
|
||||
|
||||
<Form {...store.form()} resetOnSuccess={['password']}>
|
||||
{({ processing, errors }) => (
|
||||
@@ -19,7 +22,7 @@ export default function ConfirmPassword() {
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,16 @@ import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { login } from '@/routes';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Forgot password" />
|
||||
<Head title={t('Forgot password')} />
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
@@ -32,7 +35,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
@@ -54,9 +57,9 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>log in</TextLink>
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground rtl:space-x-reverse">
|
||||
<span>{t('Or, return to')}</span>
|
||||
<TextLink href={login()}>{t('log in')}</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { register } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
@@ -22,9 +23,11 @@ export default function Login({
|
||||
canResetPassword,
|
||||
canRegister,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
<Head title={t('Log in')} />
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
@@ -44,7 +47,7 @@ export default function Login({
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
@@ -55,10 +58,10 @@ export default function Login({
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
className="ms-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot password?
|
||||
{t('Forgot password?')}
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -68,12 +71,12 @@ export default function Login({
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
@@ -96,9 +99,9 @@ export default function Login({
|
||||
|
||||
{canRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
{t("Don't have an account?")}{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
{t('Sign up')}
|
||||
</TextLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { login } from '@/routes';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
@@ -14,9 +15,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function Register({ passwordRules }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Register" />
|
||||
<Head title={t('Register')} />
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
@@ -36,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
placeholder={t('Full name')}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
@@ -53,7 +56,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
@@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
@@ -82,7 +85,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
@@ -102,9 +105,9 @@ export default function Register({ passwordRules }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
{t('Already have an account?')}{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
Log in
|
||||
{t('Log in')}
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { update } from '@/routes/password';
|
||||
|
||||
type Props = {
|
||||
@@ -14,9 +15,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Reset password" />
|
||||
<Head title={t('Reset password')} />
|
||||
|
||||
<Form
|
||||
{...update.form()}
|
||||
@@ -50,7 +53,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
@@ -65,7 +68,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const { t } = useTranslation();
|
||||
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
|
||||
@@ -23,20 +25,22 @@ export default function TwoFactorChallenge() {
|
||||
}>(() => {
|
||||
if (showRecoveryInput) {
|
||||
return {
|
||||
title: 'Recovery code',
|
||||
description:
|
||||
title: t('Recovery code'),
|
||||
description: t(
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
toggleText: 'login using an authentication code',
|
||||
),
|
||||
toggleText: t('login using an authentication code'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
title: t('Authentication code'),
|
||||
description: t(
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
toggleText: 'login using a recovery code',
|
||||
),
|
||||
toggleText: t('login using a recovery code'),
|
||||
};
|
||||
}, [showRecoveryInput]);
|
||||
}, [showRecoveryInput, t]);
|
||||
|
||||
setLayoutProps({
|
||||
title: authConfigContent.title,
|
||||
@@ -51,7 +55,7 @@ export default function TwoFactorChallenge() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Two-factor authentication" />
|
||||
<Head title={t('Two-factor authentication')} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form
|
||||
@@ -67,7 +71,7 @@ export default function TwoFactorChallenge() {
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
placeholder={t('Enter recovery code')}
|
||||
autoFocus={showRecoveryInput}
|
||||
required
|
||||
/>
|
||||
@@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<span>{t('or you can')} </span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
|
||||
@@ -3,18 +3,22 @@ import { Form, Head } from '@inertiajs/react';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { logout } from '@/routes';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Email verification" />
|
||||
<Head title={t('Email verification')} />
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to the email address
|
||||
you provided during registration.
|
||||
{t(
|
||||
'A new verification link has been sent to the email address you provided during registration.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,7 +34,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
|
||||
href={logout()}
|
||||
className="mx-auto block text-sm"
|
||||
>
|
||||
Log out
|
||||
{t('Log out')}
|
||||
</TextLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { VideoPlayer } from '@/components/video-player';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { home, login } from '@/routes';
|
||||
import { follow, unfollow } from '@/routes/channels';
|
||||
@@ -84,6 +85,7 @@ export default function ChannelShow({
|
||||
isFollowing,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const chatForm = useForm({ body: '' });
|
||||
const currentViewers = viewerStats.current;
|
||||
@@ -160,10 +162,14 @@ export default function ChannelShow({
|
||||
<span>{channel.display_name}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-4" />
|
||||
{currentViewers} viewers
|
||||
{t(':count viewers', {
|
||||
count: currentViewers,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{channel.followers_count} followers
|
||||
{t(':count followers', {
|
||||
count: channel.followers_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{channel.description && (
|
||||
@@ -185,7 +191,7 @@ export default function ChannelShow({
|
||||
className="xl:hidden"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Chat
|
||||
{t('Chat')}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
@@ -193,7 +199,9 @@ export default function ChannelShow({
|
||||
className="h-[82svh] rounded-t-md"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Live chat</SheetTitle>
|
||||
<SheetTitle>
|
||||
{t('Live chat')}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ChatPanel
|
||||
channel={channel}
|
||||
@@ -223,13 +231,15 @@ export default function ChannelShow({
|
||||
'fill-current text-primary',
|
||||
)}
|
||||
/>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
{isFollowing
|
||||
? t('Following')
|
||||
: t('Follow')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={login()}>
|
||||
<LogIn className="size-4" />
|
||||
Log in to follow
|
||||
{t('Log in to follow')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -241,11 +251,13 @@ export default function ChannelShow({
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
Recent VODs
|
||||
{t('Recent VODs')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vods.length} available
|
||||
{t(':count available', {
|
||||
count: vods.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{vods.length > 0 ? (
|
||||
@@ -256,14 +268,14 @@ export default function ChannelShow({
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-card p-6 text-sm text-muted-foreground">
|
||||
No public recordings yet.
|
||||
{t('No public recordings yet.')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside className="hidden min-h-[480px] border-l bg-card xl:flex">
|
||||
<aside className="hidden min-h-[480px] border-s bg-card xl:flex">
|
||||
<ChatPanel
|
||||
channel={channel}
|
||||
authUser={Boolean(auth.user)}
|
||||
@@ -288,6 +300,8 @@ ChannelShow.layout = {
|
||||
};
|
||||
|
||||
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
|
||||
{channel.banner_url ? (
|
||||
@@ -304,11 +318,14 @@ function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-xl font-semibold">
|
||||
{channel.display_name} is offline
|
||||
{t(':channel is offline', {
|
||||
channel: channel.display_name,
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
Recent recordings remain available below when the
|
||||
creator publishes VODs.
|
||||
{t(
|
||||
'Recent recordings remain available below when the creator publishes VODs.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,6 +351,7 @@ function ChatPanel({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const getInitials = useInitials();
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
@@ -383,9 +401,11 @@ function ChatPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="size-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Live chat</h2>
|
||||
<h2 className="font-semibold">{t('Live chat')}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessageCount(messages.length)}
|
||||
{t(':count messages', {
|
||||
count: messages.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,13 +458,13 @@ function ChatPanel({
|
||||
)}
|
||||
>
|
||||
{isOwnMessage
|
||||
? 'You'
|
||||
? t('You')
|
||||
: message.user.name}
|
||||
</span>
|
||||
{isChannelOwner && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
<Crown className="size-3" />
|
||||
Owner
|
||||
{t('Owner')}
|
||||
</span>
|
||||
)}
|
||||
{message.created_at && (
|
||||
@@ -454,7 +474,7 @@ function ChatPanel({
|
||||
message.created_at,
|
||||
)}
|
||||
className={cn(
|
||||
'ml-auto shrink-0 text-[11px] text-muted-foreground',
|
||||
'ms-auto shrink-0 text-[11px] text-muted-foreground',
|
||||
isChannelOwner &&
|
||||
'text-primary/80',
|
||||
)}
|
||||
@@ -484,8 +504,10 @@ function ChatPanel({
|
||||
<MessageSquare className="size-5 text-primary" />
|
||||
<span>
|
||||
{channel.is_live
|
||||
? 'No messages yet.'
|
||||
: 'Chat appears when the channel is live.'}
|
||||
? t('No messages yet.')
|
||||
: t(
|
||||
'Chat appears when the channel is live.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -504,8 +526,8 @@ function ChatPanel({
|
||||
}
|
||||
placeholder={
|
||||
channel.is_live
|
||||
? 'Send a message'
|
||||
: 'Channel is offline'
|
||||
? t('Send a message')
|
||||
: t('Channel is offline')
|
||||
}
|
||||
disabled={!channel.is_live || form.processing}
|
||||
maxLength={500}
|
||||
@@ -518,7 +540,7 @@ function ChatPanel({
|
||||
disabled={
|
||||
!channel.is_live || form.processing
|
||||
}
|
||||
aria-label="Open emoji picker"
|
||||
aria-label={t('Open emoji picker')}
|
||||
aria-expanded={emojiPickerOpen}
|
||||
onClick={() =>
|
||||
setEmojiPickerOpen((open) => !open)
|
||||
@@ -527,12 +549,14 @@ function ChatPanel({
|
||||
<SmilePlus className="size-4" />
|
||||
</Button>
|
||||
{emojiPickerOpen && channel.is_live && (
|
||||
<div className="absolute right-0 bottom-full z-10 mb-2 grid w-52 grid-cols-4 gap-1 rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
|
||||
<div className="absolute end-0 bottom-full z-10 mb-2 grid w-52 grid-cols-4 gap-1 rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
|
||||
{chatEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
aria-label={`Insert ${emoji}`}
|
||||
aria-label={t('Insert :emoji', {
|
||||
emoji,
|
||||
})}
|
||||
className="flex size-10 items-center justify-center rounded-md text-lg hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
onClick={() =>
|
||||
insertEmoji(emoji)
|
||||
@@ -548,7 +572,7 @@ function ChatPanel({
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
aria-label={t('Send message')}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
@@ -562,7 +586,7 @@ function ChatPanel({
|
||||
>
|
||||
{form.errors.body ??
|
||||
(!channel.is_live
|
||||
? 'Channel is offline'
|
||||
? t('Channel is offline')
|
||||
: '')}
|
||||
</span>
|
||||
<span
|
||||
@@ -577,7 +601,7 @@ function ChatPanel({
|
||||
</form>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={login()}>Log in to chat</Link>
|
||||
<Link href={login()}>{t('Log in to chat')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -585,10 +609,6 @@ function ChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return count === 1 ? '1 message' : `${count} messages`;
|
||||
}
|
||||
|
||||
function formatChatTime(value: string): string {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
@@ -601,6 +621,8 @@ function formatChatDateTime(value: string): string {
|
||||
}
|
||||
|
||||
function VodCard({ vod }: { vod: VodSummary }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border bg-card">
|
||||
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
|
||||
@@ -610,19 +632,20 @@ function VodCard({ vod }: { vod: VodSummary }) {
|
||||
<div>
|
||||
<div className="line-clamp-2 font-medium">{vod.title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Expires{' '}
|
||||
{vod.expires_at
|
||||
? new Date(vod.expires_at).toLocaleDateString()
|
||||
: 'later'}
|
||||
{t('Expires :date', {
|
||||
date: vod.expires_at
|
||||
? new Date(vod.expires_at).toLocaleDateString()
|
||||
: t('later'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{vod.playback_url ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={vod.playback_url}>Watch VOD</a>
|
||||
<a href={vod.playback_url}>{t('Watch VOD')}</a>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Processing recording
|
||||
{t('Processing recording')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -637,6 +660,8 @@ function LiveState({
|
||||
channel: ChannelDetail;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (channel.is_live) {
|
||||
return (
|
||||
<span
|
||||
@@ -646,14 +671,14 @@ function LiveState({
|
||||
)}
|
||||
>
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||
Offline
|
||||
{t('Offline')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useClipboard } from '@/hooks/use-clipboard';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard as dashboardRoute } from '@/routes';
|
||||
import { show as showChannel } from '@/routes/channels';
|
||||
@@ -75,6 +76,7 @@ export default function Dashboard({
|
||||
recentBroadcasts,
|
||||
}: Props) {
|
||||
const [copiedText, copy] = useClipboard();
|
||||
const { t } = useTranslation();
|
||||
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
|
||||
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
|
||||
string | null
|
||||
@@ -155,7 +157,7 @@ export default function Dashboard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Creator Studio" />
|
||||
<Head title={t('Creator Studio')} />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
@@ -168,16 +170,17 @@ export default function Dashboard({
|
||||
/>
|
||||
{pendingBroadcast && !liveBroadcast && (
|
||||
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
|
||||
READY
|
||||
{t('READY')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
{channel?.display_name ?? 'Creator Studio'}
|
||||
{channel?.display_name ?? t('Creator Studio')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prepare broadcasts, secure your stream key, and
|
||||
monitor the current session.
|
||||
{t(
|
||||
'Prepare broadcasts, secure your stream key, and monitor the current session.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +217,7 @@ export default function Dashboard({
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Nyone Live"
|
||||
placeholder={t('Nyone Live')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -229,7 +232,7 @@ export default function Dashboard({
|
||||
event.target.value.toLowerCase(),
|
||||
)
|
||||
}
|
||||
placeholder="nyone-live"
|
||||
placeholder={t('nyone-live')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
@@ -284,9 +287,9 @@ export default function Dashboard({
|
||||
<div className="border-warning/40 bg-warning/10 text-warning flex items-start gap-3 rounded-md border p-3 text-sm">
|
||||
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
This account can browse and follow
|
||||
channels, but cannot create a channel
|
||||
yet.
|
||||
{t(
|
||||
'This account can browse and follow channels, but cannot create a channel yet.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -306,10 +309,10 @@ export default function Dashboard({
|
||||
label="Broadcast state"
|
||||
value={
|
||||
channel.is_live
|
||||
? 'Live'
|
||||
? t('Live')
|
||||
: pendingBroadcast
|
||||
? 'Ready'
|
||||
: 'Offline'
|
||||
? t('Ready')
|
||||
: t('Offline')
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
@@ -323,7 +326,7 @@ export default function Dashboard({
|
||||
? new Date(
|
||||
channel.stream_key_last_used_at,
|
||||
).toLocaleDateString()
|
||||
: 'Never'
|
||||
: t('Never')
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
@@ -348,7 +351,9 @@ export default function Dashboard({
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Building Nyone live"
|
||||
placeholder={t(
|
||||
'Building Nyone live',
|
||||
)}
|
||||
disabled={
|
||||
Boolean(liveBroadcast) ||
|
||||
Boolean(pendingBroadcast)
|
||||
@@ -373,7 +378,7 @@ export default function Dashboard({
|
||||
Boolean(pendingBroadcast)
|
||||
}
|
||||
/>
|
||||
Record VOD
|
||||
{t('Record VOD')}
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -390,8 +395,9 @@ export default function Dashboard({
|
||||
</form>
|
||||
{(liveBroadcast ?? pendingBroadcast) && (
|
||||
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
|
||||
Finish the current broadcast before
|
||||
preparing another session.
|
||||
{t(
|
||||
'Finish the current broadcast before preparing another session.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -466,7 +472,7 @@ export default function Dashboard({
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-xs text-muted-foreground">
|
||||
No thumbnail
|
||||
{t('No thumbnail')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -486,8 +492,9 @@ export default function Dashboard({
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ImagePlus className="size-3.5" />
|
||||
<span>
|
||||
Use a 16:9 JPG, PNG, or
|
||||
WebP up to 4 MB.
|
||||
{t(
|
||||
'Use a 16:9 JPG, PNG, or WebP up to 4 MB.',
|
||||
)}
|
||||
</span>
|
||||
{channelForm.data
|
||||
.thumbnail && (
|
||||
@@ -584,7 +591,9 @@ export default function Dashboard({
|
||||
label="Stream key"
|
||||
value={
|
||||
streaming.tokenizedPath ??
|
||||
'Rotate the key to reveal it once.'
|
||||
t(
|
||||
'Rotate the key to reveal it once.',
|
||||
)
|
||||
}
|
||||
copied={
|
||||
copiedText ===
|
||||
@@ -595,8 +604,9 @@ export default function Dashboard({
|
||||
/>
|
||||
{plainStreamKey && (
|
||||
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
|
||||
This key is shown once. Store it in
|
||||
OBS before leaving this page.
|
||||
{t(
|
||||
'This key is shown once. Store it in OBS before leaving this page.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
@@ -624,8 +634,8 @@ export default function Dashboard({
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{liveBroadcast.recording_enabled
|
||||
? 'Recording enabled'
|
||||
: 'Live only'}
|
||||
? t('Recording enabled')
|
||||
: t('Live only')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -647,8 +657,9 @@ export default function Dashboard({
|
||||
{pendingBroadcast.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Start OBS to push the session
|
||||
live.
|
||||
{t(
|
||||
'Start OBS to push the session live.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -667,10 +678,11 @@ export default function Dashboard({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stop broadcast?</DialogTitle>
|
||||
<DialogTitle>{t('Stop broadcast?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This ends the live session and clears the channel's
|
||||
live state. Viewers will see the offline screen.
|
||||
{t(
|
||||
"This ends the live session and clears the channel's live state. Viewers will see the offline screen.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -709,13 +721,15 @@ function Panel({
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="min-w-0 rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<h2 className="font-semibold">{t(title)}</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
{t(description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -753,13 +767,15 @@ function CategorySelect({
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
<option value="">{t('No category')}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
@@ -782,10 +798,12 @@ function CopyRow({
|
||||
muted?: boolean;
|
||||
onCopy: (text: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{label}
|
||||
{t(label)}
|
||||
</div>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<code
|
||||
@@ -815,9 +833,11 @@ function CopyRow({
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">{t(label)}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -831,15 +851,24 @@ function BroadcastRow({
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const active = broadcast.status !== 'ended';
|
||||
const { t } = useTranslation();
|
||||
const status = t(broadcast.status ?? 'unknown');
|
||||
const mode = broadcast.recording_enabled ? t('recording') : t('live only');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-background p-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{broadcast.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{broadcast.status ?? 'unknown'} -{' '}
|
||||
{broadcast.recording_enabled ? 'recording' : 'live only'}
|
||||
{broadcast.has_vod ? ' - VOD ready' : ''}
|
||||
{broadcast.has_vod
|
||||
? t(':status - :mode - VOD ready', {
|
||||
status,
|
||||
mode,
|
||||
})
|
||||
: t(':status - :mode', {
|
||||
status,
|
||||
mode,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
@@ -861,11 +890,13 @@ function StatusPill({
|
||||
ready?: boolean;
|
||||
viewers?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (live) {
|
||||
return (
|
||||
<span className="bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE - {viewers} viewers
|
||||
{t('LIVE - :count viewers', { count: viewers })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -873,14 +904,14 @@ function StatusPill({
|
||||
if (ready) {
|
||||
return (
|
||||
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
|
||||
READY
|
||||
{t('READY')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||
OFFLINE
|
||||
{t('OFFLINE')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -908,6 +939,8 @@ function ReadinessRow({
|
||||
done?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
|
||||
<span
|
||||
@@ -918,15 +951,17 @@ function ReadinessRow({
|
||||
>
|
||||
{done && <Check className="size-3" />}
|
||||
</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className="text-sm">{t(label)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
|
||||
{text}
|
||||
{t(text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import Heading from '@/components/heading';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
|
||||
export default function Appearance() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Appearance settings" />
|
||||
<Head title={t('Appearance settings')} />
|
||||
|
||||
<h1 className="sr-only">Appearance settings</h1>
|
||||
<h1 className="sr-only">{t('Appearance settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
@@ -17,6 +21,7 @@ export default function Appearance() {
|
||||
description="Update your account's appearance settings"
|
||||
/>
|
||||
<AppearanceTabs />
|
||||
<LanguageSwitcher variant="settings" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function Profile({
|
||||
status?: string;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const user = auth.user;
|
||||
const getInitials = useInitials();
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -52,9 +54,9 @@ export default function Profile({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Profile settings" />
|
||||
<Head title={t('Profile settings')} />
|
||||
|
||||
<h1 className="sr-only">Profile settings</h1>
|
||||
<h1 className="sr-only">{t('Profile settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
@@ -114,7 +116,7 @@ export default function Profile({
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ImagePlus className="size-3.5" />
|
||||
<span>
|
||||
JPG, PNG, or WebP up to 2 MB.
|
||||
{t('JPG, PNG, or WebP up to 2 MB.')}
|
||||
</span>
|
||||
</div>
|
||||
{progress && (
|
||||
@@ -138,7 +140,7 @@ export default function Profile({
|
||||
name="name"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
placeholder={t('Full name')}
|
||||
/>
|
||||
|
||||
<InputError
|
||||
@@ -158,7 +160,7 @@ export default function Profile({
|
||||
name="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
placeholder={t('Email address')}
|
||||
/>
|
||||
|
||||
<InputError
|
||||
@@ -171,22 +173,26 @@ export default function Profile({
|
||||
user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">
|
||||
Your email address is unverified.{' '}
|
||||
{t(
|
||||
'Your email address is unverified.',
|
||||
)}{' '}
|
||||
<Link
|
||||
href={send()}
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the
|
||||
verification email.
|
||||
{t(
|
||||
'Click here to resend the verification email.',
|
||||
)}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status ===
|
||||
'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">
|
||||
A new verification link has been
|
||||
sent to your email address.
|
||||
{t(
|
||||
'A new verification link has been sent to your email address.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit } from '@/routes/security';
|
||||
import { disable, enable } from '@/routes/two-factor';
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function Security({
|
||||
twoFactorEnabled = false,
|
||||
passwordRules,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -53,9 +55,9 @@ export default function Security({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Security settings" />
|
||||
<Head title={t('Security settings')} />
|
||||
|
||||
<h1 className="sr-only">Security settings</h1>
|
||||
<h1 className="sr-only">{t('Security settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
@@ -99,7 +101,7 @@ export default function Security({
|
||||
name="current_password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
placeholder={t('Current password')}
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
@@ -114,7 +116,7 @@ export default function Security({
|
||||
name="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
placeholder={t('New password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
|
||||
@@ -131,7 +133,7 @@ export default function Security({
|
||||
name="password_confirmation"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
|
||||
@@ -163,9 +165,9 @@ export default function Security({
|
||||
{twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will be prompted for a secure, random pin
|
||||
during login, which you can retrieve from the
|
||||
TOTP-supported application on your phone.
|
||||
{t(
|
||||
'You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="relative inline">
|
||||
@@ -191,10 +193,9 @@ export default function Security({
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When you enable two-factor authentication, you
|
||||
will be prompted for a secure pin during login.
|
||||
This pin can be retrieved from a TOTP-supported
|
||||
application on your phone.
|
||||
{t(
|
||||
'When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
index as supportIndex,
|
||||
@@ -24,6 +25,7 @@ type Props = {
|
||||
|
||||
export default function SupportIndex({ categories, conversations }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
category: categories[0]?.value ?? 'bug',
|
||||
subject: '',
|
||||
@@ -53,7 +55,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Contact admin" />
|
||||
<Head title={t('Contact admin')} />
|
||||
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="grid content-start gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
@@ -63,16 +65,19 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Contact admin
|
||||
{t('Contact admin')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bug reports, suggestions, channel requests,
|
||||
and account questions.
|
||||
{t(
|
||||
'Bug reports, suggestions, channel requests, and account questions.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
{conversations.length} threads
|
||||
{t(':count threads', {
|
||||
count: conversations.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +119,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="grid min-h-64 place-items-center rounded-md border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
No conversations yet.
|
||||
{t('No conversations yet.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,9 +127,11 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold">New conversation</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('New conversation')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attach up to 3 files, 200 MB each.
|
||||
{t('Attach up to 3 files, 200 MB each.')}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -237,6 +244,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -246,7 +255,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { index as supportIndex } from '@/routes/support';
|
||||
import { show as showAttachment } from '@/routes/support/attachments';
|
||||
@@ -22,6 +23,7 @@ type Props = {
|
||||
|
||||
export default function SupportShow({ conversation }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
@@ -56,7 +58,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||
<Link href={supportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Contact admin
|
||||
{t('Contact admin')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
@@ -72,7 +74,9 @@ export default function SupportShow({ conversation }: Props) {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground">
|
||||
{conversation.messages_count} messages
|
||||
{t(':count messages', {
|
||||
count: conversation.messages_count,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +91,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>This conversation is closed.</span>
|
||||
<span>{t('This conversation is closed.')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -161,6 +165,7 @@ SupportShow.layout = {
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -172,7 +177,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'YOU'}
|
||||
{isAdmin ? t('ADMIN') : t('YOU')}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -229,6 +234,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -238,7 +245,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard, home, login, register } from '@/routes';
|
||||
import { show as showChannel } from '@/routes/channels';
|
||||
@@ -31,6 +33,7 @@ export default function Welcome({
|
||||
categories,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<string>('all');
|
||||
|
||||
@@ -55,7 +58,7 @@ export default function Welcome({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Live" />
|
||||
<Head title={t('Live')} />
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4">
|
||||
@@ -74,7 +77,7 @@ export default function Welcome({
|
||||
href={home()}
|
||||
className="rounded-md px-3 py-2 text-foreground"
|
||||
>
|
||||
Live
|
||||
{t('Live')}
|
||||
</Link>
|
||||
{auth.user && (
|
||||
<>
|
||||
@@ -82,44 +85,47 @@ export default function Welcome({
|
||||
href={dashboard()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Studio
|
||||
{t('Studio')}
|
||||
</Link>
|
||||
<Link
|
||||
href={supportIndex()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Contact
|
||||
{t('Contact admin')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{auth.user ? (
|
||||
<>
|
||||
<Button asChild size="sm">
|
||||
<Link href={dashboard()}>
|
||||
<LayoutDashboard className="size-4" />
|
||||
Studio
|
||||
{t('Studio')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={supportIndex()}>
|
||||
<LifeBuoy className="size-4" />
|
||||
Contact
|
||||
{t('Contact admin')}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={login()}>Log in</Link>
|
||||
<Link href={login()}>
|
||||
{t('Log in')}
|
||||
</Link>
|
||||
</Button>
|
||||
{canRegister && (
|
||||
<Button asChild size="sm">
|
||||
<Link href={register()}>
|
||||
<UserPlus className="size-4" />
|
||||
Register
|
||||
{t('Register')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -140,7 +146,7 @@ export default function Welcome({
|
||||
<Thumbnail
|
||||
channel={featuredChannel}
|
||||
className="aspect-video lg:aspect-auto lg:w-[58%] lg:flex-none"
|
||||
label="Featured live"
|
||||
label={t('Featured live')}
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1 content-between gap-6 p-5">
|
||||
<div className="min-w-0 space-y-4">
|
||||
@@ -152,7 +158,14 @@ export default function Welcome({
|
||||
</h1>
|
||||
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||
{featuredChannel.description ??
|
||||
`${featuredChannel.display_name} is live now with ${featuredChannel.viewer_count} viewers.`}
|
||||
t(
|
||||
':channel is live now with :count viewers.',
|
||||
{
|
||||
channel:
|
||||
featuredChannel.display_name,
|
||||
count: featuredChannel.viewer_count,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,18 +175,18 @@ export default function Welcome({
|
||||
</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{featuredChannel.category
|
||||
?.name ?? 'Uncategorized'}
|
||||
?.name ??
|
||||
t('Uncategorized')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-4" />
|
||||
{
|
||||
featuredChannel.viewer_count
|
||||
}{' '}
|
||||
viewers
|
||||
{t(':count viewers', {
|
||||
count: featuredChannel.viewer_count,
|
||||
})}
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
|
||||
Watch
|
||||
<ArrowRight className="size-4" />
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ms-auto">
|
||||
{t('Watch')}
|
||||
<ArrowRight className="size-4 rtl:rotate-180" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,11 +199,12 @@ export default function Welcome({
|
||||
</div>
|
||||
<div className="max-w-xl space-y-2">
|
||||
<h1 className="text-2xl font-semibold md:text-4xl">
|
||||
The live floor is quiet
|
||||
{t('The live floor is quiet')}
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground md:text-base">
|
||||
Creators can prepare a broadcast in the
|
||||
studio and go live from OBS when ready.
|
||||
{t(
|
||||
'Creators can prepare a broadcast in the studio and go live from OBS when ready.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,10 +214,10 @@ export default function Welcome({
|
||||
<div className="flex items-center justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
Live signal
|
||||
{t('Live signal')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Public channels broadcasting now
|
||||
{t('Public channels broadcasting now')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-live rounded-md px-2 py-1 text-xs font-medium text-white">
|
||||
@@ -212,12 +226,12 @@ export default function Welcome({
|
||||
</div>
|
||||
<Metric
|
||||
icon={Radio}
|
||||
label="Live channels"
|
||||
label={t('Live channels')}
|
||||
value={String(liveChannels.length)}
|
||||
/>
|
||||
<Metric
|
||||
icon={Users}
|
||||
label="Viewers"
|
||||
label={t('Viewers')}
|
||||
value={String(
|
||||
liveChannels.reduce(
|
||||
(total, item) =>
|
||||
@@ -228,7 +242,7 @@ export default function Welcome({
|
||||
/>
|
||||
<Metric
|
||||
icon={Compass}
|
||||
label="Categories"
|
||||
label={t('Categories')}
|
||||
value={String(categories.length)}
|
||||
/>
|
||||
<Button asChild variant="outline">
|
||||
@@ -236,8 +250,8 @@ export default function Welcome({
|
||||
href={auth.user ? dashboard() : register()}
|
||||
>
|
||||
{auth.user
|
||||
? 'Open studio'
|
||||
: 'Start a channel'}
|
||||
? t('Open studio')
|
||||
: t('Start a channel')}
|
||||
</Link>
|
||||
</Button>
|
||||
</aside>
|
||||
@@ -247,22 +261,24 @@ export default function Welcome({
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Live directory
|
||||
{t('Live directory')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse active channels by category or title.
|
||||
{t(
|
||||
'Browse active channels by category or title.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) =>
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
placeholder="Search live channels"
|
||||
className="pl-9 sm:w-72"
|
||||
placeholder={t('Search live channels')}
|
||||
className="ps-9 sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
@@ -272,7 +288,9 @@ export default function Welcome({
|
||||
}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
<option value="all">
|
||||
{t('All categories')}
|
||||
</option>
|
||||
{categories.map((item) => (
|
||||
<option key={item.id} value={item.slug}>
|
||||
{item.name}
|
||||
@@ -296,10 +314,12 @@ export default function Welcome({
|
||||
<Search className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
No streams match this view
|
||||
{t('No streams match this view')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Clear search or switch categories.
|
||||
{t(
|
||||
'Clear search or switch categories.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,6 +332,8 @@ export default function Welcome({
|
||||
}
|
||||
|
||||
function StreamCard({ channel }: { channel: ChannelCard }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={showChannel(channel.slug)}
|
||||
@@ -325,16 +347,21 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
|
||||
{channel.broadcast?.title ?? channel.display_name}
|
||||
</div>
|
||||
<div className="truncate text-sm text-muted-foreground">
|
||||
{channel.display_name} by {channel.owner.name}
|
||||
{t(':channel by :owner', {
|
||||
channel: channel.display_name,
|
||||
owner: channel.owner.name,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{channel.category?.name ?? 'Uncategorized'}
|
||||
{channel.category?.name ?? t('Uncategorized')}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{channel.viewer_count} viewers
|
||||
{t(':count viewers', {
|
||||
count: channel.viewer_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,6 +378,8 @@ function Thumbnail({
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -368,11 +397,11 @@ function Thumbnail({
|
||||
<div className="grid h-full w-full place-items-center bg-[radial-gradient(circle_at_40%_30%,oklch(0.34_0.12_326),oklch(0.14_0.04_315)_55%)]">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="bg-live live-pulse size-2 rounded-full" />
|
||||
{label}
|
||||
{t(label)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-3 left-3">
|
||||
<div className="absolute start-3 top-3">
|
||||
<LiveBadge compact />
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,6 +409,8 @@ function Thumbnail({
|
||||
}
|
||||
|
||||
function LiveBadge({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -388,7 +419,7 @@ function LiveBadge({ compact = false }: { compact?: boolean }) {
|
||||
)}
|
||||
>
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
5
resources/js/types/global.d.ts
vendored
5
resources/js/types/global.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import type { Auth } from '@/types/auth';
|
||||
import type { Localization, Translations } from '@/types/localization';
|
||||
|
||||
declare module 'react' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -12,7 +13,11 @@ declare module '@inertiajs/core' {
|
||||
sharedPageProps: {
|
||||
name: string;
|
||||
auth: Auth;
|
||||
fallbackLocale: string;
|
||||
locale: string;
|
||||
localization: Localization;
|
||||
sidebarOpen: boolean;
|
||||
translations: Translations;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type * from './auth';
|
||||
export type * from './localization';
|
||||
export type * from './navigation';
|
||||
export type * from './streaming';
|
||||
export type * from './support';
|
||||
|
||||
23
resources/js/types/localization.ts
Normal file
23
resources/js/types/localization.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type Translations = Record<string, string>;
|
||||
|
||||
export type TranslationReplacements = Record<
|
||||
string,
|
||||
boolean | null | number | string | undefined
|
||||
>;
|
||||
|
||||
export type LocaleDirection = 'ltr' | 'rtl';
|
||||
|
||||
export type LocaleOption = {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
direction: LocaleDirection;
|
||||
};
|
||||
|
||||
export type Localization = {
|
||||
locale: string;
|
||||
fallbackLocale: string;
|
||||
direction: LocaleDirection;
|
||||
isRtl: boolean;
|
||||
locales: LocaleOption[];
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
@php
|
||||
$locale = app()->getLocale();
|
||||
$direction = data_get(config("localization.locales.{$locale}"), 'direction', 'ltr');
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<html lang="{{ str_replace('_', '-', $locale) }}" dir="{{ $direction }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user