Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {NextConfig} from 'next';
import manifest from './package.json';
import {execSync} from 'child_process';
import createNextIntlPlugin from 'next-intl/plugin';

function run(cmd: string) {
return execSync(cmd).toString().trim();
Expand All @@ -17,7 +18,8 @@ function getCommitHash(): string {

const longVersionName = `${manifest.version}-${getGitBranchFormatted()}+${getCommitHash()}`;

const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = withNextIntl({
output: 'export',
basePath: '',
images: {
Expand All @@ -26,6 +28,6 @@ const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_APP_VERSION: longVersionName,
},
};
});

export default nextConfig;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"lucide": "^0.556.0",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"next-intl": "^4.9.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
Expand Down
10 changes: 8 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {RootContainer} from '@/components/root-container';
import {BackendProvider} from '@/backend.context';
import {QueryProvider} from '@/components/query-provider';
import {SessionProvider} from '@/components/session-provider';
import {useLocale} from 'next-intl';
import IntlProvider from '@/components/intl-provider';

export const metadata: Metadata = {
title: 'Friendly Web',
Expand All @@ -17,8 +19,10 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = useLocale();

return (
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<head>
<meta
name="viewport"
Expand All @@ -34,7 +38,9 @@ export default function RootLayout({
<BackendProvider>
<SessionProvider>
<QueryProvider>
<RootContainer>{children}</RootContainer>
<IntlProvider>
<RootContainer>{children}</RootContainer>
</IntlProvider>
</QueryProvider>
</SessionProvider>
</BackendProvider>
Expand Down
33 changes: 22 additions & 11 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {formatNetworkError} from '@/services/backend-service';
import {createFileLink, createFriendInviteLink} from '@/lib/utils';
import {useQuery} from '@tanstack/react-query';
import {useSession} from '@/components/session-provider';
import {useTranslations} from 'next-intl';

function ProfileHeader({
userDetails,
Expand All @@ -31,6 +32,8 @@ function ProfileHeader({
userDetails: UserDetailsResponse | null;
logOut: () => void;
}) {
const t = useTranslations('profile');

const avatarUrl = useMemo(
() => (userDetails?.avatar ? createFileLink(userDetails.avatar) : ''),
[userDetails],
Expand Down Expand Up @@ -62,7 +65,7 @@ function ProfileHeader({
>
<Link href="#">
<Pencil className="w-4 h-4 sm:mr-2" />
<p className="hidden sm:block">Edit profile</p>
<p className="hidden sm:block">{t('edit_profile')}</p>
</Link>
</Button>
<Button
Expand All @@ -71,18 +74,20 @@ function ProfileHeader({
onClick={logOut}
>
<LogOut className="w-4 h-4 sm:mr-2" />
<p className="hidden sm:block">LogOut</p>
<p className="hidden sm:block">{t('log_out')}</p>
</Button>
</div>
</div>
);
}

function InterestsBlock({interests}: {interests: string[]}) {
const t = useTranslations('profile');

return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold uppercase mb-2 text-zinc-900 dark:text-zinc-100">
Interests
{t('interests')}
</h3>
<div className="flex flex-row gap-2 flex-wrap">
{interests.map(interest => (
Expand Down Expand Up @@ -125,39 +130,43 @@ function FriendCard({friend}: {friend: UserDetailsResponse}) {
}

function FriendsBlock({friends}: {friends: UserDetailsResponse[]}) {
const t = useTranslations('profile');

return (
<div className="flex flex-col gap-2">
<h3 className="flex flex-row gap-2 mb-2">
<p className="flex-1 text-sm font-semibold uppercase text-zinc-900 dark:text-zinc-100">
Friends
{t('friends.title')}
</p>
<Link
href="#"
className="text-sm text-neutral-700 dark:text-zinc-400 font-normal hover:underline"
hidden={friends.length < 1}
>
All friends
{t('friends.see_all')}
</Link>
</h3>
<div className="flex flex-row gap-2 flex-nowrap">
{friends.slice(0, 3).map(friend => (
<FriendCard key={friend.id} friend={friend} />
))}
<p hidden={friends.length > 0}>You have no any frieds yet.</p>
<p hidden={friends.length > 0}>{t('friends.no_friends')}</p>
</div>
</div>
);
}

function QrCodeCard({url}: {url: string | null}) {
const t = useTranslations('profile');

return (
<div className="md:w-1/4 md:h-fit md:mt-4 md:mr-8 flex flex-col items-center md:items-start gap-6 p-4 md:rounded-xl md:border md:border-zinc-200 dark:md:border-zinc-800 md:bg-white dark:md:bg-zinc-900 text-sm">
<div className="flex flex-col gap-2 pl-2 pt-2 pr-2">
<div className="flex flex-row gap-2 items-center font-medium text-zinc-900 dark:text-zinc-100">
<QrCodeIcon className="w-4 h-4" /> My QR Code
<QrCodeIcon className="w-4 h-4" /> {t('qr.title')}
</div>
<p className="text-neutral-700 dark:text-zinc-400">
Share your profile to make connections.
{t('qr.desc')}
</p>
</div>
<div className="w-full flex flex-col items-center">
Expand All @@ -178,22 +187,24 @@ function QrCodeCard({url}: {url: string | null}) {
void navigator.clipboard.writeText(url ?? '');
}}
>
<Copy className="w-4 h-4 mr-2" /> Copy
<Copy className="w-4 h-4 mr-2" /> {t('qr.copy')}
</Button>
{/* TODO: Impl saving QR as file (Do we really need this?) */}
<Button
hidden={true}
variant="outline"
className="flex-1 dark:bg-zinc-950 dark:hover:bg-zinc-800 cursor-pointer"
>
<Save className="w-4 h-4 mr-2" /> Save
<Save className="w-4 h-4 mr-2" /> {t('qr.save')}
</Button>
</div>
</div>
);
}

export default function Home() {
const t = useTranslations('profile');

const router = useRouter();
const backend = useBackend();
const session = useSession();
Expand Down Expand Up @@ -275,7 +286,7 @@ export default function Home() {
content = (
<div className="flex flex-col h-[50vh] gap-4 w-full items-center justify-center">
<Activity className="h-10 w-10 animate-pulse text-foreground/80" />
<h3>{errorMessage ?? 'Something wrong...'}</h3>
<h3>{errorMessage ?? t('unknown_error')}</h3>
</div>
);
} else {
Expand Down
15 changes: 9 additions & 6 deletions src/app/signIn/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import {useMutation} from '@tanstack/react-query';
import {useSession} from '@/components/session-provider';
import {err} from '@/network/result';
import {formatNetworkError} from '@/services/backend-service';
import {useTranslations} from 'next-intl';

export default function SignInPage() {
const t = useTranslations('sign_in');

const router = useRouter();
const backend = useBackend();
const session = useSession();
Expand Down Expand Up @@ -126,25 +129,25 @@ export default function SignInPage() {
</div>
<Input
type="text"
placeholder="Nickname"
placeholder={t('nickname')}
value={nickname}
onChange={e => setNickname(e.target.value)}
/>
<Input
type="text"
placeholder="Description"
placeholder={t('description')}
value={description}
onChange={e => setDescription(e.target.value)}
/>
<Input
type="text"
placeholder="Interests (separated by ,)"
placeholder={t('interests')}
value={interests}
onChange={e => setInterests(e.target.value)}
/>
<Input
type="text"
placeholder="Social link (Optinal)"
placeholder={t('social_link')}
value={socialLink}
onChange={e => setSocialLink(e.target.value)}
/>
Expand All @@ -159,8 +162,8 @@ export default function SignInPage() {
}
>
{createAccountMutation.isPending
? 'Loading...'
: 'Create account'}
? t('loading')
: t('create_account')}
</Button>
</div>
);
Expand Down
45 changes: 45 additions & 0 deletions src/components/intl-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import {NextIntlClientProvider} from 'next-intl';
import {useEffect, useState} from 'react';
import en from '../messages/en.json';

type Messages = typeof en;

const loaders: Record<string, () => Promise<{default: Messages}>> = {
en: () => import('../messages/en.json'),
ru: () => import('../messages/ru.json'),
};

const fallbackLocale = 'en';

export default function IntlProvider({children}: {children: React.ReactNode}) {
const [messages, setMessages] = useState<Messages | null>(null);
const [locale, setLocale] = useState(fallbackLocale);

useEffect(() => {
const detected = navigator.language.split('-')[0];
const loc = loaders[detected] ? detected : fallbackLocale;

loaders[loc]()
.then(mod => {
setMessages(mod.default);
setLocale(loc);
console.log(`Loaded locale ${loc}`);
})
.catch(() => {
void loaders[fallbackLocale]().then(mod => {
setMessages(mod.default);
setLocale(fallbackLocale);
});
});
}, []);

if (!messages) return null;

return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}
9 changes: 9 additions & 0 deletions src/global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// import {routing} from '@/i18n/routing';
// import {formats} from '@/i18n/request';
import messages from './messages/en.json';

declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
}
}
15 changes: 15 additions & 0 deletions src/i18n/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {getRequestConfig} from 'next-intl/server';

export default getRequestConfig(async () => {
// const headerList = await headers();
// const acceptLanguage = headerList.get('accept-language');
// const locale = acceptLanguage?.split(',')[0].split('-')[0] || 'en';

// console.log(`Detected locale: ${locale}`);
const locale = 'en';

return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
27 changes: 27 additions & 0 deletions src/messages/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"sign_in" : {
"loading" : "Loading...",
"create_account": "Create account",
"nickname" : "Nickname",
"description" : "Description",
"interests" : "Interests (Separated by comma)",
"social_link" : "Social link (Optional)"
},
"profile" : {
"edit_profile" : "Edit",
"log_out" : "Log out",
"interests" : "Interests",
"friends" : {
"title" : "Friends",
"see_all" : "All friends",
"no_friends" : "You have no any friends yet."
},
"qr" : {
"title" : "QR code",
"desc" : "Share your profile to make connections.",
"copy" : "Copy",
"save" : "Save"
},
"unknown_error" : "An unknown error occurred."
}
}
27 changes: 27 additions & 0 deletions src/messages/ru.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"sign_in": {
"loading": "Загрузка...",
"create_account": "Создать аккаунт",
"nickname": "Никнейм",
"description": "Описание",
"interests": "Интересы (через запятую)",
"social_link": "Ссылка на соцсеть (необязательно)"
},
"profile": {
"edit_profile": "Редактировать",
"log_out": "Выйти",
"interests": "Интересы",
"friends": {
"title": "Друзья",
"see_all": "Все друзья",
"no_friends": "У вас пока нет друзей."
},
"qr": {
"title": "QR-код",
"desc": "Поделитесь своим профилем, чтобы находить новые знакомства.",
"copy": "Копировать",
"save": "Сохранить"
},
"unknown_error": "Произошла неизвестная ошибка."
}
}
Loading