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

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

View File

@@ -1,5 +1,6 @@
import { AlertCircleIcon } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useTranslation } from '@/lib/translations';
export default function AlertError({
errors,
@@ -8,10 +9,12 @@ export default function AlertError({
errors: string[];
title?: string;
}) {
const { t } = useTranslation();
return (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{title || 'Something went wrong.'}</AlertTitle>
<AlertTitle>{t(title || 'Something went wrong.')}</AlertTitle>
<AlertDescription>
<ul className="list-inside list-disc text-sm">
{Array.from(new Set(errors)).map((error, index) => (

View File

@@ -38,6 +38,7 @@ import {
import { UserMenuContent } from '@/components/user-menu-content';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useInitials } from '@/hooks/use-initials';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
import { dashboard, home } from '@/routes';
import { dashboard as adminDashboard } from '@/routes/admin';
@@ -56,6 +57,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
const { auth } = page.props;
const getInitials = useInitials();
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
const { t } = useTranslation();
const mainNavItems: NavItem[] = [
{
title: 'Live',
@@ -108,7 +110,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
>
<SheetTitle className="sr-only">
Navigation menu
{t('Navigation menu')}
</SheetTitle>
<SheetHeader className="flex justify-start text-left">
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
@@ -125,7 +127,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
{item.icon && (
<item.icon className="h-5 w-5" />
)}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</Link>
))}
</div>
@@ -168,7 +170,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
{item.icon && (
<item.icon className="mr-2 h-4 w-4" />
)}
{item.title}
{t(item.title)}
</Link>
{isCurrentUrl(item.href) && (
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
@@ -199,13 +201,13 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span className="sr-only">
Live directory
{t('Live directory')}
</span>
<Radio className="size-5 opacity-80 group-hover:opacity-100" />
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Live directory</p>
<p>{t('Live directory')}</p>
</TooltipContent>
</Tooltip>
</div>

View File

@@ -3,6 +3,7 @@ import { Monitor, Moon, Sun } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import type { Appearance } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
export default function AppearanceToggleTab({
@@ -10,6 +11,7 @@ export default function AppearanceToggleTab({
...props
}: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const { t } = useTranslation();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
@@ -37,7 +39,7 @@ export default function AppearanceToggleTab({
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
<span className="ml-1.5 text-sm">{t(label)}</span>
</button>
))}
</div>

View File

@@ -8,6 +8,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { useTranslation } from '@/lib/translations';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
export function Breadcrumbs({
@@ -15,6 +16,8 @@ export function Breadcrumbs({
}: {
breadcrumbs: BreadcrumbItemType[];
}) {
const { t } = useTranslation();
return (
<>
{breadcrumbs.length > 0 && (
@@ -28,12 +31,12 @@ export function Breadcrumbs({
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>
{item.title}
{t(item.title)}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={item.href}>
{item.title}
{t(item.title)}
</Link>
</BreadcrumbLink>
)}

View File

@@ -15,9 +15,11 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/lib/translations';
export default function DeleteUser() {
const passwordInput = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
return (
<div className="space-y-6">
@@ -28,9 +30,11 @@ export default function DeleteUser() {
/>
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p>
<p className="font-medium">{t('Warning')}</p>
<p className="text-sm">
Please proceed with caution, this cannot be undone.
{t(
'Please proceed with caution, this cannot be undone.',
)}
</p>
</div>
@@ -45,13 +49,12 @@ export default function DeleteUser() {
</DialogTrigger>
<DialogContent>
<DialogTitle>
Are you sure you want to delete your account?
{t('Are you sure you want to delete your account?')}
</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources
and data will also be permanently deleted. Please
enter your password to confirm you would like to
permanently delete your account.
{t(
'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
)}
</DialogDescription>
<Form
@@ -77,7 +80,7 @@ export default function DeleteUser() {
id="password"
name="password"
ref={passwordInput}
placeholder="Password"
placeholder={t('Password')}
autoComplete="current-password"
/>

View File

@@ -1,3 +1,5 @@
import { useTranslation } from '@/lib/translations';
export default function Heading({
title,
description,
@@ -7,6 +9,8 @@ export default function Heading({
description?: string;
variant?: 'default' | 'small';
}) {
const { t } = useTranslation();
return (
<header className={variant === 'small' ? '' : 'mb-8 space-y-0.5'}>
<h2
@@ -16,10 +20,12 @@ export default function Heading({
: 'text-xl font-semibold tracking-tight'
}
>
{title}
{t(title)}
</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-sm text-muted-foreground">
{t(description)}
</p>
)}
</header>
);

View File

@@ -7,25 +7,27 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useTranslation } from '@/lib/translations';
import type { NavItem } from '@/types';
export function NavMain({ items = [] }: { items: NavItem[] }) {
const { isCurrentUrl } = useCurrentUrl();
const { t } = useTranslation();
return (
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{t('Platform')}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isCurrentUrl(item.href)}
tooltip={{ children: item.title }}
tooltip={{ children: t(item.title) }}
>
<Link href={item.href} prefetch>
{item.icon && <item.icon />}
<span>{item.title}</span>
<span>{t(item.title)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -2,6 +2,7 @@ import { Eye, EyeOff } from 'lucide-react';
import type { ComponentProps, Ref } from 'react';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
export default function PasswordInput({
@@ -10,6 +11,7 @@ export default function PasswordInput({
...props
}: Omit<ComponentProps<'input'>, 'type'> & { ref?: Ref<HTMLInputElement> }) {
const [showPassword, setShowPassword] = useState(false);
const { t } = useTranslation();
return (
<div className="relative">
@@ -23,7 +25,7 @@ export default function PasswordInput({
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-label={t(showPassword ? 'Hide password' : 'Show password')}
tabIndex={-1}
>
{showPassword ? (

View File

@@ -10,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useTranslation } from '@/lib/translations';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
type Props = {
@@ -26,6 +27,7 @@ export default function TwoFactorRecoveryCodes({
const [codesAreVisible, setCodesAreVisible] = useState<boolean>(false);
const codesSectionRef = useRef<HTMLDivElement | null>(null);
const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible;
const { t } = useTranslation();
const toggleCodesVisibility = useCallback(async () => {
if (!codesAreVisible && !recoveryCodesList.length) {
@@ -57,11 +59,12 @@ export default function TwoFactorRecoveryCodes({
<CardHeader>
<CardTitle className="flex gap-3">
<LockKeyhole className="size-4" aria-hidden="true" />
2FA recovery codes
{t('2FA recovery codes')}
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
{t(
'Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.',
)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -76,7 +79,9 @@ export default function TwoFactorRecoveryCodes({
className="size-4"
aria-hidden="true"
/>
{codesAreVisible ? 'Hide' : 'View'} recovery codes
{codesAreVisible
? t('Hide recovery codes')
: t('View recovery codes')}
</Button>
{canRegenerateCodes && (
@@ -92,7 +97,7 @@ export default function TwoFactorRecoveryCodes({
disabled={processing}
aria-describedby="regenerate-warning"
>
<RefreshCw /> Regenerate codes
<RefreshCw /> {t('Regenerate codes')}
</Button>
)}
</Form>
@@ -112,7 +117,7 @@ export default function TwoFactorRecoveryCodes({
ref={codesSectionRef}
className="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
role="list"
aria-label="Recovery codes"
aria-label={t('Recovery codes')}
>
{recoveryCodesList.length ? (
recoveryCodesList.map((code, index) => (
@@ -127,7 +132,9 @@ export default function TwoFactorRecoveryCodes({
) : (
<div
className="space-y-2"
aria-label="Loading recovery codes"
aria-label={t(
'Loading recovery codes',
)}
>
{Array.from(
{ length: 8 },
@@ -145,13 +152,13 @@ export default function TwoFactorRecoveryCodes({
<div className="text-xs text-muted-foreground select-none">
<p id="regenerate-warning">
Each recovery code can be used once to
access your account and will be removed
after use. If you need more, click{' '}
{t(
'Each recovery code can be used once to access your account and will be removed after use. If you need more, click',
)}{' '}
<span className="font-bold">
Regenerate codes
{t('Regenerate codes')}
</span>{' '}
above.
{t('above.')}
</p>
</div>
</>

View File

@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/hooks/use-appearance';
import { useClipboard } from '@/hooks/use-clipboard';
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
import { useTranslation } from '@/lib/translations';
import { confirm } from '@/routes/two-factor';
function GridScanIcon() {
@@ -64,6 +65,7 @@ function TwoFactorSetupStep({
}) {
const { resolvedAppearance } = useAppearance();
const [copiedText, copy] = useClipboard();
const { t } = useTranslation();
const IconComponent = copiedText === manualSetupKey ? Check : Copy;
return (
@@ -104,7 +106,7 @@ function TwoFactorSetupStep({
<div className="relative flex w-full items-center justify-center">
<div className="absolute inset-0 top-1/2 h-px w-full bg-border" />
<span className="relative bg-card px-2 py-1">
or, enter the code manually
{t('or, enter the code manually')}
</span>
</div>
@@ -147,6 +149,7 @@ function TwoFactorVerificationStep({
}) {
const [code, setCode] = useState<string>('');
const pinInputContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
useEffect(() => {
setTimeout(() => {
@@ -210,7 +213,7 @@ function TwoFactorVerificationStep({
onClick={onBack}
disabled={processing}
>
Back
{t('Back')}
</Button>
<Button
type="submit"
@@ -219,7 +222,7 @@ function TwoFactorVerificationStep({
processing || code.length < OTP_MAX_LENGTH
}
>
Confirm
{t('Confirm')}
</Button>
</div>
</div>
@@ -254,6 +257,7 @@ export default function TwoFactorSetupModal({
}: Props) {
const [showVerificationStep, setShowVerificationStep] =
useState<boolean>(false);
const { t } = useTranslation();
const modalConfig = useMemo<{
title: string;
@@ -262,29 +266,32 @@ export default function TwoFactorSetupModal({
}>(() => {
if (twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
title: t('Two-factor authentication enabled'),
description: t(
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
),
buttonText: t('Close'),
};
}
if (showVerificationStep) {
return {
title: 'Verify authentication code',
description:
title: t('Verify authentication code'),
description: t(
'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
),
buttonText: t('Continue'),
};
}
return {
title: 'Enable two-factor authentication',
description:
title: t('Enable two-factor authentication'),
description: t(
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
),
buttonText: t('Continue'),
};
}, [twoFactorEnabled, showVerificationStep]);
}, [showVerificationStep, t, twoFactorEnabled]);
const resetModalState = useCallback(() => {
setShowVerificationStep(false);

View File

@@ -2,10 +2,13 @@ import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from "react"
import { useTranslation } from "@/lib/translations"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
const { t } = useTranslation()
return <nav aria-label={t("breadcrumb")} data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -84,6 +87,8 @@ function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
const { t } = useTranslation()
return (
<span
data-slot="breadcrumb-ellipsis"
@@ -93,7 +98,7 @@ function BreadcrumbEllipsis({
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
<span className="sr-only">{t("More")}</span>
</span>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { useTranslation } from '@/lib/translations';
import { login } from '@/routes';
import { store } from '@/routes/register';
@@ -14,9 +15,11 @@ type Props = {
};
export default function Register({ passwordRules }: Props) {
const { t } = useTranslation();
return (
<>
<Head title="Register" />
<Head title={t('Register')} />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
@@ -36,7 +39,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
placeholder={t('Full name')}
/>
<InputError
message={errors.name}
@@ -53,7 +56,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={2}
autoComplete="email"
name="email"
placeholder="email@example.com"
placeholder={t('email@example.com')}
/>
<InputError message={errors.email} />
</div>
@@ -66,7 +69,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
placeholder={t('Password')}
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -82,7 +85,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
placeholder={t('Confirm password')}
passwordrules={passwordRules}
/>
<InputError
@@ -102,9 +105,9 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
{t('Already have an account?')}{' '}
<TextLink href={login()} tabIndex={6}>
Log in
{t('Log in')}
</TextLink>
</div>
</>

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { useTranslation } from '@/lib/translations';
import { update } from '@/routes/password';
type Props = {
@@ -14,9 +15,11 @@ type Props = {
};
export default function ResetPassword({ token, email, passwordRules }: Props) {
const { t } = useTranslation();
return (
<>
<Head title="Reset password" />
<Head title={t('Reset password')} />
<Form
{...update.form()}
@@ -50,7 +53,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
placeholder={t('Password')}
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -65,7 +68,7 @@ export default function ResetPassword({ token, email, passwordRules }: Props) {
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
placeholder={t('Confirm password')}
passwordrules={passwordRules}
/>
<InputError

View File

@@ -10,9 +10,11 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
import { useTranslation } from '@/lib/translations';
import { store } from '@/routes/two-factor/login';
export default function TwoFactorChallenge() {
const { t } = useTranslation();
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
const [code, setCode] = useState<string>('');
@@ -23,20 +25,22 @@ export default function TwoFactorChallenge() {
}>(() => {
if (showRecoveryInput) {
return {
title: 'Recovery code',
description:
title: t('Recovery code'),
description: t(
'Please confirm access to your account by entering one of your emergency recovery codes.',
toggleText: 'login using an authentication code',
),
toggleText: t('login using an authentication code'),
};
}
return {
title: 'Authentication code',
description:
title: t('Authentication code'),
description: t(
'Enter the authentication code provided by your authenticator application.',
toggleText: 'login using a recovery code',
),
toggleText: t('login using a recovery code'),
};
}, [showRecoveryInput]);
}, [showRecoveryInput, t]);
setLayoutProps({
title: authConfigContent.title,
@@ -51,7 +55,7 @@ export default function TwoFactorChallenge() {
return (
<>
<Head title="Two-factor authentication" />
<Head title={t('Two-factor authentication')} />
<div className="space-y-6">
<Form
@@ -67,7 +71,7 @@ export default function TwoFactorChallenge() {
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
placeholder={t('Enter recovery code')}
autoFocus={showRecoveryInput}
required
/>
@@ -113,7 +117,7 @@ export default function TwoFactorChallenge() {
</Button>
<div className="text-center text-sm text-muted-foreground">
<span>or you can </span>
<span>{t('or you can')} </span>
<button
type="button"
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useClipboard } from '@/hooks/use-clipboard';
import { useTranslation } from '@/lib/translations';
import { cn } from '@/lib/utils';
import { dashboard as dashboardRoute } from '@/routes';
import { show as showChannel } from '@/routes/channels';
@@ -75,6 +76,7 @@ export default function Dashboard({
recentBroadcasts,
}: Props) {
const [copiedText, copy] = useClipboard();
const { t } = useTranslation();
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<
string | null
@@ -155,7 +157,7 @@ export default function Dashboard({
return (
<>
<Head title="Creator Studio" />
<Head title={t('Creator Studio')} />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-center gap-4">
@@ -168,16 +170,17 @@ export default function Dashboard({
/>
{pendingBroadcast && !liveBroadcast && (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
{t('READY')}
</span>
)}
</div>
<h1 className="truncate text-2xl font-semibold">
{channel?.display_name ?? 'Creator Studio'}
{channel?.display_name ?? t('Creator Studio')}
</h1>
<p className="text-sm text-muted-foreground">
Prepare broadcasts, secure your stream key, and
monitor the current session.
{t(
'Prepare broadcasts, secure your stream key, and monitor the current session.',
)}
</p>
</div>
</div>
@@ -214,7 +217,7 @@ export default function Dashboard({
event.target.value,
)
}
placeholder="Nyone Live"
placeholder={t('Nyone Live')}
/>
</Field>
<Field
@@ -229,7 +232,7 @@ export default function Dashboard({
event.target.value.toLowerCase(),
)
}
placeholder="nyone-live"
placeholder={t('nyone-live')}
/>
</Field>
<Field label="Category">
@@ -284,9 +287,9 @@ export default function Dashboard({
<div className="border-warning/40 bg-warning/10 text-warning flex items-start gap-3 rounded-md border p-3 text-sm">
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
<span>
This account can browse and follow
channels, but cannot create a channel
yet.
{t(
'This account can browse and follow channels, but cannot create a channel yet.',
)}
</span>
</div>
</Panel>
@@ -306,10 +309,10 @@ export default function Dashboard({
label="Broadcast state"
value={
channel.is_live
? 'Live'
? t('Live')
: pendingBroadcast
? 'Ready'
: 'Offline'
? t('Ready')
: t('Offline')
}
/>
<MetricCard
@@ -323,7 +326,7 @@ export default function Dashboard({
? new Date(
channel.stream_key_last_used_at,
).toLocaleDateString()
: 'Never'
: t('Never')
}
/>
</section>
@@ -348,7 +351,9 @@ export default function Dashboard({
event.target.value,
)
}
placeholder="Building Nyone live"
placeholder={t(
'Building Nyone live',
)}
disabled={
Boolean(liveBroadcast) ||
Boolean(pendingBroadcast)
@@ -373,7 +378,7 @@ export default function Dashboard({
Boolean(pendingBroadcast)
}
/>
Record VOD
{t('Record VOD')}
</label>
<Button
type="submit"
@@ -390,8 +395,9 @@ export default function Dashboard({
</form>
{(liveBroadcast ?? pendingBroadcast) && (
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
Finish the current broadcast before
preparing another session.
{t(
'Finish the current broadcast before preparing another session.',
)}
</div>
)}
</Panel>
@@ -466,7 +472,7 @@ export default function Dashboard({
/>
) : (
<div className="grid h-full place-items-center text-xs text-muted-foreground">
No thumbnail
{t('No thumbnail')}
</div>
)}
</div>
@@ -486,8 +492,9 @@ export default function Dashboard({
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ImagePlus className="size-3.5" />
<span>
Use a 16:9 JPG, PNG, or
WebP up to 4 MB.
{t(
'Use a 16:9 JPG, PNG, or WebP up to 4 MB.',
)}
</span>
{channelForm.data
.thumbnail && (
@@ -584,7 +591,9 @@ export default function Dashboard({
label="Stream key"
value={
streaming.tokenizedPath ??
'Rotate the key to reveal it once.'
t(
'Rotate the key to reveal it once.',
)
}
copied={
copiedText ===
@@ -595,8 +604,9 @@ export default function Dashboard({
/>
{plainStreamKey && (
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
This key is shown once. Store it in
OBS before leaving this page.
{t(
'This key is shown once. Store it in OBS before leaving this page.',
)}
</div>
)}
<Button
@@ -624,8 +634,8 @@ export default function Dashboard({
</div>
<div className="text-sm text-muted-foreground">
{liveBroadcast.recording_enabled
? 'Recording enabled'
: 'Live only'}
? t('Recording enabled')
: t('Live only')}
</div>
</div>
<Button
@@ -647,8 +657,9 @@ export default function Dashboard({
{pendingBroadcast.title}
</div>
<div className="text-sm text-muted-foreground">
Start OBS to push the session
live.
{t(
'Start OBS to push the session live.',
)}
</div>
</div>
</div>
@@ -667,10 +678,11 @@ export default function Dashboard({
>
<DialogContent>
<DialogHeader>
<DialogTitle>Stop broadcast?</DialogTitle>
<DialogTitle>{t('Stop broadcast?')}</DialogTitle>
<DialogDescription>
This ends the live session and clears the channel's
live state. Viewers will see the offline screen.
{t(
"This ends the live session and clears the channel's live state. Viewers will see the offline screen.",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -709,13 +721,15 @@ function Panel({
description?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<section className="min-w-0 rounded-md border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="font-semibold">{title}</h2>
<h2 className="font-semibold">{t(title)}</h2>
{description && (
<p className="text-sm text-muted-foreground">
{description}
{t(description)}
</p>
)}
</div>
@@ -753,13 +767,15 @@ function CategorySelect({
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="">No category</option>
<option value="">{t('No category')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -782,10 +798,12 @@ function CopyRow({
muted?: boolean;
onCopy: (text: string) => Promise<boolean>;
}) {
const { t } = useTranslation();
return (
<div className="grid min-w-0 gap-2">
<div className="text-xs font-medium text-muted-foreground uppercase">
{label}
{t(label)}
</div>
<div className="flex min-w-0 gap-2">
<code
@@ -815,9 +833,11 @@ function CopyRow({
}
function MetricCard({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
return (
<div className="rounded-md border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-sm text-muted-foreground">{t(label)}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
@@ -831,15 +851,24 @@ function BroadcastRow({
onStop: () => void;
}) {
const active = broadcast.status !== 'ended';
const { t } = useTranslation();
const status = t(broadcast.status ?? 'unknown');
const mode = broadcast.recording_enabled ? t('recording') : t('live only');
return (
<div className="flex flex-col gap-3 rounded-md border bg-background p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium">{broadcast.title}</div>
<div className="text-sm text-muted-foreground">
{broadcast.status ?? 'unknown'} -{' '}
{broadcast.recording_enabled ? 'recording' : 'live only'}
{broadcast.has_vod ? ' - VOD ready' : ''}
{broadcast.has_vod
? t(':status - :mode - VOD ready', {
status,
mode,
})
: t(':status - :mode', {
status,
mode,
})}
</div>
</div>
{active && (
@@ -861,11 +890,13 @@ function StatusPill({
ready?: boolean;
viewers?: number;
}) {
const { t } = useTranslation();
if (live) {
return (
<span className="bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE - {viewers} viewers
{t('LIVE - :count viewers', { count: viewers })}
</span>
);
}
@@ -873,14 +904,14 @@ function StatusPill({
if (ready) {
return (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
{t('READY')}
</span>
);
}
return (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
OFFLINE
{t('OFFLINE')}
</span>
);
}
@@ -908,6 +939,8 @@ function ReadinessRow({
done?: boolean;
label: string;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
<span
@@ -918,15 +951,17 @@ function ReadinessRow({
>
{done && <Check className="size-3" />}
</span>
<span className="text-sm">{label}</span>
<span className="text-sm">{t(label)}</span>
</div>
);
}
function EmptyState({ text }: { text: string }) {
const { t } = useTranslation();
return (
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
{text}
{t(text)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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