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

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