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>
);
}