Implemented project-wide localization plumbing and converted the visible UI/app messages to translation-ready strings.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function AlertError({
|
||||
errors,
|
||||
@@ -8,10 +9,12 @@ export default function AlertError({
|
||||
errors: string[];
|
||||
title?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
|
||||
<AlertTitle>{t(title || 'Something went wrong.')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{Array.from(new Set(errors)).map((error, index) => (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard, home } from '@/routes';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
@@ -56,6 +57,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
const { auth } = page.props;
|
||||
const getInitials = useInitials();
|
||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Live',
|
||||
@@ -108,7 +110,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation menu
|
||||
{t('Navigation menu')}
|
||||
</SheetTitle>
|
||||
<SheetHeader className="flex justify-start text-left">
|
||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||
@@ -125,7 +127,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
{item.icon && (
|
||||
<item.icon className="h-5 w-5" />
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -168,7 +170,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
{item.icon && (
|
||||
<item.icon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
{isCurrentUrl(item.href) && (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
|
||||
@@ -199,13 +201,13 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Live directory
|
||||
{t('Live directory')}
|
||||
</span>
|
||||
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Live directory</p>
|
||||
<p>{t('Live directory')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { Appearance } from '@/hooks/use-appearance';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AppearanceToggleTab({
|
||||
@@ -10,6 +11,7 @@ export default function AppearanceToggleTab({
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
|
||||
{ value: 'light', icon: Sun, label: 'Light' },
|
||||
@@ -37,7 +39,7 @@ export default function AppearanceToggleTab({
|
||||
)}
|
||||
>
|
||||
<Icon className="-ml-1 h-4 w-4" />
|
||||
<span className="ml-1.5 text-sm">{label}</span>
|
||||
<span className="ml-1.5 text-sm">{t(label)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
export function Breadcrumbs({
|
||||
@@ -15,6 +16,8 @@ export function Breadcrumbs({
|
||||
}: {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs.length > 0 && (
|
||||
@@ -28,12 +31,12 @@ export function Breadcrumbs({
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={item.href}>
|
||||
{item.title}
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function DeleteUser() {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -28,9 +30,11 @@ export default function DeleteUser() {
|
||||
/>
|
||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
||||
<p className="font-medium">Warning</p>
|
||||
<p className="font-medium">{t('Warning')}</p>
|
||||
<p className="text-sm">
|
||||
Please proceed with caution, this cannot be undone.
|
||||
{t(
|
||||
'Please proceed with caution, this cannot be undone.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +49,12 @@ export default function DeleteUser() {
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Are you sure you want to delete your account?
|
||||
{t('Are you sure you want to delete your account?')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once your account is deleted, all of its resources
|
||||
and data will also be permanently deleted. Please
|
||||
enter your password to confirm you would like to
|
||||
permanently delete your account.
|
||||
{t(
|
||||
'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<Form
|
||||
@@ -77,7 +80,7 @@ export default function DeleteUser() {
|
||||
id="password"
|
||||
name="password"
|
||||
ref={passwordInput}
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export default function Heading({
|
||||
title,
|
||||
description,
|
||||
@@ -7,6 +9,8 @@ export default function Heading({
|
||||
description?: string;
|
||||
variant?: 'default' | 'small';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
|
||||
<h2
|
||||
@@ -16,10 +20,12 @@ export default function Heading({
|
||||
: 'text-xl font-semibold tracking-tight'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
{t(title)}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(description)}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t('Platform')}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrentUrl(item.href)}
|
||||
tooltip={{ children: item.title }}
|
||||
tooltip={{ children: t(item.title) }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Eye, EyeOff } from 'lucide-react';
|
||||
import type { ComponentProps, Ref } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function PasswordInput({
|
||||
@@ -10,6 +11,7 @@ export default function PasswordInput({
|
||||
...props
|
||||
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -23,7 +25,7 @@ export default function PasswordInput({
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-label={t(showPassword ? 'Hide password' : 'Show password')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
type Props = {
|
||||
@@ -26,6 +27,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
|
||||
const codesSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleCodesVisibility = useCallback(async () => {
|
||||
if (!codesAreVisible && !recoveryCodesList.length) {
|
||||
@@ -57,11 +59,12 @@ export default function TwoFactorRecoveryCodes({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-3">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
2FA recovery codes
|
||||
{t('2FA recovery codes')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recovery codes let you regain access if you lose your 2FA
|
||||
device. Store them in a secure password manager.
|
||||
{t(
|
||||
'Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -76,7 +79,9 @@ export default function TwoFactorRecoveryCodes({
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{codesAreVisible ? 'Hide' : 'View'} recovery codes
|
||||
{codesAreVisible
|
||||
? t('Hide recovery codes')
|
||||
: t('View recovery codes')}
|
||||
</Button>
|
||||
|
||||
{canRegenerateCodes && (
|
||||
@@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
disabled={processing}
|
||||
aria-describedby="regenerate-warning"
|
||||
>
|
||||
<RefreshCw /> Regenerate codes
|
||||
<RefreshCw /> {t('Regenerate codes')}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
@@ -112,7 +117,7 @@ export default function TwoFactorRecoveryCodes({
|
||||
ref={codesSectionRef}
|
||||
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||
role="list"
|
||||
aria-label="Recovery codes"
|
||||
aria-label={t('Recovery codes')}
|
||||
>
|
||||
{recoveryCodesList.length ? (
|
||||
recoveryCodesList.map((code, index) => (
|
||||
@@ -127,7 +132,9 @@ export default function TwoFactorRecoveryCodes({
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label="Loading recovery codes"
|
||||
aria-label={t(
|
||||
'Loading recovery codes',
|
||||
)}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: 8 },
|
||||
@@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({
|
||||
|
||||
<div className="text-xs text-muted-foreground select-none">
|
||||
<p id="regenerate-warning">
|
||||
Each recovery code can be used once to
|
||||
access your account and will be removed
|
||||
after use. If you need more, click{' '}
|
||||
{t(
|
||||
'Each recovery code can be used once to access your account and will be removed after use. If you need more, click',
|
||||
)}{' '}
|
||||
<span className="font-bold">
|
||||
Regenerate codes
|
||||
{t('Regenerate codes')}
|
||||
</span>{' '}
|
||||
above.
|
||||
{t('above.')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useClipboard } from '@/hooks/use-clipboard';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
|
||||
function GridScanIcon() {
|
||||
@@ -64,6 +65,7 @@ function TwoFactorSetupStep({
|
||||
}) {
|
||||
const { resolvedAppearance } = useAppearance();
|
||||
const [copiedText, copy] = useClipboard();
|
||||
const { t } = useTranslation();
|
||||
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
|
||||
|
||||
return (
|
||||
@@ -104,7 +106,7 @@ function TwoFactorSetupStep({
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
|
||||
<span className="relative bg-card px-2 py-1">
|
||||
or, enter the code manually
|
||||
{t('or, enter the code manually')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +149,7 @@ function TwoFactorVerificationStep({
|
||||
}) {
|
||||
const [code, setCode] = useState<string>('');
|
||||
const pinInputContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
@@ -210,7 +213,7 @@ function TwoFactorVerificationStep({
|
||||
onClick={onBack}
|
||||
disabled={processing}
|
||||
>
|
||||
Back
|
||||
{t('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -219,7 +222,7 @@ function TwoFactorVerificationStep({
|
||||
processing || code.length < OTP_MAX_LENGTH
|
||||
}
|
||||
>
|
||||
Confirm
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({
|
||||
}: Props) {
|
||||
const [showVerificationStep, setShowVerificationStep] =
|
||||
useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modalConfig = useMemo<{
|
||||
title: string;
|
||||
@@ -262,29 +266,32 @@ export default function TwoFactorSetupModal({
|
||||
}>(() => {
|
||||
if (twoFactorEnabled) {
|
||||
return {
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
title: t('Two-factor authentication enabled'),
|
||||
description: t(
|
||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
||||
buttonText: 'Close',
|
||||
),
|
||||
buttonText: t('Close'),
|
||||
};
|
||||
}
|
||||
|
||||
if (showVerificationStep) {
|
||||
return {
|
||||
title: 'Verify authentication code',
|
||||
description:
|
||||
title: t('Verify authentication code'),
|
||||
description: t(
|
||||
'Enter the 6-digit code from your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
),
|
||||
buttonText: t('Continue'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Enable two-factor authentication',
|
||||
description:
|
||||
title: t('Enable two-factor authentication'),
|
||||
description: t(
|
||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
||||
buttonText: 'Continue',
|
||||
),
|
||||
buttonText: t('Continue'),
|
||||
};
|
||||
}, [twoFactorEnabled, showVerificationStep]);
|
||||
}, [showVerificationStep, t, twoFactorEnabled]);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
setShowVerificationStep(false);
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <nav aria-label={t("breadcrumb")} data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
@@ -84,6 +87,8 @@ function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
@@ -93,7 +98,7 @@ function BreadcrumbEllipsis({
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
<span className="sr-only">{t("More")}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslatedChildren } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
@@ -39,19 +40,23 @@ function Button({
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const translatedChildren = useTranslatedChildren(children)
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{translatedChildren}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
@@ -49,6 +50,8 @@ function DialogContent({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -63,7 +66,7 @@ function DialogContent({
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("Close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslatedChildren } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const translatedChildren = useTranslatedChildren(children)
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
@@ -15,7 +19,9 @@ function Label({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{translatedChildren}
|
||||
</LabelPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
@@ -50,6 +51,8 @@ function SheetContent({
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -72,7 +75,7 @@ function SheetContent({
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("Close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
@@ -160,6 +161,7 @@ function Sidebar({
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -180,8 +182,10 @@ function Sidebar({
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>{t("Sidebar")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("Displays the mobile sidebar.")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
@@ -252,6 +256,7 @@ function SidebarTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar, isMobile, state } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -267,22 +272,23 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
{isMobile || state === "collapsed" ? <PanelLeftOpenIcon /> : <PanelLeftCloseIcon />}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
<span className="sr-only">{t("Toggle sidebar")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={t("Toggle sidebar")}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle sidebar"
|
||||
title={t("Toggle sidebar")}
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { useTranslation } from "@/lib/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
aria-label={t("Loading")}
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserInfo } from '@/components/user-info';
|
||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
@@ -18,6 +19,7 @@ type Props = {
|
||||
|
||||
export function UserMenuContent({ user }: Props) {
|
||||
const cleanup = useMobileNavigation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = () => {
|
||||
cleanup();
|
||||
@@ -41,7 +43,7 @@ export function UserMenuContent({ user }: Props) {
|
||||
onClick={cleanup}
|
||||
>
|
||||
<Settings className="mr-2" />
|
||||
Settings
|
||||
{t('Settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -55,7 +57,7 @@ export function UserMenuContent({ user }: Props) {
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
{t('Log out')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hls from 'hls.js/light';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
@@ -9,6 +10,7 @@ export function VideoPlayer({
|
||||
title: string;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -39,7 +41,7 @@ export function VideoPlayer({
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="flex aspect-video w-full items-center justify-center bg-neutral-950 text-sm text-neutral-300">
|
||||
Offline
|
||||
{t('Offline')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
|
||||
export default function AuthCardLayout({
|
||||
@@ -19,6 +20,8 @@ export default function AuthCardLayout({
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
@@ -34,8 +37,12 @@ export default function AuthCardLayout({
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card className="rounded-md shadow-sm">
|
||||
<CardHeader className="px-10 pt-8 pb-0 text-center">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t(title ?? '')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(description ?? '')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-10 py-8">
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
@@ -8,6 +9,8 @@ export default function AuthSimpleLayout({
|
||||
title,
|
||||
description,
|
||||
}: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
|
||||
@@ -20,13 +23,15 @@ export default function AuthSimpleLayout({
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<AppLogoIcon className="size-6 fill-current" />
|
||||
</div>
|
||||
<span className="sr-only">{title}</span>
|
||||
<span className="sr-only">{t(title ?? '')}</span>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t(title ?? '')}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{description}
|
||||
{t(description ?? '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { home } from '@/routes';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function AuthSplitLayout({
|
||||
description,
|
||||
}: AuthLayoutProps) {
|
||||
const { name } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
@@ -31,9 +33,11 @@ export default function AuthSplitLayout({
|
||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
<h1 className="text-xl font-medium">
|
||||
{t(title ?? '')}
|
||||
</h1>
|
||||
<p className="text-sm text-balance text-muted-foreground">
|
||||
{description}
|
||||
{t(description ?? '')}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Heading from '@/components/heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn, toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit } from '@/routes/profile';
|
||||
@@ -31,6 +32,7 @@ const sidebarNavItems: NavItem[] = [
|
||||
|
||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
@@ -43,7 +45,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
<aside className="w-full max-w-xl lg:w-48">
|
||||
<nav
|
||||
className="flex flex-col space-y-1 space-x-0"
|
||||
aria-label="Settings"
|
||||
aria-label={t('Settings')}
|
||||
>
|
||||
{sidebarNavItems.map((item, index) => (
|
||||
<Button
|
||||
|
||||
103
resources/js/lib/translations.tsx
Normal file
103
resources/js/lib/translations.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { TranslationReplacements, Translations } from '@/types';
|
||||
|
||||
export type Translate = (
|
||||
key: string,
|
||||
replacements?: TranslationReplacements,
|
||||
) => string;
|
||||
|
||||
export function translateString(
|
||||
translations: Translations,
|
||||
key: string,
|
||||
replacements: TranslationReplacements = {},
|
||||
): string {
|
||||
return Object.entries(replacements).reduce(
|
||||
(message, [name, value]) =>
|
||||
message
|
||||
.replaceAll(`:${name}`, String(value ?? ''))
|
||||
.replaceAll(`{${name}}`, String(value ?? '')),
|
||||
translations[key] ?? key,
|
||||
);
|
||||
}
|
||||
|
||||
export function translateChildren(
|
||||
children: ReactNode,
|
||||
translate: Translate,
|
||||
): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (typeof child === 'string') {
|
||||
return translateTextNode(child, translate);
|
||||
}
|
||||
|
||||
if (!isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const element = child as ReactElement<{ children?: ReactNode }>;
|
||||
|
||||
if (!element.props.children) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return cloneElement(
|
||||
element,
|
||||
undefined,
|
||||
translateChildren(element.props.children, translate),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranslation(): {
|
||||
locale: string;
|
||||
t: Translate;
|
||||
translateNode: (children: ReactNode) => ReactNode;
|
||||
} {
|
||||
const { locale, translations } = usePage().props;
|
||||
|
||||
const t = useCallback<Translate>(
|
||||
(key, replacements = {}) =>
|
||||
translateString(translations, key, replacements),
|
||||
[translations],
|
||||
);
|
||||
|
||||
const translateNode = useCallback(
|
||||
(children: ReactNode) => translateChildren(children, t),
|
||||
[t],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
t,
|
||||
translateNode,
|
||||
}),
|
||||
[locale, t, translateNode],
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslatedChildren(children: ReactNode): ReactNode {
|
||||
const { translateNode } = useTranslation();
|
||||
|
||||
return translateNode(children);
|
||||
}
|
||||
|
||||
function translateTextNode(value: string, translate: Translate): string {
|
||||
const text = value.trim();
|
||||
|
||||
if (text === '' || !/[A-Za-z]/.test(text)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
|
||||
const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
|
||||
|
||||
return `${leadingWhitespace}${translate(text)}${trailingWhitespace}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
|
||||
import { restore, suspend } from '@/routes/admin/channels';
|
||||
@@ -104,6 +105,7 @@ export default function AdminDashboard({
|
||||
users,
|
||||
}: Props) {
|
||||
const [action, setAction] = useState<ModerationAction>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
function setChannelCreationDefault(channelCreationOpen: boolean) {
|
||||
router.patch(
|
||||
@@ -153,7 +155,7 @@ export default function AdminDashboard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Admin" />
|
||||
<Head title={t('Admin')} />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -162,16 +164,19 @@ export default function AdminDashboard({
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Moderation console
|
||||
{t('Moderation console')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review live streams, suspended states, users,
|
||||
and recordings.
|
||||
{t(
|
||||
'Review live streams, suspended states, users, and recordings.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
{liveBroadcasts.length} active broadcasts
|
||||
{t(':count active broadcasts', {
|
||||
count: liveBroadcasts.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,10 +207,12 @@ export default function AdminDashboard({
|
||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
Channel creation access
|
||||
{t('Channel creation access')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control who can create a creator channel.
|
||||
{t(
|
||||
'Control who can create a creator channel.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,10 +266,14 @@ export default function AdminDashboard({
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="text-live size-5" />
|
||||
<h2 className="font-semibold">Live broadcasts</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Live broadcasts')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{liveBroadcasts.length} rows
|
||||
{t(':count rows', {
|
||||
count: liveBroadcasts.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -277,12 +288,15 @@ export default function AdminDashboard({
|
||||
{broadcast.title}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{broadcast.channel.display_name} by{' '}
|
||||
{broadcast.channel.owner}
|
||||
{t(':channel by :owner', {
|
||||
channel:
|
||||
broadcast.channel.display_name,
|
||||
owner: broadcast.channel.owner,
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-live mt-2 inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -319,9 +333,13 @@ export default function AdminDashboard({
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">Recent channels</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Recent channels')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{channels.length} rows
|
||||
{t(':count rows', {
|
||||
count: channels.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -336,18 +354,20 @@ export default function AdminDashboard({
|
||||
{channel.display_name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@{channel.slug} owned by{' '}
|
||||
{channel.owner.name}
|
||||
{t('@:slug owned by :owner', {
|
||||
slug: channel.slug,
|
||||
owner: channel.owner.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{channel.is_live && (
|
||||
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</span>
|
||||
)}
|
||||
{channel.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -400,9 +420,9 @@ export default function AdminDashboard({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle(action)}</DialogTitle>
|
||||
<DialogTitle>{t(dialogTitle(action))}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dialogDescription(action)}
|
||||
{t(dialogDescription(action))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -449,10 +469,12 @@ function Stat({
|
||||
value: number;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span>{t(label)}</span>
|
||||
<Icon className={live ? 'text-live size-4' : 'size-4'} />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{value}</div>
|
||||
@@ -470,6 +492,7 @@ function CreatorAccessRow({
|
||||
onChange: (canCreateChannel: boolean) => void;
|
||||
}) {
|
||||
const effectiveAccess = defaultOpen || user.can_create_channel;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
@@ -478,12 +501,12 @@ function CreatorAccessRow({
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
{user.is_admin && (
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
ADMIN
|
||||
{t('ADMIN')}
|
||||
</span>
|
||||
)}
|
||||
{user.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
@@ -493,11 +516,13 @@ function CreatorAccessRow({
|
||||
: 'border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-0.5 text-xs font-medium'
|
||||
}
|
||||
>
|
||||
{effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'}
|
||||
{effectiveAccess
|
||||
? t('CAN CREATE')
|
||||
: t('NEEDS APPROVAL')}
|
||||
</span>
|
||||
{defaultOpen && !user.can_create_channel && (
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
DEFAULT
|
||||
{t('DEFAULT')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -563,9 +588,11 @@ function Avatar({ channel }: { channel: AdminChannel }) {
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||
{text}
|
||||
{t(text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Inbox, MessageSquare, Shield, User } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
index as adminSupportIndex,
|
||||
@@ -16,9 +17,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Support inbox" />
|
||||
<Head title={t('Support inbox')} />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -27,11 +30,12 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Support inbox
|
||||
{t('Support inbox')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contact threads from users and channel request
|
||||
notes.
|
||||
{t(
|
||||
'Contact threads from users and channel request notes.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,10 +49,14 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">Conversations</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('Conversations')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{conversations.length} rows
|
||||
{t(':count rows', {
|
||||
count: conversations.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -96,7 +104,7 @@ export default function AdminSupportIndex({ stats, conversations }: Props) {
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||
No support conversations found.
|
||||
{t('No support conversations found.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -116,15 +124,19 @@ AdminSupportIndex.layout = {
|
||||
};
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>{' '}
|
||||
<span className="text-muted-foreground">{t(label)}</span>{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -134,7 +146,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard as adminDashboard } from '@/routes/admin';
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ type Props = {
|
||||
|
||||
export default function AdminSupportShow({ conversation }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
@@ -84,7 +86,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
>
|
||||
<Link href={adminSupportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Support inbox
|
||||
{t('Support inbox')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
@@ -111,7 +113,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
) : (
|
||||
<Lock className="size-4" />
|
||||
)}
|
||||
{isClosed ? 'Reopen' : 'Close'}
|
||||
{isClosed ? t('Reopen') : t('Close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +128,9 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Reopen the conversation to reply.</span>
|
||||
<span>
|
||||
{t('Reopen the conversation to reply.')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -197,7 +201,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">User</h2>
|
||||
<h2 className="font-semibold">{t('User')}</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
@@ -218,12 +222,12 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
)}
|
||||
>
|
||||
{conversation.user?.can_create_channel
|
||||
? 'CAN CREATE CHANNEL'
|
||||
: 'NEEDS CHANNEL GRANT'}
|
||||
? t('CAN CREATE CHANNEL')
|
||||
: t('NEEDS CHANNEL GRANT')}
|
||||
</span>
|
||||
{conversation.user?.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
{t('SUSPENDED')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,12 +250,13 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
Channel request
|
||||
{t('Channel request')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Creator access grants stay in the moderation
|
||||
console.
|
||||
{t(
|
||||
'Creator access grants stay in the moderation console.',
|
||||
)}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={adminDashboard()}>
|
||||
@@ -277,6 +282,7 @@ AdminSupportShow.layout = {
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -288,7 +294,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'USER'}
|
||||
{isAdmin ? t('ADMIN') : t('USER')}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -345,6 +351,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -354,7 +362,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Confirm password" />
|
||||
<Head title={t('Confirm password')} />
|
||||
|
||||
<Form {...store.form()} resetOnSuccess={['password']}>
|
||||
{({ processing, errors }) => (
|
||||
@@ -19,7 +22,7 @@ export default function ConfirmPassword() {
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,16 @@ import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { login } from '@/routes';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Forgot password" />
|
||||
<Head title={t('Forgot password')} />
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
@@ -32,7 +35,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
@@ -55,8 +58,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>log in</TextLink>
|
||||
<span>{t('Or, return to')}</span>
|
||||
<TextLink href={login()}>{t('log in')}</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { register } from '@/routes';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
@@ -22,9 +23,11 @@ export default function Login({
|
||||
canResetPassword,
|
||||
canRegister,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
<Head title={t('Log in')} />
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
@@ -44,7 +47,7 @@ export default function Login({
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
@@ -58,7 +61,7 @@ export default function Login({
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot password?
|
||||
{t('Forgot password?')}
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -68,7 +71,7 @@ export default function Login({
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
@@ -96,9 +99,9 @@ export default function Login({
|
||||
|
||||
{canRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
{t("Don't have an account?")}{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
Sign up
|
||||
{t('Sign up')}
|
||||
</TextLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { login } from '@/routes';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
@@ -14,9 +15,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function Register({ passwordRules }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Register" />
|
||||
<Head title={t('Register')} />
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
@@ -36,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={1}
|
||||
autoComplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
placeholder={t('Full name')}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
@@ -53,7 +56,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
placeholder={t('email@example.com')}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
@@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={3}
|
||||
autoComplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
@@ -82,7 +85,7 @@ export default function Register({ passwordRules }: Props) {
|
||||
tabIndex={4}
|
||||
autoComplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
@@ -102,9 +105,9 @@ export default function Register({ passwordRules }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
{t('Already have an account?')}{' '}
|
||||
<TextLink href={login()} tabIndex={6}>
|
||||
Log in
|
||||
{t('Log in')}
|
||||
</TextLink>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { update } from '@/routes/password';
|
||||
|
||||
type Props = {
|
||||
@@ -14,9 +15,11 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Reset password" />
|
||||
<Head title={t('Reset password')} />
|
||||
|
||||
<Form
|
||||
{...update.form()}
|
||||
@@ -50,7 +53,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
placeholder="Password"
|
||||
placeholder={t('Password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
@@ -65,7 +68,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const { t } = useTranslation();
|
||||
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
|
||||
@@ -23,20 +25,22 @@ export default function TwoFactorChallenge() {
|
||||
}>(() => {
|
||||
if (showRecoveryInput) {
|
||||
return {
|
||||
title: 'Recovery code',
|
||||
description:
|
||||
title: t('Recovery code'),
|
||||
description: t(
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
toggleText: 'login using an authentication code',
|
||||
),
|
||||
toggleText: t('login using an authentication code'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
title: t('Authentication code'),
|
||||
description: t(
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
toggleText: 'login using a recovery code',
|
||||
),
|
||||
toggleText: t('login using a recovery code'),
|
||||
};
|
||||
}, [showRecoveryInput]);
|
||||
}, [showRecoveryInput, t]);
|
||||
|
||||
setLayoutProps({
|
||||
title: authConfigContent.title,
|
||||
@@ -51,7 +55,7 @@ export default function TwoFactorChallenge() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Two-factor authentication" />
|
||||
<Head title={t('Two-factor authentication')} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form
|
||||
@@ -67,7 +71,7 @@ export default function TwoFactorChallenge() {
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
placeholder={t('Enter recovery code')}
|
||||
autoFocus={showRecoveryInput}
|
||||
required
|
||||
/>
|
||||
@@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<span>{t('or you can')} </span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
|
||||
@@ -3,18 +3,22 @@ import { Form, Head } from '@inertiajs/react';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { logout } from '@/routes';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Email verification" />
|
||||
<Head title={t('Email verification')} />
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to the email address
|
||||
you provided during registration.
|
||||
{t(
|
||||
'A new verification link has been sent to the email address you provided during registration.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,7 +34,7 @@ export default function VerifyEmail({ status }: { status?: string }) {
|
||||
href={logout()}
|
||||
className="mx-auto block text-sm"
|
||||
>
|
||||
Log out
|
||||
{t('Log out')}
|
||||
</TextLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { VideoPlayer } from '@/components/video-player';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { home, login } from '@/routes';
|
||||
import { follow, unfollow } from '@/routes/channels';
|
||||
@@ -84,6 +85,7 @@ export default function ChannelShow({
|
||||
isFollowing,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const chatForm = useForm({ body: '' });
|
||||
const currentViewers = viewerStats.current;
|
||||
@@ -160,10 +162,14 @@ export default function ChannelShow({
|
||||
<span>{channel.display_name}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-4" />
|
||||
{currentViewers} viewers
|
||||
{t(':count viewers', {
|
||||
count: currentViewers,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{channel.followers_count} followers
|
||||
{t(':count followers', {
|
||||
count: channel.followers_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{channel.description && (
|
||||
@@ -185,7 +191,7 @@ export default function ChannelShow({
|
||||
className="xl:hidden"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Chat
|
||||
{t('Chat')}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
@@ -193,7 +199,9 @@ export default function ChannelShow({
|
||||
className="h-[82svh] rounded-t-md"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Live chat</SheetTitle>
|
||||
<SheetTitle>
|
||||
{t('Live chat')}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ChatPanel
|
||||
channel={channel}
|
||||
@@ -223,13 +231,15 @@ export default function ChannelShow({
|
||||
'fill-current text-primary',
|
||||
)}
|
||||
/>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
{isFollowing
|
||||
? t('Following')
|
||||
: t('Follow')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={login()}>
|
||||
<LogIn className="size-4" />
|
||||
Log in to follow
|
||||
{t('Log in to follow')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -241,11 +251,13 @@ export default function ChannelShow({
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
Recent VODs
|
||||
{t('Recent VODs')}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vods.length} available
|
||||
{t(':count available', {
|
||||
count: vods.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{vods.length > 0 ? (
|
||||
@@ -256,7 +268,7 @@ export default function ChannelShow({
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-card p-6 text-sm text-muted-foreground">
|
||||
No public recordings yet.
|
||||
{t('No public recordings yet.')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -288,6 +300,8 @@ ChannelShow.layout = {
|
||||
};
|
||||
|
||||
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
|
||||
{channel.banner_url ? (
|
||||
@@ -304,11 +318,14 @@ function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-xl font-semibold">
|
||||
{channel.display_name} is offline
|
||||
{t(':channel is offline', {
|
||||
channel: channel.display_name,
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
Recent recordings remain available below when the
|
||||
creator publishes VODs.
|
||||
{t(
|
||||
'Recent recordings remain available below when the creator publishes VODs.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,6 +351,7 @@ function ChatPanel({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const getInitials = useInitials();
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
@@ -383,9 +401,11 @@ function ChatPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="size-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Live chat</h2>
|
||||
<h2 className="font-semibold">{t('Live chat')}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessageCount(messages.length)}
|
||||
{t(':count messages', {
|
||||
count: messages.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,13 +458,13 @@ function ChatPanel({
|
||||
)}
|
||||
>
|
||||
{isOwnMessage
|
||||
? 'You'
|
||||
? t('You')
|
||||
: message.user.name}
|
||||
</span>
|
||||
{isChannelOwner && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
<Crown className="size-3" />
|
||||
Owner
|
||||
{t('Owner')}
|
||||
</span>
|
||||
)}
|
||||
{message.created_at && (
|
||||
@@ -484,8 +504,10 @@ function ChatPanel({
|
||||
<MessageSquare className="size-5 text-primary" />
|
||||
<span>
|
||||
{channel.is_live
|
||||
? 'No messages yet.'
|
||||
: 'Chat appears when the channel is live.'}
|
||||
? t('No messages yet.')
|
||||
: t(
|
||||
'Chat appears when the channel is live.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -504,8 +526,8 @@ function ChatPanel({
|
||||
}
|
||||
placeholder={
|
||||
channel.is_live
|
||||
? 'Send a message'
|
||||
: 'Channel is offline'
|
||||
? t('Send a message')
|
||||
: t('Channel is offline')
|
||||
}
|
||||
disabled={!channel.is_live || form.processing}
|
||||
maxLength={500}
|
||||
@@ -518,7 +540,7 @@ function ChatPanel({
|
||||
disabled={
|
||||
!channel.is_live || form.processing
|
||||
}
|
||||
aria-label="Open emoji picker"
|
||||
aria-label={t('Open emoji picker')}
|
||||
aria-expanded={emojiPickerOpen}
|
||||
onClick={() =>
|
||||
setEmojiPickerOpen((open) => !open)
|
||||
@@ -532,7 +554,9 @@ function ChatPanel({
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
aria-label={`Insert ${emoji}`}
|
||||
aria-label={t('Insert :emoji', {
|
||||
emoji,
|
||||
})}
|
||||
className="flex size-10 items-center justify-center rounded-md text-lg hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
onClick={() =>
|
||||
insertEmoji(emoji)
|
||||
@@ -548,7 +572,7 @@ function ChatPanel({
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
aria-label={t('Send message')}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
@@ -562,7 +586,7 @@ function ChatPanel({
|
||||
>
|
||||
{form.errors.body ??
|
||||
(!channel.is_live
|
||||
? 'Channel is offline'
|
||||
? t('Channel is offline')
|
||||
: '')}
|
||||
</span>
|
||||
<span
|
||||
@@ -577,7 +601,7 @@ function ChatPanel({
|
||||
</form>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={login()}>Log in to chat</Link>
|
||||
<Link href={login()}>{t('Log in to chat')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -585,10 +609,6 @@ function ChatPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return count === 1 ? '1 message' : `${count} messages`;
|
||||
}
|
||||
|
||||
function formatChatTime(value: string): string {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
@@ -601,6 +621,8 @@ function formatChatDateTime(value: string): string {
|
||||
}
|
||||
|
||||
function VodCard({ vod }: { vod: VodSummary }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border bg-card">
|
||||
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
|
||||
@@ -610,19 +632,20 @@ function VodCard({ vod }: { vod: VodSummary }) {
|
||||
<div>
|
||||
<div className="line-clamp-2 font-medium">{vod.title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Expires{' '}
|
||||
{vod.expires_at
|
||||
? 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,17 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import Heading from '@/components/heading';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
|
||||
export default function Appearance() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Appearance settings" />
|
||||
<Head title={t('Appearance settings')} />
|
||||
|
||||
<h1 className="sr-only">Appearance settings</h1>
|
||||
<h1 className="sr-only">{t('Appearance settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function Profile({
|
||||
status?: string;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const user = auth.user;
|
||||
const getInitials = useInitials();
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -52,9 +54,9 @@ export default function Profile({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Profile settings" />
|
||||
<Head title={t('Profile settings')} />
|
||||
|
||||
<h1 className="sr-only">Profile settings</h1>
|
||||
<h1 className="sr-only">{t('Profile settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
@@ -114,7 +116,7 @@ export default function Profile({
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ImagePlus className="size-3.5" />
|
||||
<span>
|
||||
JPG, PNG, or WebP up to 2 MB.
|
||||
{t('JPG, PNG, or WebP up to 2 MB.')}
|
||||
</span>
|
||||
</div>
|
||||
{progress && (
|
||||
@@ -138,7 +140,7 @@ export default function Profile({
|
||||
name="name"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
placeholder={t('Full name')}
|
||||
/>
|
||||
|
||||
<InputError
|
||||
@@ -158,7 +160,7 @@ export default function Profile({
|
||||
name="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
placeholder={t('Email address')}
|
||||
/>
|
||||
|
||||
<InputError
|
||||
@@ -171,22 +173,26 @@ export default function Profile({
|
||||
user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">
|
||||
Your email address is unverified.{' '}
|
||||
{t(
|
||||
'Your email address is unverified.',
|
||||
)}{' '}
|
||||
<Link
|
||||
href={send()}
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to resend the
|
||||
verification email.
|
||||
{t(
|
||||
'Click here to resend the verification email.',
|
||||
)}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status ===
|
||||
'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">
|
||||
A new verification link has been
|
||||
sent to your email address.
|
||||
{t(
|
||||
'A new verification link has been sent to your email address.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { edit } from '@/routes/security';
|
||||
import { disable, enable } from '@/routes/two-factor';
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function Security({
|
||||
twoFactorEnabled = false,
|
||||
passwordRules,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -53,9 +55,9 @@ export default function Security({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Security settings" />
|
||||
<Head title={t('Security settings')} />
|
||||
|
||||
<h1 className="sr-only">Security settings</h1>
|
||||
<h1 className="sr-only">{t('Security settings')}</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
@@ -99,7 +101,7 @@ export default function Security({
|
||||
name="current_password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
placeholder={t('Current password')}
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
@@ -114,7 +116,7 @@ export default function Security({
|
||||
name="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
placeholder={t('New password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
|
||||
@@ -131,7 +133,7 @@ export default function Security({
|
||||
name="password_confirmation"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('Confirm password')}
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
|
||||
@@ -163,9 +165,9 @@ export default function Security({
|
||||
{twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will be prompted for a secure, random pin
|
||||
during login, which you can retrieve from the
|
||||
TOTP-supported application on your phone.
|
||||
{t(
|
||||
'You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="relative inline">
|
||||
@@ -191,10 +193,9 @@ export default function Security({
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-start space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When you enable two-factor authentication, you
|
||||
will be prompted for a secure pin during login.
|
||||
This pin can be retrieved from a TOTP-supported
|
||||
application on your phone.
|
||||
{t(
|
||||
'When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
index as supportIndex,
|
||||
@@ -24,6 +25,7 @@ type Props = {
|
||||
|
||||
export default function SupportIndex({ categories, conversations }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
category: categories[0]?.value ?? 'bug',
|
||||
subject: '',
|
||||
@@ -53,7 +55,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Contact admin" />
|
||||
<Head title={t('Contact admin')} />
|
||||
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="grid content-start gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
@@ -63,16 +65,19 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Contact admin
|
||||
{t('Contact admin')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bug reports, suggestions, channel requests,
|
||||
and account questions.
|
||||
{t(
|
||||
'Bug reports, suggestions, channel requests, and account questions.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
{conversations.length} threads
|
||||
{t(':count threads', {
|
||||
count: conversations.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +119,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="grid min-h-64 place-items-center rounded-md border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
No conversations yet.
|
||||
{t('No conversations yet.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,9 +127,11 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold">New conversation</h2>
|
||||
<h2 className="font-semibold">
|
||||
{t('New conversation')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attach up to 3 files, 200 MB each.
|
||||
{t('Attach up to 3 files, 200 MB each.')}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -237,6 +244,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -246,7 +255,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SupportAttachmentErrors } from '@/components/support-attachment-errors'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { index as supportIndex } from '@/routes/support';
|
||||
import { show as showAttachment } from '@/routes/support/attachments';
|
||||
@@ -22,6 +23,7 @@ type Props = {
|
||||
|
||||
export default function SupportShow({ conversation }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const form = useForm({
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
@@ -56,7 +58,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||
<Link href={supportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Contact admin
|
||||
{t('Contact admin')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
@@ -72,7 +74,9 @@ export default function SupportShow({ conversation }: Props) {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground">
|
||||
{conversation.messages_count} messages
|
||||
{t(':count messages', {
|
||||
count: conversation.messages_count,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +91,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>This conversation is closed.</span>
|
||||
<span>{t('This conversation is closed.')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -161,6 +165,7 @@ SupportShow.layout = {
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -172,7 +177,7 @@ function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'YOU'}
|
||||
{isAdmin ? t('ADMIN') : t('YOU')}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -229,6 +234,8 @@ function Field({
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -238,7 +245,7 @@ function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
{status === 'open' ? t('OPEN') : t('CLOSED')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/lib/translations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard, home, login, register } from '@/routes';
|
||||
import { show as showChannel } from '@/routes/channels';
|
||||
@@ -31,6 +32,7 @@ export default function Welcome({
|
||||
categories,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<string>('all');
|
||||
|
||||
@@ -55,7 +57,7 @@ export default function Welcome({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Live" />
|
||||
<Head title={t('Live')} />
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4">
|
||||
@@ -74,7 +76,7 @@ export default function Welcome({
|
||||
href={home()}
|
||||
className="rounded-md px-3 py-2 text-foreground"
|
||||
>
|
||||
Live
|
||||
{t('Live')}
|
||||
</Link>
|
||||
{auth.user && (
|
||||
<>
|
||||
@@ -82,13 +84,13 @@ export default function Welcome({
|
||||
href={dashboard()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Studio
|
||||
{t('Studio')}
|
||||
</Link>
|
||||
<Link
|
||||
href={supportIndex()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Contact
|
||||
{t('Contact')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -100,26 +102,28 @@ export default function Welcome({
|
||||
<Button asChild size="sm">
|
||||
<Link href={dashboard()}>
|
||||
<LayoutDashboard className="size-4" />
|
||||
Studio
|
||||
{t('Studio')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={supportIndex()}>
|
||||
<LifeBuoy className="size-4" />
|
||||
Contact
|
||||
{t('Contact')}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={login()}>Log in</Link>
|
||||
<Link href={login()}>
|
||||
{t('Log in')}
|
||||
</Link>
|
||||
</Button>
|
||||
{canRegister && (
|
||||
<Button asChild size="sm">
|
||||
<Link href={register()}>
|
||||
<UserPlus className="size-4" />
|
||||
Register
|
||||
{t('Register')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -140,7 +144,7 @@ export default function Welcome({
|
||||
<Thumbnail
|
||||
channel={featuredChannel}
|
||||
className="aspect-video lg:aspect-auto lg:w-[58%] lg:flex-none"
|
||||
label="Featured live"
|
||||
label={t('Featured live')}
|
||||
/>
|
||||
<div className="grid min-w-0 flex-1 content-between gap-6 p-5">
|
||||
<div className="min-w-0 space-y-4">
|
||||
@@ -152,7 +156,14 @@ export default function Welcome({
|
||||
</h1>
|
||||
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||
{featuredChannel.description ??
|
||||
`${featuredChannel.display_name} is live now with ${featuredChannel.viewer_count} viewers.`}
|
||||
t(
|
||||
':channel is live now with :count viewers.',
|
||||
{
|
||||
channel:
|
||||
featuredChannel.display_name,
|
||||
count: featuredChannel.viewer_count,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,17 +173,17 @@ export default function Welcome({
|
||||
</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{featuredChannel.category
|
||||
?.name ?? 'Uncategorized'}
|
||||
?.name ??
|
||||
t('Uncategorized')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-4" />
|
||||
{
|
||||
featuredChannel.viewer_count
|
||||
}{' '}
|
||||
viewers
|
||||
{t(':count viewers', {
|
||||
count: featuredChannel.viewer_count,
|
||||
})}
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-primary sm:ml-auto">
|
||||
Watch
|
||||
{t('Watch')}
|
||||
<ArrowRight className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -186,11 +197,12 @@ export default function Welcome({
|
||||
</div>
|
||||
<div className="max-w-xl space-y-2">
|
||||
<h1 className="text-2xl font-semibold md:text-4xl">
|
||||
The live floor is quiet
|
||||
{t('The live floor is quiet')}
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground md:text-base">
|
||||
Creators can prepare a broadcast in the
|
||||
studio and go live from OBS when ready.
|
||||
{t(
|
||||
'Creators can prepare a broadcast in the studio and go live from OBS when ready.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,10 +212,10 @@ export default function Welcome({
|
||||
<div className="flex items-center justify-between gap-3 border-b pb-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
Live signal
|
||||
{t('Live signal')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Public channels broadcasting now
|
||||
{t('Public channels broadcasting now')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-live rounded-md px-2 py-1 text-xs font-medium text-white">
|
||||
@@ -212,12 +224,12 @@ export default function Welcome({
|
||||
</div>
|
||||
<Metric
|
||||
icon={Radio}
|
||||
label="Live channels"
|
||||
label={t('Live channels')}
|
||||
value={String(liveChannels.length)}
|
||||
/>
|
||||
<Metric
|
||||
icon={Users}
|
||||
label="Viewers"
|
||||
label={t('Viewers')}
|
||||
value={String(
|
||||
liveChannels.reduce(
|
||||
(total, item) =>
|
||||
@@ -228,7 +240,7 @@ export default function Welcome({
|
||||
/>
|
||||
<Metric
|
||||
icon={Compass}
|
||||
label="Categories"
|
||||
label={t('Categories')}
|
||||
value={String(categories.length)}
|
||||
/>
|
||||
<Button asChild variant="outline">
|
||||
@@ -236,8 +248,8 @@ export default function Welcome({
|
||||
href={auth.user ? dashboard() : register()}
|
||||
>
|
||||
{auth.user
|
||||
? 'Open studio'
|
||||
: 'Start a channel'}
|
||||
? t('Open studio')
|
||||
: t('Start a channel')}
|
||||
</Link>
|
||||
</Button>
|
||||
</aside>
|
||||
@@ -247,10 +259,12 @@ export default function Welcome({
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Live directory
|
||||
{t('Live directory')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse active channels by category or title.
|
||||
{t(
|
||||
'Browse active channels by category or title.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
@@ -261,7 +275,7 @@ export default function Welcome({
|
||||
onChange={(event) =>
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
placeholder="Search live channels"
|
||||
placeholder={t('Search live channels')}
|
||||
className="pl-9 sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,7 +286,9 @@ export default function Welcome({
|
||||
}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
<option value="all">
|
||||
{t('All categories')}
|
||||
</option>
|
||||
{categories.map((item) => (
|
||||
<option key={item.id} value={item.slug}>
|
||||
{item.name}
|
||||
@@ -296,10 +312,12 @@ export default function Welcome({
|
||||
<Search className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
No streams match this view
|
||||
{t('No streams match this view')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Clear search or switch categories.
|
||||
{t(
|
||||
'Clear search or switch categories.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,6 +330,8 @@ export default function Welcome({
|
||||
}
|
||||
|
||||
function StreamCard({ channel }: { channel: ChannelCard }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={showChannel(channel.slug)}
|
||||
@@ -325,16 +345,21 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
|
||||
{channel.broadcast?.title ?? channel.display_name}
|
||||
</div>
|
||||
<div className="truncate text-sm text-muted-foreground">
|
||||
{channel.display_name} by {channel.owner.name}
|
||||
{t(':channel by :owner', {
|
||||
channel: channel.display_name,
|
||||
owner: channel.owner.name,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{channel.category?.name ?? 'Uncategorized'}
|
||||
{channel.category?.name ?? t('Uncategorized')}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{channel.viewer_count} viewers
|
||||
{t(':count viewers', {
|
||||
count: channel.viewer_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,6 +376,8 @@ function Thumbnail({
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -368,7 +395,7 @@ function Thumbnail({
|
||||
<div className="grid h-full w-full place-items-center bg-[radial-gradient(circle_at_40%_30%,oklch(0.34_0.12_326),oklch(0.14_0.04_315)_55%)]">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="bg-live live-pulse size-2 rounded-full" />
|
||||
{label}
|
||||
{t(label)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -380,6 +407,8 @@ function Thumbnail({
|
||||
}
|
||||
|
||||
function LiveBadge({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -388,7 +417,7 @@ function LiveBadge({ compact = false }: { compact?: boolean }) {
|
||||
)}
|
||||
>
|
||||
<span className="live-pulse size-1.5 rounded-full bg-white" />
|
||||
LIVE
|
||||
{t('LIVE')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
4
resources/js/types/global.d.ts
vendored
4
resources/js/types/global.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import type { Auth } from '@/types/auth';
|
||||
import type { Translations } from '@/types/localization';
|
||||
|
||||
declare module 'react' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -12,7 +13,10 @@ declare module '@inertiajs/core' {
|
||||
sharedPageProps: {
|
||||
name: string;
|
||||
auth: Auth;
|
||||
fallbackLocale: string;
|
||||
locale: string;
|
||||
sidebarOpen: boolean;
|
||||
translations: Translations;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type * from './auth';
|
||||
export type * from './localization';
|
||||
export type * from './navigation';
|
||||
export type * from './streaming';
|
||||
export type * from './support';
|
||||
|
||||
6
resources/js/types/localization.ts
Normal file
6
resources/js/types/localization.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Translations = Record<string, string>;
|
||||
|
||||
export type TranslationReplacements = Record<
|
||||
string,
|
||||
boolean | null | number | string | undefined
|
||||
>;
|
||||
Reference in New Issue
Block a user