From 4b84c25b2afd83c14f14289336da224fc891d30d Mon Sep 17 00:00:00 2001 From: basedest Date: Mon, 2 Feb 2026 09:14:07 +0300 Subject: [PATCH 01/12] feat: avatars on article page --- app/api/user/avatar/[username]/route.ts | 34 +++++++++++++++++++ .../api/migrations/rename-content-fields.ts | 22 ------------ src/entities/article/ui/article-item.tsx | 12 +++---- src/entities/tag/ui/tags-list.tsx | 6 ++-- src/features/auth/user-menu/ui/user-menu.tsx | 2 +- src/instrumentation.ts | 29 ---------------- src/shared/ui/badge.tsx | 32 ++++++++++++----- src/views/article/page.tsx | 12 +++++-- 8 files changed, 77 insertions(+), 72 deletions(-) create mode 100644 app/api/user/avatar/[username]/route.ts delete mode 100644 src/entities/article/api/migrations/rename-content-fields.ts diff --git a/app/api/user/avatar/[username]/route.ts b/app/api/user/avatar/[username]/route.ts new file mode 100644 index 0000000..53a7e0c --- /dev/null +++ b/app/api/user/avatar/[username]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { connectDB } from '~/shared/lib/server/connection'; +import { UserModel } from '~/entities/user/model/types'; +import { withApiLogging } from '~/shared/lib/server/with-api-logging'; + +/** Public: redirect to a user's avatar image by username. Avatars are stored as http(s) URLs (S3/MinIO). */ +async function getAvatarByUsernameHandler( + _req: Request, + _logContext: { requestId: string; traceId?: string }, + username: string, +) { + if (!username) { + return new NextResponse(null, { status: 404 }); + } + + await connectDB(); + const user = await UserModel.findOne({ name: username }).select('image').lean(); + if (!user?.image) { + return new NextResponse(null, { status: 404 }); + } + + const image = user.image as string; + if (image.startsWith('http://') || image.startsWith('https://')) { + return NextResponse.redirect(image); + } + + return new NextResponse(null, { status: 404 }); +} + +export const GET = async (req: Request, routeContext: { params: Promise<{ username: string }> }) => { + const { username } = await routeContext.params; + const wrapped = withApiLogging((r, ctx) => getAvatarByUsernameHandler(r, ctx, username)); + return wrapped(req); +}; diff --git a/src/entities/article/api/migrations/rename-content-fields.ts b/src/entities/article/api/migrations/rename-content-fields.ts deleted file mode 100644 index 7029842..0000000 --- a/src/entities/article/api/migrations/rename-content-fields.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Rename article content fields from snake_case to camelCase for consistency. - * content_pm -> contentPm, content_format -> contentFormat, content_schema_version -> contentSchemaVersion. - * Idempotent: only renames fields that exist (MongoDB $rename is a no-op for missing fields). - * Uses native collection.updateMany() so $rename is executed and acknowledged by MongoDB. - */ - -import { ArticleModel } from '~/entities/article/model/types'; - -export async function migrateRenameContentFields(): Promise<{ modified: number }> { - const result = await ArticleModel.collection.updateMany( - {}, - { - $rename: { - content_pm: 'contentPm', - content_format: 'contentFormat', - content_schema_version: 'contentSchemaVersion', - }, - }, - ); - return { modified: result.modifiedCount ?? 0 }; -} diff --git a/src/entities/article/ui/article-item.tsx b/src/entities/article/ui/article-item.tsx index f1dd598..ff26f65 100644 --- a/src/entities/article/ui/article-item.tsx +++ b/src/entities/article/ui/article-item.tsx @@ -67,7 +67,7 @@ export function ArticleItem(props: Article) { - +
- +
{props.category} @@ -92,8 +92,8 @@ export function ArticleItem(props: Article) { {props.description} - -
+ +
@{props.author} {new Date(props.createdAt).toLocaleDateString()} @@ -103,14 +103,14 @@ export function ArticleItem(props: Article) { {canEdit && (
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..e326f27 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, setRequestLocale } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { notFound } from 'next/navigation'; +import { routing } from '~/i18n/routing'; +import { AppProviders } from '~/app/providers'; +import { Header } from '~/widgets/header'; +import { Footer } from '~/widgets/footer'; + +type Props = { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export const metadata: Metadata = { + title: 'Articlify - Modern Blog Platform', + description: 'A modern blog platform', +}; + +export default async function LocaleLayout({ children, params }: Props) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + + return ( + + +
+
+
{children}
+
+
+
+
+ ); +} diff --git a/app/loading.tsx b/app/[locale]/loading.tsx similarity index 100% rename from app/loading.tsx rename to app/[locale]/loading.tsx diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx new file mode 100644 index 0000000..7d6016d --- /dev/null +++ b/app/[locale]/not-found.tsx @@ -0,0 +1,26 @@ +import { Link } from '~/i18n/navigation'; +import { Button } from '~/shared/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; +import { getTranslations } from 'next-intl/server'; + +export default async function NotFound() { + const t = await getTranslations('common'); + const tButton = await getTranslations('button'); + + return ( +
+ + + 404 + {t('pageNotFound')} + + +

{t('pageNotFoundDescription')}

+ +
+
+
+ ); +} diff --git a/app/page.tsx b/app/[locale]/page.tsx similarity index 66% rename from app/page.tsx rename to app/[locale]/page.tsx index fdd6465..069a087 100644 --- a/app/page.tsx +++ b/app/[locale]/page.tsx @@ -1,9 +1,10 @@ import { HomePage } from '~/views/home'; interface PageProps { + params: Promise<{ locale: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page(props: PageProps) { - return ; + return ; } diff --git a/app/layout.tsx b/app/layout.tsx index 2c3144e..6d14ac0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,32 +1,17 @@ -import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import '~/app/styles/globals.css'; -import { AppProviders } from '~/app/providers'; -import { Header } from '~/widgets/header'; -import { Footer } from '~/widgets/footer'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; const inter = Inter({ subsets: ['latin'] }); -export const metadata: Metadata = { - title: 'Articlify - Modern Blog Platform', - description: 'A modern blog platform built with Next.js, tRPC, and shadcn/ui', -}; - export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - -
-
-
{children}
-
-
- - -
+ {children} + + ); diff --git a/app/not-found.tsx b/app/not-found.tsx index 5793a99..dcee10e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; -export default function NotFound() { +export default function RootNotFound() { return (
diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index 7656ca5..f10b495 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,7 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); + /** @type {import('next').NextConfig} */ // Derive image config from storage env (local MinIO vs prod S3) @@ -41,4 +45,4 @@ const nextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 9c11f1e..5ad7b5f 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "mongoose": "^9.1.5", "next": "^16.1.6", "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.8.2", "next-themes": "^0.4.6", "pino": "^10.3.0", "pino-pretty": "^13.1.3", diff --git a/src/entities/article/ui/article-item.tsx b/src/entities/article/ui/article-item.tsx index ff26f65..62d327f 100644 --- a/src/entities/article/ui/article-item.tsx +++ b/src/entities/article/ui/article-item.tsx @@ -2,8 +2,8 @@ import { useSession } from 'next-auth/react'; import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { Link, useRouter } from '~/i18n/navigation'; import { useState } from 'react'; import type { Article } from '~/entities/article/model/types'; import { TagsList } from '~/entities/tag/ui/tags-list'; @@ -18,6 +18,7 @@ import { useToast } from '~/shared/ui/use-toast'; export function ArticleItem(props: Article) { const img = props.img ?? `/img/${props.category}.png`; const { data: session } = useSession(); + const tCategory = useTranslations('category'); const [dialogOpen, setDialogOpen] = useState(false); const router = useRouter(); const { toast } = useToast(); @@ -81,8 +82,8 @@ export function ArticleItem(props: Article) {
- - {props.category} + + {tCategory(props.category)}
diff --git a/src/entities/tag/ui/tags-list.tsx b/src/entities/tag/ui/tags-list.tsx index 1831917..19b7dba 100644 --- a/src/entities/tag/ui/tags-list.tsx +++ b/src/entities/tag/ui/tags-list.tsx @@ -1,4 +1,4 @@ -import Link from 'next/link'; +import { Link } from '~/i18n/navigation'; import React from 'react'; import { Badge } from '~/shared/ui/badge'; diff --git a/src/entities/tag/ui/tags-picker.tsx b/src/entities/tag/ui/tags-picker.tsx index cbc3a17..f8dd446 100644 --- a/src/entities/tag/ui/tags-picker.tsx +++ b/src/entities/tag/ui/tags-picker.tsx @@ -1,6 +1,7 @@ 'use client'; import React from 'react'; +import { useTranslations } from 'next-intl'; import { MultiSelect, type Option } from '~/shared/ui/multi-select'; const tagOptions: Option[] = [ @@ -22,12 +23,13 @@ interface TagsPickerProps { } export function TagsPicker({ value, onChange, defaultValue }: TagsPickerProps) { + const t = useTranslations('editor'); return ( ); diff --git a/src/entities/user/api/user.router.ts b/src/entities/user/api/user.router.ts index 188b202..d1f3729 100644 --- a/src/entities/user/api/user.router.ts +++ b/src/entities/user/api/user.router.ts @@ -82,4 +82,14 @@ export const userRouter = router({ return await userService.updateAvatar(userId, imageUrl); }), + + updatePreferredLanguage: protectedProcedure + .input(z.object({ locale: z.enum(['en', 'ru']) })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user.id; + if (!userId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'User ID not found' }); + } + return await userService.updatePreferredLanguage(userId, input.locale); + }), }); diff --git a/src/entities/user/api/user.service.ts b/src/entities/user/api/user.service.ts index cdbb775..7d798e3 100644 --- a/src/entities/user/api/user.service.ts +++ b/src/entities/user/api/user.service.ts @@ -43,6 +43,17 @@ export class UserService { } return user; } + + async updatePreferredLanguage(id: string, locale: string) { + const user = await userRepository.updateById(id, { preferredLanguage: locale }); + if (!user) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User not found', + }); + } + return user; + } } export const userService = new UserService(); diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index 4b03a52..0fca90c 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -8,6 +8,7 @@ export interface User { regDate: Date; role?: string; image?: string; + preferredLanguage?: string; id?: Types.ObjectId; } @@ -18,6 +19,7 @@ const UserSchema = new Schema({ regDate: { type: Date, required: true }, role: String, image: String, + preferredLanguage: String, }); UserSchema.pre('save', async function () { diff --git a/src/features/auth/access-denied/ui/access-denied.tsx b/src/features/auth/access-denied/ui/access-denied.tsx index 92fc140..185c44e 100644 --- a/src/features/auth/access-denied/ui/access-denied.tsx +++ b/src/features/auth/access-denied/ui/access-denied.tsx @@ -1,9 +1,15 @@ -import Link from 'next/link'; +'use client'; + +import { Link } from '~/i18n/navigation'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; import { ShieldAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; export function AccessDenied({ callbackUrl }: { callbackUrl: string }) { + const t = useTranslations('auth'); + const tNav = useTranslations('nav'); + return (
@@ -11,12 +17,12 @@ export function AccessDenied({ callbackUrl }: { callbackUrl: string }) {
- Access Denied - You need to be signed in to view this page + {t('accessDenied')} + {t('accessDeniedDescription')}
diff --git a/src/features/auth/login/ui/login-form.tsx b/src/features/auth/login/ui/login-form.tsx index 29a2f2e..bde218c 100644 --- a/src/features/auth/login/ui/login-form.tsx +++ b/src/features/auth/login/ui/login-form.tsx @@ -2,19 +2,26 @@ import { useState } from 'react'; import { signIn } from 'next-auth/react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; +import { useRouter } from '~/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; +import { Link } from '~/i18n/navigation'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; import { Input } from '~/shared/ui/input'; import { Label } from '~/shared/ui/label'; import { Alert, AlertDescription } from '~/shared/ui/alert'; import { Loader2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { LanguageSwitcher } from '~/features/i18n'; export function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/'; + const t = useTranslations('auth'); + const tForm = useTranslations('form'); + const tButton = useTranslations('button'); + const tError = useTranslations('error'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -36,13 +43,13 @@ export function LoginForm() { }); if (result?.error) { - setError('Invalid username or password'); + setError(tError('invalidCredentials')); } else { router.push(callbackUrl); router.refresh(); } } catch { - setError('An error occurred. Please try again.'); + setError(tError('generic')); } finally { setIsLoading(false); } @@ -51,8 +58,13 @@ export function LoginForm() { return ( - Login - Enter your credentials to access your account +
+
+ {t('loginTitle')} + {t('loginDescription')} +
+ +
@@ -63,11 +75,11 @@ export function LoginForm() { )}
- + setFormData({ ...formData, username: e.target.value })} required @@ -75,11 +87,11 @@ export function LoginForm() {
- + setFormData({ ...formData, password: e.target.value })} required @@ -90,17 +102,17 @@ export function LoginForm() { {isLoading ? ( <> - Logging in... + {t('loggingIn')} ) : ( - 'Login' + tButton('login') )}

- Don't have an account?{' '} + {t('dontHaveAccount')}{' '} - Register + {tButton('register')}

diff --git a/src/features/auth/register/ui/register-form.tsx b/src/features/auth/register/ui/register-form.tsx index c2e5a0a..6c3fd1b 100644 --- a/src/features/auth/register/ui/register-form.tsx +++ b/src/features/auth/register/ui/register-form.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { signIn } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; +import { useRouter } from '~/i18n/navigation'; +import { Link } from '~/i18n/navigation'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; import { Input } from '~/shared/ui/input'; @@ -11,9 +11,16 @@ import { Label } from '~/shared/ui/label'; import { Alert, AlertDescription } from '~/shared/ui/alert'; import { Loader2 } from 'lucide-react'; import { trpc } from '~/shared/api/trpc/client'; +import { useTranslations } from 'next-intl'; +import { LanguageSwitcher } from '~/features/i18n'; export function RegisterForm() { const router = useRouter(); + const t = useTranslations('auth'); + const tForm = useTranslations('form'); + const tButton = useTranslations('button'); + const tError = useTranslations('error'); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [formData, setFormData] = useState({ @@ -31,7 +38,7 @@ export function RegisterForm() { setError(''); if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match'); + setError(tError('passwordsDoNotMatch')); setIsLoading(false); return; } @@ -50,13 +57,13 @@ export function RegisterForm() { }); if (result?.error) { - setError('Registration successful, but login failed. Please try logging in.'); + setError(tError('registrationSuccessLoginFailed')); } else { router.push('/dashboard'); router.refresh(); } } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Registration failed. Please try again.'); + setError(err instanceof Error ? err.message : tError('registrationFailed')); } finally { setIsLoading(false); } @@ -65,8 +72,13 @@ export function RegisterForm() { return ( - Register - Create a new account to start publishing articles +
+
+ {t('registerTitle')} + {t('registerDescription')} +
+ +
@@ -77,11 +89,11 @@ export function RegisterForm() { )}
- + setFormData({ ...formData, username: e.target.value })} required @@ -91,11 +103,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, email: e.target.value })} required @@ -103,11 +115,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, password: e.target.value })} required @@ -116,11 +128,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, confirmPassword: e.target.value })} required @@ -131,17 +143,17 @@ export function RegisterForm() { {isLoading ? ( <> - Creating account... + {t('creatingAccount')} ) : ( - 'Register' + tButton('register') )}

- Already have an account?{' '} + {t('alreadyHaveAccount')}{' '} - Login + {tButton('login')}

diff --git a/src/features/auth/user-menu/ui/user-menu.tsx b/src/features/auth/user-menu/ui/user-menu.tsx index 4e820f6..68528b9 100644 --- a/src/features/auth/user-menu/ui/user-menu.tsx +++ b/src/features/auth/user-menu/ui/user-menu.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { signOut, useSession } from 'next-auth/react'; -import Link from 'next/link'; +import { Link } from '~/i18n/navigation'; import { DropdownMenu, DropdownMenuContent, @@ -14,9 +14,12 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '~/shared/ui/avatar'; import { Button } from '~/shared/ui/button'; import { LogOut, User, ChevronDown } from 'lucide-react'; +import { useTranslations } from 'next-intl'; export function UserMenu() { const { data: session } = useSession(); + const t = useTranslations('auth'); + const tButton = useTranslations('button'); if (!session?.user) { return null; @@ -35,12 +38,12 @@ export function UserMenu() { - My Account + {t('myAccount')} - Profile + {t('profile')} @@ -49,7 +52,7 @@ export function UserMenu() { className="text-destructive focus:bg-destructive focus:text-destructive-foreground hover:bg-destructive hover:text-destructive-foreground cursor-pointer dark:text-red-400" > - Sign Out + {tButton('signOut')} diff --git a/src/features/avatar/upload/ui/avatar-editor.tsx b/src/features/avatar/upload/ui/avatar-editor.tsx index 9928cc0..a0ff5ae 100644 --- a/src/features/avatar/upload/ui/avatar-editor.tsx +++ b/src/features/avatar/upload/ui/avatar-editor.tsx @@ -2,7 +2,8 @@ import React, { useRef, useState } from 'react'; import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '~/i18n/navigation'; import { Avatar, AvatarFallback, AvatarImage } from '~/shared/ui/avatar'; import { Button } from '~/shared/ui/button'; import { Camera, Loader2 } from 'lucide-react'; @@ -16,6 +17,7 @@ export function AvatarEditor() { const { data: session, update: updateSession } = useSession(); const router = useRouter(); const { toast } = useToast(); + const t = useTranslations('avatar'); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); @@ -24,13 +26,13 @@ export function AvatarEditor() { await updateSession(); router.refresh(); toast({ - title: 'Avatar updated', - description: 'Your profile picture has been updated.', + title: t('avatarUpdated'), + description: t('avatarUpdatedDescription'), }); }, onError: (error) => { toast({ - title: 'Upload failed', + title: t('uploadFailed'), description: error.message, variant: 'destructive', }); @@ -44,8 +46,8 @@ export function AvatarEditor() { if (file.size > MAX_SIZE_BYTES) { toast({ - title: 'File too large', - description: 'Image must be smaller than 2MB.', + title: t('fileTooLarge'), + description: t('imageTooLarge'), variant: 'destructive', }); e.target.value = ''; @@ -70,7 +72,7 @@ export function AvatarEditor() { if (!session?.user) return null; const user = session.user; - const displayName = user.name ?? 'User'; + const displayName = user.name ?? t('user'); return (
@@ -81,7 +83,7 @@ export function AvatarEditor() { onChange={handleFileChange} className="sr-only" disabled={uploading} - aria-label="Change avatar" + aria-label={t('changeAvatar')} />
@@ -95,14 +97,16 @@ export function AvatarEditor() { className="absolute right-0 bottom-0 h-8 w-8 rounded-full shadow" onClick={() => inputRef.current?.click()} disabled={uploading} - aria-label="Change avatar" + aria-label={t('changeAvatar')} > {uploading ? : }

{displayName}

-

{user.role === 'admin' ? 'Administrator' : 'User'}

+

+ {user.role === 'admin' ? t('administrator') : t('user')} +

diff --git a/src/features/i18n/index.ts b/src/features/i18n/index.ts new file mode 100644 index 0000000..78f5497 --- /dev/null +++ b/src/features/i18n/index.ts @@ -0,0 +1 @@ +export { LanguageSwitcher } from './ui/language-switcher'; diff --git a/src/features/i18n/ui/language-switcher.tsx b/src/features/i18n/ui/language-switcher.tsx new file mode 100644 index 0000000..e9b56eb --- /dev/null +++ b/src/features/i18n/ui/language-switcher.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useLocale } from 'next-intl'; +import { usePathname, useRouter } from '~/i18n/navigation'; +import { useSession } from 'next-auth/react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/shared/ui/select'; +import { Label } from '~/shared/ui/label'; +import { useTranslations } from 'next-intl'; +import { trpc } from '~/shared/api/trpc/client'; +import { routing } from '~/i18n/routing'; +import { cn } from '~/shared/lib/utils'; + +const LOCALE_COOKIE = 'NEXT_LOCALE'; + +function setLocaleCookie(locale: string) { + document.cookie = `${LOCALE_COOKIE}=${locale};path=/;max-age=31536000;SameSite=Lax`; +} + +interface LanguageSwitcherProps { + id?: string; + className?: string; + variant?: 'default' | 'compact'; +} + +export function LanguageSwitcher({ id, className, variant = 'default' }: LanguageSwitcherProps) { + const locale = useLocale(); + const pathname = usePathname(); + const router = useRouter(); + const { data: session } = useSession(); + const t = useTranslations('footer'); + const tCommon = useTranslations('common'); + const updatePreferredLanguageMutation = trpc.user.updatePreferredLanguage.useMutation(); + + const handleChange = (newLocale: string) => { + if (!routing.locales.includes(newLocale as 'en' | 'ru')) return; + router.replace(pathname, { locale: newLocale }); + if (typeof window !== 'undefined') { + localStorage.setItem('preferredLanguage', newLocale); + setLocaleCookie(newLocale); + } + if (session?.user?.id) { + updatePreferredLanguageMutation.mutateAsync({ locale: newLocale as 'en' | 'ru' }).catch(() => {}); + } + }; + + if (variant === 'compact') { + return ( +
+ {routing.locales.map((loc) => ( + + ))} +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts new file mode 100644 index 0000000..a10a415 --- /dev/null +++ b/src/i18n/navigation.ts @@ -0,0 +1,4 @@ +import { createNavigation } from 'next-intl/navigation'; +import { routing } from './routing'; + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..1e133e3 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,15 @@ +import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; + + const messages = (await import(`../shared/lib/locales/${locale}.json`)).default; + + return { + locale, + messages, + }; +}); diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts new file mode 100644 index 0000000..2cb4399 --- /dev/null +++ b/src/i18n/routing.ts @@ -0,0 +1,7 @@ +import { defineRouting } from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['en', 'ru'], + defaultLocale: 'en', + localePrefix: 'always', +}); diff --git a/src/proxy.ts b/src/proxy.ts index bb14aac..bbf0649 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,22 +1,8 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; +import createMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; -// Lightweight proxy - just for redirects or simple checks -// Auth protection is handled in app/(protected)/layout.tsx server-side -export function proxy(request: NextRequest) { - // You can add simple redirects here if needed - return NextResponse.next(); -} +export default createMiddleware(routing); export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - */ - '/((?!api|_next/static|_next/image|favicon.ico).*)', - ], + matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'], }; diff --git a/src/shared/lib/locales/en.json b/src/shared/lib/locales/en.json new file mode 100644 index 0000000..5fcc388 --- /dev/null +++ b/src/shared/lib/locales/en.json @@ -0,0 +1,161 @@ +{ + "nav": { + "home": "Home", + "about": "About", + "categories": "Categories", + "editor": "Editor", + "signIn": "Sign In", + "brand": "Articlify" + }, + "button": { + "save": "Save", + "cancel": "Cancel", + "login": "Login", + "register": "Register", + "signOut": "Sign Out", + "tryAgain": "Try again", + "goHome": "Go Home", + "selectTheme": "Select theme" + }, + "form": { + "email": "Email", + "password": "Password", + "username": "Username", + "confirmPassword": "Confirm Password", + "submit": "Submit", + "placeholderUsername": "Enter your username", + "placeholderPassword": "Enter your password", + "placeholderChooseUsername": "Choose a username", + "placeholderEmail": "your@email.com", + "placeholderCreatePassword": "Create a password", + "placeholderConfirmPassword": "Confirm your password" + }, + "error": { + "network": "Network error, please try again", + "unauthorized": "You are not authorized", + "invalidCredentials": "Invalid username or password", + "generic": "An error occurred. Please try again.", + "passwordsDoNotMatch": "Passwords do not match", + "registrationFailed": "Registration failed. Please try again.", + "registrationSuccessLoginFailed": "Registration successful, but login failed. Please try logging in.", + "unexpected": "An unexpected error occurred." + }, + "footer": { + "language": "Language", + "theme": "Theme", + "themeSystem": "System", + "themeDark": "Dark", + "themeLight": "Light", + "madeBy": "Made by", + "author": "Ivan Shcherbakov", + "version": "Version", + "copyright": "Copyright © 2022-2025 Articlify Inc. All rights reserved." + }, + "auth": { + "loginTitle": "Login", + "loginDescription": "Enter your credentials to access your account", + "loggingIn": "Logging in...", + "registerTitle": "Register", + "registerDescription": "Create a new account to start publishing articles", + "creatingAccount": "Creating account...", + "dontHaveAccount": "Don't have an account?", + "alreadyHaveAccount": "Already have an account?", + "accessDenied": "Access Denied", + "accessDeniedDescription": "You need to be signed in to view this page", + "myAccount": "My Account", + "profile": "Profile" + }, + "common": { + "pageNotFound": "Page Not Found", + "pageNotFoundDescription": "The page you are looking for doesn't exist or has been moved.", + "somethingWentWrong": "Something went wrong!", + "english": "English", + "russian": "Русский" + }, + "home": { + "tagline": "A place with articles and without cancel-culture." + }, + "category": { + "art": "Art", + "it": "IT", + "games": "Games", + "music": "Music", + "science": "Science", + "sports": "Sports", + "travel": "Travel", + "movies": "Movies", + "other": "Other" + }, + "articles": { + "noArticles": "No articles", + "searchResults": "Search results", + "latestArticles": "Latest articles", + "searchByTitle": "Search by title...", + "articlesCount": "{count} articles", + "browseCategory": "Browse {category} category", + "returnToAllArticles": "Return to all articles", + "lastUpdated": "Last updated:", + "categoryPageTitle": "{category} Articles | Articlify", + "categoryPageDescription": "Browse all articles in the {category} category" + }, + "dashboard": { + "title": "Dashboard", + "profile": "Profile", + "profileDescription": "Your account information", + "memberSince": "Member since", + "viewYourArticles": "View Your Articles", + "quickActions": "Quick Actions", + "manageContent": "Manage your content", + "createNewArticle": "Create New Article", + "manageArticles": "Manage Articles", + "browseArticles": "Browse Articles" + }, + "editor": { + "placeholder": "Start writing...", + "loadingEditor": "Loading editor…", + "editArticle": "Edit Article", + "createNewArticle": "Create New Article", + "title": "Title", + "description": "Description", + "category": "Category", + "tags": "Tags", + "coverImage": "Cover Image", + "content": "Content", + "placeholderTitle": "Enter article title...", + "placeholderDescription": "Brief description of your article...", + "selectCategory": "Select a category...", + "selectOrCreateTags": "Select or create tags...", + "coverImageNote": "Image will be converted to 2:1 ratio", + "saving": "Saving...", + "updateArticle": "Update Article", + "publishArticle": "Publish Article", + "success": "Success", + "articleCreated": "Article created successfully", + "articleUpdated": "Article updated successfully", + "error": "Error", + "validationError": "Validation Error", + "titleCategoryDescriptionRequired": "Title, category, and description are required", + "failedToSave": "Failed to save article", + "noPermissionToEdit": "You do not have permission to edit this article", + "preview": "Preview" + }, + "pagination": { + "previous": "Previous", + "next": "Next", + "morePages": "More pages", + "goToPreviousPage": "Go to previous page", + "goToNextPage": "Go to next page", + "ariaLabel": "Pagination" + }, + "avatar": { + "administrator": "Administrator", + "user": "User", + "changeAvatar": "Change avatar", + "uploading": "Uploading…", + "avatarUpdated": "Avatar updated", + "avatarUpdatedDescription": "Your profile picture has been updated.", + "uploadFailed": "Upload failed", + "fileTooLarge": "File too large", + "imageTooLarge": "Image must be smaller than 2MB." + } +} diff --git a/src/shared/lib/locales/ru.json b/src/shared/lib/locales/ru.json new file mode 100644 index 0000000..bc6f4f3 --- /dev/null +++ b/src/shared/lib/locales/ru.json @@ -0,0 +1,161 @@ +{ + "nav": { + "home": "Главная", + "about": "О сервисе", + "categories": "Категории", + "editor": "Редактор", + "signIn": "Войти", + "brand": "Articlify" + }, + "button": { + "save": "Сохранить", + "cancel": "Отмена", + "login": "Войти", + "register": "Регистрация", + "signOut": "Выйти", + "tryAgain": "Попробовать снова", + "goHome": "На главную", + "selectTheme": "Выберите тему" + }, + "form": { + "email": "Эл. почта", + "password": "Пароль", + "username": "Имя пользователя", + "confirmPassword": "Подтвердите пароль", + "submit": "Отправить", + "placeholderUsername": "Введите имя пользователя", + "placeholderPassword": "Введите пароль", + "placeholderChooseUsername": "Выберите имя пользователя", + "placeholderEmail": "your@email.com", + "placeholderCreatePassword": "Придумайте пароль", + "placeholderConfirmPassword": "Подтвердите пароль" + }, + "error": { + "network": "Ошибка сети, попробуйте снова", + "unauthorized": "У вас нет доступа", + "invalidCredentials": "Неверное имя пользователя или пароль", + "generic": "Произошла ошибка. Попробуйте снова.", + "passwordsDoNotMatch": "Пароли не совпадают", + "registrationFailed": "Ошибка регистрации. Попробуйте снова.", + "registrationSuccessLoginFailed": "Регистрация прошла успешно, но вход не удался. Попробуйте войти.", + "unexpected": "Произошла непредвиденная ошибка." + }, + "footer": { + "language": "Язык", + "theme": "Тема", + "themeSystem": "Системная", + "themeDark": "Тёмная", + "themeLight": "Светлая", + "madeBy": "Автор", + "author": "Иван Щербаков", + "version": "Версия", + "copyright": "© 2022-2025 Articlify Inc. Все права защищены." + }, + "auth": { + "loginTitle": "Вход", + "loginDescription": "Введите данные для доступа к аккаунту", + "loggingIn": "Вход...", + "registerTitle": "Регистрация", + "registerDescription": "Создайте аккаунт, чтобы публиковать статьи", + "creatingAccount": "Создание аккаунта...", + "dontHaveAccount": "Нет аккаунта?", + "alreadyHaveAccount": "Уже есть аккаунт?", + "accessDenied": "Доступ запрещён", + "accessDeniedDescription": "Для просмотра этой страницы необходимо войти", + "myAccount": "Мой аккаунт", + "profile": "Профиль" + }, + "common": { + "pageNotFound": "Страница не найдена", + "pageNotFoundDescription": "Запрашиваемая страница не существует или была перемещена.", + "somethingWentWrong": "Что-то пошло не так!", + "english": "English", + "russian": "Русский" + }, + "home": { + "tagline": "Пространство для вдумчивых текстов — без культуры отмены." + }, + "category": { + "art": "Искусство", + "it": "IT", + "games": "Игры", + "music": "Музыка", + "science": "Наука", + "sports": "Спорт", + "travel": "Путешествия", + "movies": "Кино", + "other": "Другое" + }, + "articles": { + "noArticles": "Нет статей", + "searchResults": "Результаты поиска", + "latestArticles": "Последние статьи", + "searchByTitle": "Поиск по названию...", + "articlesCount": "{count} статей", + "browseCategory": "Статьи в разделе «{category}»", + "returnToAllArticles": "Все статьи", + "lastUpdated": "Обновлено:", + "categoryPageTitle": "Статьи — {category} | Articlify", + "categoryPageDescription": "Все статьи в разделе «{category}»" + }, + "dashboard": { + "title": "Панель управления", + "profile": "Профиль", + "profileDescription": "Информация об аккаунте", + "memberSince": "Участник с", + "viewYourArticles": "Мои статьи", + "quickActions": "Быстрые действия", + "manageContent": "Управление контентом", + "createNewArticle": "Создать статью", + "manageArticles": "Управление статьями", + "browseArticles": "Все статьи" + }, + "editor": { + "placeholder": "Начните писать...", + "loadingEditor": "Загрузка редактора…", + "editArticle": "Редактировать статью", + "createNewArticle": "Создать статью", + "title": "Заголовок", + "description": "Описание", + "category": "Категория", + "tags": "Теги", + "coverImage": "Обложка", + "content": "Содержание", + "placeholderTitle": "Введите заголовок статьи...", + "placeholderDescription": "Краткое описание статьи...", + "selectCategory": "Выберите категорию...", + "selectOrCreateTags": "Выберите или создайте теги...", + "coverImageNote": "Изображение будет приведено к формату 2:1", + "saving": "Сохранение...", + "updateArticle": "Обновить статью", + "publishArticle": "Опубликовать", + "success": "Успешно", + "articleCreated": "Статья успешно создана", + "articleUpdated": "Статья успешно обновлена", + "error": "Ошибка", + "validationError": "Ошибка проверки", + "titleCategoryDescriptionRequired": "Заполните заголовок, категорию и описание", + "failedToSave": "Не удалось сохранить статью", + "noPermissionToEdit": "У вас нет прав на редактирование этой статьи", + "preview": "Превью" + }, + "pagination": { + "previous": "Назад", + "next": "Вперёд", + "morePages": "Ещё страницы", + "goToPreviousPage": "Перейти на предыдущую страницу", + "goToNextPage": "Перейти на следующую страницу", + "ariaLabel": "Пагинация" + }, + "avatar": { + "administrator": "Администратор", + "user": "Пользователь", + "changeAvatar": "Изменить аватар", + "uploading": "Загрузка…", + "avatarUpdated": "Аватар обновлён", + "avatarUpdatedDescription": "Фото профиля обновлено.", + "uploadFailed": "Ошибка загрузки", + "fileTooLarge": "Файл слишком большой", + "imageTooLarge": "Размер изображения не должен превышать 2 МБ." + } +} diff --git a/src/shared/ui/pagination.tsx b/src/shared/ui/pagination.tsx index a5c6aee..5f718d6 100644 --- a/src/shared/ui/pagination.tsx +++ b/src/shared/ui/pagination.tsx @@ -1,14 +1,18 @@ +'use client'; + import * as React from 'react'; import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { cn } from '~/shared/lib/utils'; import { buttonVariants, type Button } from '~/shared/ui/button'; function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + const t = useTranslations('pagination'); return (