Compare commits
5 Commits
672677396f
...
37c6b318a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c6b318a6 | |||
| 0f16f9d209 | |||
| ed59327cb7 | |||
| 4c0e463aec | |||
| 8018d21011 |
@ -112,7 +112,24 @@ export default [
|
|||||||
"@typescript-eslint/no-misused-promises": ["error", {
|
"@typescript-eslint/no-misused-promises": ["error", {
|
||||||
"checksVoidReturn": false
|
"checksVoidReturn": false
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
'@typescript-eslint/naming-convention': ['error',
|
||||||
|
{
|
||||||
|
selector: 'enum',
|
||||||
|
format: ['UPPER_CASE', 'PascalCase'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'variable',
|
||||||
|
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'function',
|
||||||
|
format: ['camelCase', 'PascalCase'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'typeLike',
|
||||||
|
format: ['PascalCase'],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -15,14 +15,23 @@
|
|||||||
"@graphql-codegen/typescript": "4.1.6",
|
"@graphql-codegen/typescript": "4.1.6",
|
||||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||||
"@graphql-codegen/typescript-react-apollo": "4.3.3",
|
"@graphql-codegen/typescript-react-apollo": "4.3.3",
|
||||||
|
"@hello-pangea/dnd": "18.0.1",
|
||||||
"@hookform/resolvers": "5.2.1",
|
"@hookform/resolvers": "5.2.1",
|
||||||
|
"@pbe/react-yandex-maps": "1.2.5",
|
||||||
|
"@radix-ui/react-alert-dialog": "1.1.14",
|
||||||
"@radix-ui/react-avatar": "1.1.10",
|
"@radix-ui/react-avatar": "1.1.10",
|
||||||
|
"@radix-ui/react-dialog": "1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.15",
|
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||||
"@radix-ui/react-label": "2.1.7",
|
"@radix-ui/react-label": "2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-switch": "1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "1.2.7",
|
"@radix-ui/react-tooltip": "1.2.7",
|
||||||
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"apollo-upload-client": "18.0.1",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"graphql": "16.11.0",
|
"graphql": "16.11.0",
|
||||||
@ -35,7 +44,9 @@
|
|||||||
"react": "19.1.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.1.1",
|
||||||
"react-hook-form": "7.61.1",
|
"react-hook-form": "7.61.1",
|
||||||
|
"react-icons": "5.5.0",
|
||||||
"sonner": "2.0.6",
|
"sonner": "2.0.6",
|
||||||
|
"subscriptions-transport-ws": "0.11.0",
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"zod": "4.0.14",
|
"zod": "4.0.14",
|
||||||
"zustand": "5.0.7"
|
"zustand": "5.0.7"
|
||||||
@ -45,6 +56,7 @@
|
|||||||
"@next/eslint-plugin-next": "15.4.5",
|
"@next/eslint-plugin-next": "15.4.5",
|
||||||
"@parcel/watcher": "^2.5.1",
|
"@parcel/watcher": "^2.5.1",
|
||||||
"@tailwindcss/postcss": "4.1.11",
|
"@tailwindcss/postcss": "4.1.11",
|
||||||
|
"@types/apollo-upload-client": "18.0.0",
|
||||||
"@types/node": "22.17.0",
|
"@types/node": "22.17.0",
|
||||||
"@types/react": "19.1.9",
|
"@types/react": "19.1.9",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.7",
|
||||||
|
|||||||
20
frontend/src/app/(site)/dashboard/chat/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/chat/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { ChangeChatSettings } from '@/components/features/chat/settings/ChangeChatSettings';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.chat.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatSettingsPage = () => <ChangeChatSettings />;
|
||||||
|
|
||||||
|
export default ChatSettingsPage;
|
||||||
20
frontend/src/app/(site)/dashboard/followers/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/followers/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { FollowersTable } from '@/components/features/follow/table/FollowersTable';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.followers.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FollowersPage = () => <FollowersTable />;
|
||||||
|
|
||||||
|
export default FollowersPage;
|
||||||
20
frontend/src/app/(site)/dashboard/keys/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/keys/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { KeysSettings } from '@/components/features/keys/settings/KeysSettings';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.keys.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeysSettingsPage = () => <KeysSettings />;
|
||||||
|
|
||||||
|
export default KeysSettingsPage;
|
||||||
20
frontend/src/app/(site)/dashboard/plans/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/plans/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { PlansTable } from '@/components/features/sponsorship/plan/table/PlansTable';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.plans.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlansPage = () => <PlansTable />;
|
||||||
|
|
||||||
|
export default PlansPage;
|
||||||
@ -1,19 +1,21 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { UserSettings } from '@/components/features/user/UserSettings';
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations('layout.header.headerMenu.profileMenu');
|
const t = await getTranslations('dashboard.settings.header');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t('dashboard'),
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DashboardSettings = () => (
|
const DashboardSettings = () => (
|
||||||
<div>
|
<UserSettings />
|
||||||
DashboardSettings page
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default DashboardSettings;
|
export default DashboardSettings;
|
||||||
|
|||||||
20
frontend/src/app/(site)/dashboard/sponsors/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/sponsors/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { SponsorsTable } from '@/components/features/sponsorship/subscription/table/SponsorsTable';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.sponsors.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SponsorsPage = () => <SponsorsTable />;
|
||||||
|
|
||||||
|
export default SponsorsPage;
|
||||||
20
frontend/src/app/(site)/dashboard/transactions/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/transactions/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { TransactionsTable } from '@/components/features/sponsorship/transactions/table/TransactionsTable';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('dashboard.transactions.header');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
description: t('description'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransactionsPage = () => <TransactionsTable />;
|
||||||
|
|
||||||
|
export default TransactionsPage;
|
||||||
18
frontend/src/app/account/deactivate/page.tsx
Normal file
18
frontend/src/app/account/deactivate/page.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { DeactivateForm } from '@/components/features/auth/forms/DeactivateForm';
|
||||||
|
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations('auth.deactivate');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('heading'),
|
||||||
|
...NO_INDEX_PAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeactivatePage = () => <DeactivateForm />;
|
||||||
|
export default DeactivatePage;
|
||||||
@ -2,11 +2,13 @@ import { Geist } from 'next/font/google';
|
|||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getLocale, getMessages } from 'next-intl/server';
|
import { getLocale, getMessages } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { ColorSwitcher } from '@/components/ui/elements/ColorSwitcher';
|
||||||
import ApolloClientProvider from '@/providers/ApolloClientProvider';
|
import ApolloClientProvider from '@/providers/ApolloClientProvider';
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider';
|
import { ThemeProvider } from '@/providers/ThemeProvider';
|
||||||
import { ToastProvider } from '@/providers/ToastProvider';
|
import { ToastProvider } from '@/providers/ToastProvider';
|
||||||
|
|
||||||
import '../styles/globals.css';
|
import '../styles/globals.css';
|
||||||
|
import '../styles/themes.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@ -31,6 +33,8 @@ const RootLayout = async ({
|
|||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning lang={locale}>
|
<html suppressHydrationWarning lang={locale}>
|
||||||
<body className={geistSans.variable}>
|
<body className={geistSans.variable}>
|
||||||
|
<ColorSwitcher />
|
||||||
|
|
||||||
<ApolloClientProvider>
|
<ApolloClientProvider>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
|
|||||||
179
frontend/src/components/features/auth/forms/DeactivateForm.tsx
Normal file
179
frontend/src/components/features/auth/forms/DeactivateForm.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from '@/components/ui/common/InputOTP';
|
||||||
|
import { useDeactivateAccountMutation } from '@/graphql/generated/output';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { type TypeDeactivateSchema, deactivateSchema } from '@/schemas/auth/deactivate.schema';
|
||||||
|
|
||||||
|
import AuthWrapper from '../AuthWrapper';
|
||||||
|
|
||||||
|
export const DeactivateForm = () => {
|
||||||
|
const t = useTranslations('auth.deactivate');
|
||||||
|
|
||||||
|
const { exit } = useAuth();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isShowConfirm, setIsShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TypeDeactivateSchema>({
|
||||||
|
resolver: zodResolver(deactivateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deactivate, { loading: isLoadingDeactivate }] = useDeactivateAccountMutation({
|
||||||
|
onCompleted(data) {
|
||||||
|
if (data.deactivateAccount.message) {
|
||||||
|
setIsShowConfirm(true);
|
||||||
|
} else {
|
||||||
|
exit();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeDeactivateSchema) {
|
||||||
|
void deactivate({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthWrapper
|
||||||
|
backButtonHref="/dashboard/settings"
|
||||||
|
backButtonLabel={t('backButtonLabel')}
|
||||||
|
heading={t('heading')}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid gap-y-3"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
{isShowConfirm
|
||||||
|
? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('pinLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP maxLength={6} {...field}>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('pinDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('emailLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingDeactivate}
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('emailDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('passwordLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingDeactivate}
|
||||||
|
placeholder="********"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('passwordDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-2 w-full"
|
||||||
|
disabled={!isValid || isLoadingDeactivate}
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</AuthWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Form, FormField } from '@/components/ui/common/Form';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import {
|
||||||
|
ToggleCard,
|
||||||
|
ToggleCardSkeleton,
|
||||||
|
} from '@/components/ui/elements/ToggleCard';
|
||||||
|
import { useChangeChatSettingsMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import { changeChatSettingsSchema } from '@/schemas/chat/change-chat.settings.schema';
|
||||||
|
|
||||||
|
import type { TypeChangeChatSettingsSchema } from '@/schemas/chat/change-chat.settings.schema';
|
||||||
|
|
||||||
|
export const ChangeChatSettings = () => {
|
||||||
|
const t = useTranslations('dashboard.chat');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeChatSettingsSchema>({
|
||||||
|
resolver: zodResolver(changeChatSettingsSchema),
|
||||||
|
values: {
|
||||||
|
isChatEnable: user?.stream.isChatEnable ?? false,
|
||||||
|
isChatFollowersOnly: user?.stream.isChatFollowersOnly ?? false,
|
||||||
|
isChatPremiumFollowersOnly:
|
||||||
|
user?.stream.isChatPremiumFollowersOnly ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangeChatSettingsMutation({
|
||||||
|
onCompleted(data) {
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onChange(
|
||||||
|
field: keyof TypeChangeChatSettingsSchema,
|
||||||
|
value: boolean,
|
||||||
|
) {
|
||||||
|
form.setValue(field, value);
|
||||||
|
|
||||||
|
void update({
|
||||||
|
variables: {
|
||||||
|
data: { ...form.getValues(), [field]: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-6">
|
||||||
|
{isLoadingProfile
|
||||||
|
? Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<ToggleCardSkeleton key={index} />
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isChatEnable"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t('isChatEnabled.description')}
|
||||||
|
heading={t('isChatEnabled.heading')}
|
||||||
|
isDisabled={isLoadingUpdate}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => { onChange('isChatEnable', value); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isChatFollowersOnly"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t(
|
||||||
|
'isChatFollowersOnly.description',
|
||||||
|
)}
|
||||||
|
heading={t('isChatFollowersOnly.heading')}
|
||||||
|
isDisabled={isLoadingUpdate}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => { onChange('isChatFollowersOnly', value); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isChatPremiumFollowersOnly"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t(
|
||||||
|
'isChatPremiumFollowersOnly.description',
|
||||||
|
)}
|
||||||
|
heading={t(
|
||||||
|
'isChatPremiumFollowersOnly.heading',
|
||||||
|
)}
|
||||||
|
isDisabled={
|
||||||
|
isLoadingUpdate || !user?.isVerified
|
||||||
|
}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange(
|
||||||
|
'isChatPremiumFollowersOnly',
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
106
frontend/src/components/features/follow/table/FollowersTable.tsx
Normal file
106
frontend/src/components/features/follow/table/FollowersTable.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MoreHorizontal, User } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/common/DropdownMenu';
|
||||||
|
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
|
||||||
|
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
|
||||||
|
import { DataTable, DataTableSkeleton } from '@/components/ui/elements/DataTable';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import {
|
||||||
|
type FindMyFollowersQuery,
|
||||||
|
useFindMyFollowersQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { formatDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const FollowerCell = ({ row }: any) => (
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<ChannelAvatar channel={row.original.follower} size="sm" />
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
{row.original.follower.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{row.original.follower.isVerified ? <ChannelVerified size="sm" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FollowerActionCell = ({ row }: any) => {
|
||||||
|
const t = useTranslations('dashboard.followers');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="size-8 p-0" variant="ghost">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent side="right">
|
||||||
|
<Link
|
||||||
|
href={`/${row.original.follower.name}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 size-4" />
|
||||||
|
|
||||||
|
{t('columns.viewChannel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FollowersTable = () => {
|
||||||
|
const t = useTranslations('dashboard.followers');
|
||||||
|
|
||||||
|
const { data, loading: isLoadingFollowers } = useFindMyFollowersQuery();
|
||||||
|
const followers = data?.findMyFollowers ?? [];
|
||||||
|
|
||||||
|
const followersColumns: ColumnDef<FindMyFollowersQuery['findMyFollowers'][0]>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: t('columns.date'),
|
||||||
|
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'follower',
|
||||||
|
header: t('columns.user'),
|
||||||
|
cell: FollowerCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'actions',
|
||||||
|
header: t('columns.actions'),
|
||||||
|
cell: FollowerActionCell,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{
|
||||||
|
isLoadingFollowers
|
||||||
|
? <DataTableSkeleton />
|
||||||
|
: <DataTable columns={followersColumns} data={followers} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,173 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/common/Dialog';
|
||||||
|
|
||||||
|
export const InstructionModal = () => {
|
||||||
|
const t = useTranslations('dashboard.keys.instructionModal');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="secondary">
|
||||||
|
{t('trigger')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-[800px] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
{t('heading')}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="text-sm text-muted-foreground">
|
||||||
|
{t('description')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{t('step1Title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('step1Description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="list-inside list-decimal space-y-2 pl-4">
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('downloadObs')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('downloadObsDescription')}
|
||||||
|
|
||||||
|
{' '}
|
||||||
|
|
||||||
|
<a
|
||||||
|
className="text-blue-500 underline hover:text-blue-700"
|
||||||
|
href="https://obsproject.com"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t('obsLinkText')}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('copyKeys')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('copyKeysDescription')}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-lg font-semibold">
|
||||||
|
{t('step2Title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('step2Description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="list-inside list-decimal space-y-2 pl-4">
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('openObs')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('openObsDescription')}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('openStreamSettings')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('openStreamSettingsDescription')}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('enterDetails')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('enterDetailsDescription')}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('saveSettings')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('saveSettingsDescription')}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-lg font-semibold">
|
||||||
|
{t('step3Title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('step3Description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="list-inside list-decimal space-y-2 pl-4">
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('startStream')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('startStreamDescription')}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="text-sm text-muted-foreground">
|
||||||
|
<strong>
|
||||||
|
{t('monitorStream')}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{t('monitorStreamDescription')}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
{t('congrats')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
|
||||||
|
import { CreateIngressForm } from './forms/CreateIngressForm';
|
||||||
|
import { StreamKey } from './forms/StreamKey';
|
||||||
|
import { StreamURL } from './forms/StreamURL';
|
||||||
|
import { InstructionModal } from './InstructionModal';
|
||||||
|
|
||||||
|
export const KeysSettings = () => {
|
||||||
|
const t = useTranslations('dashboard.keys.header');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile } = useCurrent();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<div className="block items-center justify-between space-y-3 lg:flex lg:space-y-0">
|
||||||
|
<Heading
|
||||||
|
description={t('description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<InstructionModal />
|
||||||
|
|
||||||
|
<CreateIngressForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
{isLoadingProfile
|
||||||
|
? Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<ToggleCardSkeleton key={index} />
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<StreamURL value={user?.stream?.serverUrl} />
|
||||||
|
|
||||||
|
<StreamKey value={user?.stream?.key} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/common/Dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/common/Select';
|
||||||
|
import { useCreateIngressMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
IngressType,
|
||||||
|
type TypeCreateIngressSchema,
|
||||||
|
createIngressSchema,
|
||||||
|
} from '@/schemas/stream/create-ingress.schema';
|
||||||
|
|
||||||
|
export const CreateIngressForm = () => {
|
||||||
|
const t = useTranslations('dashboard.keys.createModal');
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { refetch } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeCreateIngressSchema>({
|
||||||
|
resolver: zodResolver(createIngressSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ingressType: IngressType.RTMP,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [create, { loading: isLoadingCreate }] = useCreateIngressMutation({
|
||||||
|
onCompleted() {
|
||||||
|
setIsOpen(false);
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeCreateIngressSchema) {
|
||||||
|
void create({ variables: { ingressType: data.ingressType } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
{t('trigger')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('heading')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ingressType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('ingressTypeLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
'ingressTypePlaceholder',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
value={IngressType.RTMP.toString()}
|
||||||
|
>
|
||||||
|
RTMP
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
value={IngressType.WHIP.toString()}
|
||||||
|
>
|
||||||
|
WHIP
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('ingressTypeDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button disabled={!isValid || isLoadingCreate}>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { CopyButton } from '@/components/ui/elements/CopyButton';
|
||||||
|
|
||||||
|
type StreamKeyProps = {
|
||||||
|
value?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StreamKey = ({ value }: StreamKeyProps) => {
|
||||||
|
const t = useTranslations('dashboard.keys.key');
|
||||||
|
|
||||||
|
const [isShow, setIsShow] = useState(false);
|
||||||
|
|
||||||
|
const Icon = isShow ? Eye : EyeOff;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
isRightContentFull
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<div className="flex w-full items-center gap-x-4">
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
placeholder={t('heading')}
|
||||||
|
type={isShow ? 'text' : 'password'}
|
||||||
|
value={value ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyButton value={value} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lgIcon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setIsShow(!isShow); }}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { CopyButton } from '@/components/ui/elements/CopyButton';
|
||||||
|
|
||||||
|
type StreamURLProps = {
|
||||||
|
value?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StreamURL = ({ value }: StreamURLProps) => {
|
||||||
|
const t = useTranslations('dashboard.keys.url');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
isRightContentFull
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<div className="flex w-full items-center gap-x-4">
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
placeholder={t('heading')}
|
||||||
|
value={value ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyButton value={value} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/common/Dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { Textarea } from '@/components/ui/common/Textarea';
|
||||||
|
import {
|
||||||
|
useCreateSponsorshipPlanMutation,
|
||||||
|
useFindMySponsorshipPlansQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import {
|
||||||
|
type TypeCreatePlanSchema,
|
||||||
|
createPlanSchema,
|
||||||
|
} from '@/schemas/plan/create-plan.schema';
|
||||||
|
|
||||||
|
export const CreatePlanForm = () => {
|
||||||
|
const t = useTranslations('dashboard.plans.createForm');
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { refetch } = useFindMySponsorshipPlansQuery();
|
||||||
|
|
||||||
|
const form = useForm<TypeCreatePlanSchema>({
|
||||||
|
resolver: zodResolver(createPlanSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [create, { loading: isLoadingCreate }] = useCreateSponsorshipPlanMutation({
|
||||||
|
onCompleted() {
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset();
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeCreatePlanSchema) {
|
||||||
|
void create({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
{t('trigger')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('heading')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('titleLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
placeholder={t('titlePlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('titleDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('descriptionLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
placeholder={t(
|
||||||
|
'descriptionPlaceholder',
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('descriptionDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="price"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t('priceLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
placeholder={t('priceLabel')}
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('priceDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button disabled={!isValid || isLoadingCreate}>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MoreHorizontal, Trash } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/common/DropdownMenu';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableSkeleton,
|
||||||
|
} from '@/components/ui/elements/DataTable';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import {
|
||||||
|
type FindMySponsorshipPlansQuery,
|
||||||
|
useFindMySponsorshipPlansQuery,
|
||||||
|
useRemoveSponsorshipPlanMutation,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import { convertPrice } from '@/utils/convert-price';
|
||||||
|
import { formatDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
import { CreatePlanForm } from '../forms/CreatePlanForm';
|
||||||
|
|
||||||
|
import { VerifiedChannelAlert } from './VerifiedChannelAlert';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
export const PlansTable = () => {
|
||||||
|
const t = useTranslations('dashboard.plans');
|
||||||
|
|
||||||
|
const { user } = useCurrent();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
loading: isLoadingPlans,
|
||||||
|
refetch,
|
||||||
|
} = useFindMySponsorshipPlansQuery();
|
||||||
|
const plans = data?.findMySponsorshipPlans ?? [];
|
||||||
|
|
||||||
|
const plansColumns: ColumnDef<
|
||||||
|
FindMySponsorshipPlansQuery['findMySponsorshipPlans'][0]
|
||||||
|
>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: t('columns.date'),
|
||||||
|
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: t('columns.title'),
|
||||||
|
cell: ({ row }) => row.original.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'price',
|
||||||
|
header: t('columns.price'),
|
||||||
|
cell: ({ row }) => convertPrice(row.original.price),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'actions',
|
||||||
|
header: t('columns.actions'),
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ row }) => {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const [remove, { loading: isLoadingRemove }] = useRemoveSponsorshipPlanMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('columns.successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
// @ts-ignore
|
||||||
|
toast.error(t('columns.errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="size-8 p-0" variant="ghost">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent side="right">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
disabled={isLoadingRemove}
|
||||||
|
onClick={async () => remove({
|
||||||
|
variables: { planId: row.original.id },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 size-4" />
|
||||||
|
|
||||||
|
{t('columns.remove')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return user?.isVerified
|
||||||
|
? (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<div className="block items-center justify-between space-y-3 lg:flex lg:space-y-0">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreatePlanForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{isLoadingPlans
|
||||||
|
? (
|
||||||
|
<DataTableSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<DataTable columns={plansColumns} data={plans} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<VerifiedChannelAlert />
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export const VerifiedChannelAlert = () => {
|
||||||
|
const t = useTranslations('dashboard.plans.alert');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[75vh] w-full flex-col items-center justify-center">
|
||||||
|
<ShieldAlert className="size-20 text-muted-foreground" />
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold">
|
||||||
|
{t('heading')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 w-full items-center text-center text-muted-foreground lg:w-[60%]">
|
||||||
|
{t('description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MoreHorizontal, User } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/common/DropdownMenu';
|
||||||
|
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
|
||||||
|
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableSkeleton,
|
||||||
|
} from '@/components/ui/elements/DataTable';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import {
|
||||||
|
type FindMySponsorsQuery,
|
||||||
|
useFindMySponsorsQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { formatDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
export const SponsorsTable = () => {
|
||||||
|
const t = useTranslations('dashboard.sponsors');
|
||||||
|
|
||||||
|
const { data, loading: isLoadingSponsors } = useFindMySponsorsQuery();
|
||||||
|
const sponsors = data?.findMySponsors ?? [];
|
||||||
|
|
||||||
|
const sponsorsColumns: ColumnDef<
|
||||||
|
FindMySponsorsQuery['findMySponsors'][0]
|
||||||
|
>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'expiresAt',
|
||||||
|
header: t('columns.date'),
|
||||||
|
cell: ({ row }) => formatDate(row.original.expiresAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'user',
|
||||||
|
header: t('columns.user'),
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<ChannelAvatar channel={row.original.user} size="sm" />
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
{row.original.user.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{row.original.user.isVerified ? <ChannelVerified size="sm" /> : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'plan',
|
||||||
|
header: t('columns.plan'),
|
||||||
|
cell: ({ row }) => row.original.plan.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'actions',
|
||||||
|
header: t('columns.actions'),
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="size-8 p-0" variant="ghost">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent side="right">
|
||||||
|
<Link
|
||||||
|
href={`/${row.original.user.name}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 size-4" />
|
||||||
|
|
||||||
|
{t('columns.viewChannel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{isLoadingSponsors
|
||||||
|
? (
|
||||||
|
<DataTableSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<DataTable columns={sponsorsColumns} data={sponsors} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableSkeleton,
|
||||||
|
} from '@/components/ui/elements/DataTable';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import {
|
||||||
|
type FindMyTransactionsQuery,
|
||||||
|
TransactionStatus,
|
||||||
|
useFindMyTransactionsQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { convertPrice } from '@/utils/convert-price';
|
||||||
|
import { formatDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
export const TransactionsTable = () => {
|
||||||
|
const t = useTranslations('dashboard.transactions');
|
||||||
|
|
||||||
|
const { data, loading: isLoadingTransactions } = useFindMyTransactionsQuery();
|
||||||
|
const transactions = data?.findMyTransactions ?? [];
|
||||||
|
|
||||||
|
const transactionsColumns: ColumnDef<
|
||||||
|
FindMyTransactionsQuery['findMyTransactions'][0]
|
||||||
|
>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: t('columns.date'),
|
||||||
|
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: t('columns.status'),
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { status } = row.original;
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
let statusColor = '';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case TransactionStatus.Success:
|
||||||
|
statusColor = 'text-green-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-1.5 ${statusColor}`}>
|
||||||
|
{t('columns.success')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TransactionStatus.Pending:
|
||||||
|
statusColor = 'text-yellow-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-1.5 ${statusColor}`}>
|
||||||
|
{t('columns.pending')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TransactionStatus.Failed:
|
||||||
|
statusColor = 'text-red-600';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-1.5 ${statusColor}`}>
|
||||||
|
{t('columns.failed')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TransactionStatus.Expired:
|
||||||
|
statusColor = 'text-purple-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-1.5 ${statusColor}`}>
|
||||||
|
{t('columns.expired')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
statusColor = 'text-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`py-1.5 ${statusColor}`}>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'amount',
|
||||||
|
header: t('columns.amount'),
|
||||||
|
cell: ({ row }) => convertPrice(row.original.amount),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{isLoadingTransactions
|
||||||
|
? (
|
||||||
|
<DataTableSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<DataTable
|
||||||
|
columns={transactionsColumns}
|
||||||
|
data={transactions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
143
frontend/src/components/features/user/UserSettings.tsx
Normal file
143
frontend/src/components/features/user/UserSettings.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@/components/ui/common/Tabs';
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
|
||||||
|
import { ChangeEmailForm } from './account/ChangeEmailForm';
|
||||||
|
import { ChangePasswordForm } from './account/ChangePasswordForm';
|
||||||
|
import { DeactivateCard } from './account/DeactivateCard';
|
||||||
|
import { WrapperTotp } from './account/totp/WrapperTotp';
|
||||||
|
import { ChangeColorForm } from './appearance/ChangeColorForm';
|
||||||
|
import { ChangeLanguageForm } from './appearance/ChangeLanguageForm';
|
||||||
|
import { ChangeThemeForm } from './appearance/ChangeThemeForm';
|
||||||
|
import { ChangeNotificationsSettingsForm } from './notifications/ChangeNotificationsSettingsForm';
|
||||||
|
import { ChangeAvatarForm } from './profile/ChangeAvatarForm';
|
||||||
|
import { ChangeInfoForm } from './profile/ChangeInfoForm';
|
||||||
|
import { SocialLinksForm } from './profile/social-links-form/SocialLinksForm';
|
||||||
|
import { SessionsList } from './sessions/SessionsList';
|
||||||
|
|
||||||
|
export const UserSettings = () => {
|
||||||
|
const t = useTranslations('dashboard.settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:px-10">
|
||||||
|
<Heading
|
||||||
|
description={t('header.description')}
|
||||||
|
size="lg"
|
||||||
|
title={t('header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs className="mt-3 w-full" defaultValue="profile">
|
||||||
|
<TabsList className="grid max-w-2xl grid-cols-5">
|
||||||
|
<TabsTrigger value="profile">
|
||||||
|
{t('header.profile')}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="account">
|
||||||
|
{t('header.account')}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="appearance">
|
||||||
|
{t('header.appearance')}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="notifications">
|
||||||
|
{t('header.notifications')}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="sessions">
|
||||||
|
{t('header.sessions')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="profile">
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
<Heading
|
||||||
|
description={t('profile.header.description')}
|
||||||
|
title={t('profile.header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChangeAvatarForm />
|
||||||
|
|
||||||
|
<ChangeInfoForm />
|
||||||
|
|
||||||
|
<SocialLinksForm />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="account">
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
<Heading
|
||||||
|
description={t('account.header.description')}
|
||||||
|
title={t('account.header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChangeEmailForm />
|
||||||
|
|
||||||
|
<ChangePasswordForm />
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
description={t(
|
||||||
|
'account.header.securityDescription',
|
||||||
|
)}
|
||||||
|
title={t('account.header.securityHeading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WrapperTotp />
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
description={t(
|
||||||
|
'account.header.deactivationDescription',
|
||||||
|
)}
|
||||||
|
title={t('account.header.deactivationHeading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeactivateCard />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appearance">
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
<Heading
|
||||||
|
description={t('appearance.header.description')}
|
||||||
|
title={t('appearance.header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChangeThemeForm />
|
||||||
|
|
||||||
|
<ChangeLanguageForm />
|
||||||
|
|
||||||
|
<ChangeColorForm />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notifications">
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
<Heading
|
||||||
|
description={t('notifications.header.description')}
|
||||||
|
title={t('notifications.header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChangeNotificationsSettingsForm />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<div className="mt-5 space-y-6">
|
||||||
|
<Heading
|
||||||
|
description={t('sessions.header.description')}
|
||||||
|
title={t('sessions.header.heading')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SessionsList />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { Separator } from '@/components/ui/common/Separator';
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
|
||||||
|
import { useChangeEmailMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeChangeEmailSchema,
|
||||||
|
changeEmailSchema,
|
||||||
|
} from '@/schemas/user/change-email.schema';
|
||||||
|
|
||||||
|
export const ChangeEmailFormSkeleton = () => <Skeleton className="h-64 w-full" />;
|
||||||
|
|
||||||
|
export const ChangeEmailForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.email');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile, refetch } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeEmailSchema>({
|
||||||
|
resolver: zodResolver(changeEmailSchema),
|
||||||
|
values: {
|
||||||
|
email: user?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangeEmailMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid, isDirty } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeChangeEmailSchema) {
|
||||||
|
void update({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? (
|
||||||
|
<ChangeEmailFormSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FormWrapper heading={t('heading')}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid gap-y-3"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5">
|
||||||
|
<FormLabel>
|
||||||
|
{t('emailLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('emailDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-end p-5">
|
||||||
|
<Button
|
||||||
|
disabled={!isValid || !isDirty || isLoadingUpdate}
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</FormWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { Separator } from '@/components/ui/common/Separator';
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
|
||||||
|
import { useChangePasswordMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeChangePasswordSchema,
|
||||||
|
changePasswordSchema,
|
||||||
|
} from '@/schemas/user/change-password.schema';
|
||||||
|
|
||||||
|
export const ChangePasswordFormSkeleton = () => <Skeleton className="h-96 w-full" />;
|
||||||
|
|
||||||
|
export const ChangePasswordForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.password');
|
||||||
|
|
||||||
|
const { isLoadingProfile, refetch } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangePasswordSchema>({
|
||||||
|
resolver: zodResolver(changePasswordSchema),
|
||||||
|
values: {
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangePasswordMutation({
|
||||||
|
onCompleted() {
|
||||||
|
form.reset();
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeChangePasswordSchema) {
|
||||||
|
void update({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? (
|
||||||
|
<ChangePasswordFormSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FormWrapper heading={t('heading')}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid gap-y-3"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="oldPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5">
|
||||||
|
<FormLabel>
|
||||||
|
{t('oldPasswordLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder="********"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('oldPasswordDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="newPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5">
|
||||||
|
<FormLabel>
|
||||||
|
{t('newPasswordLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder="********"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('newPasswordDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-end p-5">
|
||||||
|
<Button disabled={!isValid || isLoadingUpdate}>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</FormWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
|
||||||
|
|
||||||
|
export const DeactivateCard = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.deactivation');
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
description={t('description')}
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<ConfirmModal
|
||||||
|
heading={t('confirmModal.heading')}
|
||||||
|
message={t('confirmModal.message')}
|
||||||
|
onConfirm={() => { router.push('/account/deactivate'); }}
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{t('button')}
|
||||||
|
</Button>
|
||||||
|
</ConfirmModal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
|
||||||
|
import { useDisableTotpMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
|
||||||
|
export const DisableTotp = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.twoFactor.disable');
|
||||||
|
|
||||||
|
const { refetch } = useCurrent();
|
||||||
|
|
||||||
|
const [disable, { loading: isLoadingDisable }] = useDisableTotpMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
heading={t('heading')}
|
||||||
|
message={t('message')}
|
||||||
|
onConfirm={async () => disable()}
|
||||||
|
>
|
||||||
|
<Button disabled={isLoadingDisable} variant="secondary">
|
||||||
|
{t('trigger')}
|
||||||
|
</Button>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/common/Dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from '@/components/ui/common/InputOTP';
|
||||||
|
import {
|
||||||
|
useEnableTotpMutation,
|
||||||
|
useGenerateTotpSecretQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeEnableTotpSchema,
|
||||||
|
enableTotpSchema,
|
||||||
|
} from '@/schemas/user/enable-totp.schema';
|
||||||
|
|
||||||
|
export const EnableTotp = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.twoFactor.enable');
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { refetch } = useCurrent();
|
||||||
|
|
||||||
|
const { data, loading: isLoadingGenerate } = useGenerateTotpSecretQuery();
|
||||||
|
const twoFactorAuth = data?.generateTotpSecret;
|
||||||
|
|
||||||
|
const form = useForm<TypeEnableTotpSchema>({
|
||||||
|
resolver: zodResolver(enableTotpSchema),
|
||||||
|
defaultValues: {
|
||||||
|
pin: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [enable, { loading: isLoadingEnable }] = useEnableTotpMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
setIsOpen(false);
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(values: TypeEnableTotpSchema) {
|
||||||
|
void enable({
|
||||||
|
variables: {
|
||||||
|
data: {
|
||||||
|
secret: twoFactorAuth?.secret ?? '',
|
||||||
|
pin: values.pin,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
{t('trigger')}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('heading')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{twoFactorAuth?.qrcodeUrl
|
||||||
|
? t('qrInstructions')
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
alt="QR"
|
||||||
|
className="rounded-lg"
|
||||||
|
height={300}
|
||||||
|
src={twoFactorAuth?.qrcodeUrl ?? ''}
|
||||||
|
width={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-center text-sm text-muted-foreground">
|
||||||
|
{twoFactorAuth?.secret
|
||||||
|
? t('secretCodeLabel')
|
||||||
|
+ twoFactorAuth.secret
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col justify-center max-sm:items-center">
|
||||||
|
<FormLabel>
|
||||||
|
{t('pinLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<InputOTP maxLength={6} {...field}>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('pinDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!isValid
|
||||||
|
|| isLoadingGenerate
|
||||||
|
|| isLoadingEnable
|
||||||
|
}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
|
||||||
|
import { DisableTotp } from './DisableTotp';
|
||||||
|
import { EnableTotp } from './EnableTotp';
|
||||||
|
|
||||||
|
export const WrapperTotpSkeleton = () => <Skeleton className="h-24 w-full" />;
|
||||||
|
|
||||||
|
export const WrapperTotp = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.account.twoFactor');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile } = useCurrent();
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? (
|
||||||
|
<WrapperTotpSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<CardContainer
|
||||||
|
description={t('description')}
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{!user?.isTotpEnabled ? <EnableTotp /> : <DisableTotp />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { useConfig } from '@/hooks/useConfig';
|
||||||
|
import { BASE_COLORS } from '@/libs/constants/colors.constants';
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export const ChangeColorForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.appearance.color');
|
||||||
|
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
description={t('description')}
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<div className="grid grid-cols-4 gap-2 md:grid-cols-8">
|
||||||
|
{BASE_COLORS.map((theme) => {
|
||||||
|
const isActive = config.theme === theme.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme.name}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--theme-primary': `hsl(${theme.color})`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { config.setTheme(theme.name); }}
|
||||||
|
>
|
||||||
|
<span className="flex size-9 shrink-0 -translate-x-1 items-center justify-center rounded-lg bg-(--theme-primary) hover:border-2 hover:border-foreground">
|
||||||
|
{isActive ? <Check className="size-5 text-white" /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Form, FormField } from '@/components/ui/common/Form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/common/Select';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { setCurrentLanguage } from '@/libs/i18n/language';
|
||||||
|
import {
|
||||||
|
changeLanguageSchema,
|
||||||
|
} from '@/schemas/user/change-language.schema';
|
||||||
|
|
||||||
|
import type { TypeChangeLanguageSchema } from '@/schemas/user/change-language.schema';
|
||||||
|
|
||||||
|
const languages = {
|
||||||
|
ru: 'Русский',
|
||||||
|
en: 'English',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChangeLanguageForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.appearance.language');
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeLanguageSchema>({
|
||||||
|
resolver: zodResolver(changeLanguageSchema),
|
||||||
|
values: {
|
||||||
|
language: locale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: TypeChangeLanguageSchema) {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await setCurrentLanguage(data.language);
|
||||||
|
} catch (error) {
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
description={t('description')}
|
||||||
|
heading={t('heading')}
|
||||||
|
rightContent={(
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="language"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value: string) => {
|
||||||
|
field.onChange(value);
|
||||||
|
void form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder={t('selectPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(languages).map(
|
||||||
|
([code, name]) => (
|
||||||
|
<SelectItem
|
||||||
|
key={code}
|
||||||
|
disabled={isPending}
|
||||||
|
value={code}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Form, FormField } from '@/components/ui/common/Form';
|
||||||
|
import { ToggleCard } from '@/components/ui/elements/ToggleCard';
|
||||||
|
import {
|
||||||
|
changeThemeSchema,
|
||||||
|
} from '@/schemas/user/change-theme.schema';
|
||||||
|
|
||||||
|
import type { TypeChangeThemeSchema } from '@/schemas/user/change-theme.schema';
|
||||||
|
|
||||||
|
export const ChangeThemeForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.appearance.theme');
|
||||||
|
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeThemeSchema>({
|
||||||
|
resolver: zodResolver(changeThemeSchema),
|
||||||
|
values: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChange = (value: boolean) => {
|
||||||
|
const newTheme = value ? 'dark' : 'light';
|
||||||
|
|
||||||
|
setTheme(newTheme);
|
||||||
|
form.setValue('theme', newTheme);
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="theme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t('description')}
|
||||||
|
heading={t('heading')}
|
||||||
|
value={field.value === 'dark'}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Form, FormField } from '@/components/ui/common/Form';
|
||||||
|
import {
|
||||||
|
ToggleCard,
|
||||||
|
ToggleCardSkeleton,
|
||||||
|
} from '@/components/ui/elements/ToggleCard';
|
||||||
|
import { useChangeNotificationsSettingsMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeChangeNotificationsSettingsSchema,
|
||||||
|
changeNotificationsSettingsSchema,
|
||||||
|
} from '@/schemas/user/change-notifications-settings.schema';
|
||||||
|
|
||||||
|
export const ChangeNotificationsSettingsForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.notifications');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile, refetch } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeNotificationsSettingsSchema>({
|
||||||
|
resolver: zodResolver(changeNotificationsSettingsSchema),
|
||||||
|
values: {
|
||||||
|
siteNotifications:
|
||||||
|
user?.notificationSettings?.siteNotifications ?? false,
|
||||||
|
telegramNotifications:
|
||||||
|
user?.notificationSettings?.telegramNotifications ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangeNotificationsSettingsMutation({
|
||||||
|
onCompleted(data) {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
|
||||||
|
if (data.changeNotificationSettings.telegramAuthToken) {
|
||||||
|
window.open(
|
||||||
|
`https://t.me/ksv741_teastream_bot?start=${data.changeNotificationSettings.telegramAuthToken}`,
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onChange(
|
||||||
|
field: keyof TypeChangeNotificationsSettingsSchema,
|
||||||
|
value: boolean,
|
||||||
|
) {
|
||||||
|
form.setValue(field, value);
|
||||||
|
|
||||||
|
void update({
|
||||||
|
variables: {
|
||||||
|
data: { ...form.getValues(), [field]: value },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<ToggleCardSkeleton key={index} />
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="siteNotifications"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t('siteNotifications.description')}
|
||||||
|
heading={t('siteNotifications.heading')}
|
||||||
|
isDisabled={isLoadingUpdate}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => { onChange('siteNotifications', value); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="telegramNotifications"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleCard
|
||||||
|
description={t('telegramNotifications.description')}
|
||||||
|
heading={t('telegramNotifications.heading')}
|
||||||
|
isDisabled={isLoadingUpdate}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => { onChange('telegramNotifications', value); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Trash } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type ChangeEvent, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { Form, FormField } from '@/components/ui/common/Form';
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
|
||||||
|
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
|
||||||
|
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
|
||||||
|
import {
|
||||||
|
useChangeProfileAvatarMutation,
|
||||||
|
useRemoveProfileAvatarMutation,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeUploadFileSchema,
|
||||||
|
uploadFileSchema,
|
||||||
|
} from '@/schemas/upload-file.schema';
|
||||||
|
|
||||||
|
export const ChangeAvatarFormSkeleton = () => <Skeleton className="h-52 w-full" />;
|
||||||
|
|
||||||
|
export const ChangeAvatarForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.profile.avatar');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile, refetch } = useCurrent();
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const form = useForm<TypeUploadFileSchema>({
|
||||||
|
resolver: zodResolver(uploadFileSchema),
|
||||||
|
values: {
|
||||||
|
file: user?.avatar ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangeProfileAvatarMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successUpdateMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorUpdateMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [remove, { loading: isLoadingRemove }] = useRemoveProfileAvatarMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successRemoveMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorRemoveMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
void update({ variables: { avatar: file } });
|
||||||
|
form.setValue('file', file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? (
|
||||||
|
<ChangeAvatarFormSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FormWrapper heading={t('heading')}>
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<div className="w-full items-center space-x-6 lg:flex">
|
||||||
|
<ChannelAvatar
|
||||||
|
channel={{
|
||||||
|
name: user.name,
|
||||||
|
avatar:
|
||||||
|
field.value instanceof File
|
||||||
|
? URL.createObjectURL(
|
||||||
|
field.value,
|
||||||
|
)
|
||||||
|
: field.value,
|
||||||
|
}}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
isLoadingUpdate
|
||||||
|
|| isLoadingRemove
|
||||||
|
}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{t('updateButton')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user?.avatar
|
||||||
|
? (
|
||||||
|
<ConfirmModal
|
||||||
|
heading={t(
|
||||||
|
'confirmModal.heading',
|
||||||
|
)}
|
||||||
|
message={t(
|
||||||
|
'confirmModal.message',
|
||||||
|
)}
|
||||||
|
onConfirm={async () => remove()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
isLoadingUpdate
|
||||||
|
|| isLoadingRemove
|
||||||
|
}
|
||||||
|
size="lgIcon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Trash className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</ConfirmModal>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('info')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</FormWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
frontend/src/components/features/user/profile/ChangeInfoForm.tsx
Normal file
165
frontend/src/components/features/user/profile/ChangeInfoForm.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { Separator } from '@/components/ui/common/Separator';
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { Textarea } from '@/components/ui/common/Textarea';
|
||||||
|
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
|
||||||
|
import { useChangeProfileInfoMutation } from '@/graphql/generated/output';
|
||||||
|
import { useCurrent } from '@/hooks/useCurrent';
|
||||||
|
import {
|
||||||
|
type TypeChangeInfoSchema,
|
||||||
|
changeInfoSchema,
|
||||||
|
} from '@/schemas/user/change-info.schema';
|
||||||
|
|
||||||
|
export const ChangeInfoFormSkeleton = () => <Skeleton className="h-96 w-full" />;
|
||||||
|
|
||||||
|
export const ChangeInfoForm = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.profile.info');
|
||||||
|
|
||||||
|
const { user, isLoadingProfile, refetch } = useCurrent();
|
||||||
|
|
||||||
|
const form = useForm<TypeChangeInfoSchema>({
|
||||||
|
resolver: zodResolver(changeInfoSchema),
|
||||||
|
values: {
|
||||||
|
name: user?.name ?? '',
|
||||||
|
displayName: user?.displayName ?? '',
|
||||||
|
bio: user?.bio ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useChangeProfileInfoMutation(
|
||||||
|
{
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isValid, isDirty } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeChangeInfoSchema) {
|
||||||
|
void update({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingProfile
|
||||||
|
? (
|
||||||
|
<ChangeInfoFormSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FormWrapper heading={t('heading')}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid gap-y-3"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5">
|
||||||
|
<FormLabel>
|
||||||
|
{t('usernameLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder={t('usernamePlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('usernameDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="displayName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5 pb-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t('displayNameLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder={t(
|
||||||
|
'displayNamePlaceholder',
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('displayNameDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5 pb-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t('bioLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
disabled={isLoadingUpdate}
|
||||||
|
placeholder={t('bioPlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('bioDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-end p-5">
|
||||||
|
<Button
|
||||||
|
disabled={!isValid || !isDirty || isLoadingUpdate}
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</FormWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { GripVertical, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import {
|
||||||
|
type FindSocialLinksQuery,
|
||||||
|
useFindSocialLinksQuery,
|
||||||
|
useRemoveSocialLinkMutation,
|
||||||
|
useUpdateSocialLinkMutation,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import {
|
||||||
|
type TypeSocialLinksSchema,
|
||||||
|
socialLinksSchema,
|
||||||
|
} from '@/schemas/user/social-links.schema';
|
||||||
|
|
||||||
|
import type { DraggableProvided } from '@hello-pangea/dnd';
|
||||||
|
|
||||||
|
type SocialLinkItemProps = {
|
||||||
|
socialLink: FindSocialLinksQuery['findSocialLinks'][0];
|
||||||
|
provided: DraggableProvided;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SocialLinkItem = ({ socialLink, provided }: SocialLinkItemProps) => {
|
||||||
|
const t = useTranslations('dashboard.settings.profile.socialLinks.editForm');
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { refetch } = useFindSocialLinksQuery();
|
||||||
|
|
||||||
|
const form = useForm<TypeSocialLinksSchema>({
|
||||||
|
resolver: zodResolver(socialLinksSchema),
|
||||||
|
values: {
|
||||||
|
title: socialLink.title ?? '',
|
||||||
|
url: socialLink.url ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid, isDirty } = form.formState;
|
||||||
|
|
||||||
|
function toggleEditing(id: string | null) {
|
||||||
|
setEditingId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [update, { loading: isLoadingUpdate }] = useUpdateSocialLinkMutation({
|
||||||
|
onCompleted() {
|
||||||
|
toggleEditing(null);
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successUpdateMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorUpdateMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [remove, { loading: isLoadingRemove }] = useRemoveSocialLinkMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successRemoveMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorRemoveMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(data: TypeSocialLinksSchema) {
|
||||||
|
void update({ variables: { id: socialLink.id, data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className="mb-4 flex items-center gap-x-2 rounded-md border border-border bg-background text-sm"
|
||||||
|
{...provided.draggableProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-l-md border-r border-r-border px-2 py-9 text-foreground transition"
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 px-2">
|
||||||
|
{editingId === socialLink.id
|
||||||
|
? (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="flex gap-x-6"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="w-96 space-y-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
disabled={
|
||||||
|
isLoadingUpdate
|
||||||
|
|| isLoadingRemove
|
||||||
|
}
|
||||||
|
placeholder="YouTube"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
disabled={
|
||||||
|
isLoadingUpdate
|
||||||
|
|| isLoadingRemove
|
||||||
|
}
|
||||||
|
placeholder="https://youtube.com/@TeaCoder52"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => { toggleEditing(null); }}
|
||||||
|
>
|
||||||
|
{t('cancelButton')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!isDirty
|
||||||
|
|| !isValid
|
||||||
|
|| isLoadingUpdate
|
||||||
|
|| isLoadingRemove
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<h2 className="text-[17px] font-semibold text-foreground">
|
||||||
|
{socialLink.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{socialLink.url}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-x-2 pr-4">
|
||||||
|
{editingId !== socialLink.id && (
|
||||||
|
<Button
|
||||||
|
size="lgIcon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { toggleEditing(socialLink.id); }}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lgIcon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={async () => remove({ variables: { id: socialLink.id } })}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@/components/ui/common/Form';
|
||||||
|
import { Input } from '@/components/ui/common/Input';
|
||||||
|
import { Separator } from '@/components/ui/common/Separator';
|
||||||
|
import { Skeleton } from '@/components/ui/common/Skeleton';
|
||||||
|
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
|
||||||
|
import {
|
||||||
|
useCreateSocialLinkMutation,
|
||||||
|
useFindSocialLinksQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import {
|
||||||
|
type TypeSocialLinksSchema,
|
||||||
|
socialLinksSchema,
|
||||||
|
} from '@/schemas/user/social-links.schema';
|
||||||
|
|
||||||
|
import { SocialLinksList } from './SocialLinksList';
|
||||||
|
|
||||||
|
export const SocialLinksFormSkeleton = () => <Skeleton className="h-72 w-full" />;
|
||||||
|
|
||||||
|
export const SocialLinksForm = () => {
|
||||||
|
const t = useTranslations(
|
||||||
|
'dashboard.settings.profile.socialLinks.createForm',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loading: isLoadingLinks, refetch } = useFindSocialLinksQuery();
|
||||||
|
|
||||||
|
const form = useForm<TypeSocialLinksSchema>({
|
||||||
|
resolver: zodResolver(socialLinksSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [create, { loading: isLoadingCreate }] = useCreateSocialLinkMutation({
|
||||||
|
onCompleted() {
|
||||||
|
form.reset();
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isValid } = form.formState;
|
||||||
|
|
||||||
|
function onSubmit(data: TypeSocialLinksSchema) {
|
||||||
|
void create({ variables: { data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoadingLinks
|
||||||
|
? (
|
||||||
|
<SocialLinksFormSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FormWrapper heading={t('heading')}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid gap-y-3"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5">
|
||||||
|
<FormLabel>
|
||||||
|
{t('titleLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
placeholder={t('titlePlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('titleDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="px-5 pb-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t('urlLabel')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={isLoadingCreate}
|
||||||
|
placeholder={t('urlPlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('urlDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-end p-5">
|
||||||
|
<Button disabled={!isValid || isLoadingCreate}>
|
||||||
|
{t('submitButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<SocialLinksList />
|
||||||
|
</FormWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Draggable,
|
||||||
|
type DropResult,
|
||||||
|
Droppable,
|
||||||
|
} from '@hello-pangea/dnd';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Separator } from '@/components/ui/common/Separator';
|
||||||
|
import {
|
||||||
|
useFindSocialLinksQuery,
|
||||||
|
useReorderSocialLinksMutation,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
|
||||||
|
import { SocialLinkItem } from './SocialLinkItem';
|
||||||
|
|
||||||
|
export const SocialLinksList = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.profile.socialLinks');
|
||||||
|
|
||||||
|
const { data, refetch } = useFindSocialLinksQuery();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const items = data?.findSocialLinks ?? [];
|
||||||
|
|
||||||
|
const [socialLinks, setSocialLinks] = useState(items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSocialLinks(items);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const [reorder, { loading: isLoadingReorder }] = useReorderSocialLinksMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successReorderMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorReorderMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const items = Array.from(socialLinks);
|
||||||
|
const [reorderItem] = items.splice(result.source.index, 1);
|
||||||
|
|
||||||
|
items.splice(result.destination.index, 0, reorderItem);
|
||||||
|
|
||||||
|
const bulkUpdateData = items.map((socialLink, index) => ({
|
||||||
|
id: socialLink.id,
|
||||||
|
position: index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSocialLinks(items);
|
||||||
|
|
||||||
|
void reorder({ variables: { list: bulkUpdateData } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return socialLinks.length
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="mt-5 px-5">
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable droppableId="socialLinks">
|
||||||
|
{(dropProvided) => (
|
||||||
|
<div
|
||||||
|
{...dropProvided.droppableProps}
|
||||||
|
ref={dropProvided.innerRef}
|
||||||
|
>
|
||||||
|
{socialLinks.map((socialLink, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={socialLink.id}
|
||||||
|
draggableId={socialLink.id}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={isLoadingReorder}
|
||||||
|
>
|
||||||
|
{(dragProvided) => (
|
||||||
|
<SocialLinkItem
|
||||||
|
key={socialLink.id}
|
||||||
|
provided={dragProvided}
|
||||||
|
socialLink={socialLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dropProvided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/common/Button';
|
||||||
|
import { CardContainer } from '@/components/ui/elements/CardContainer';
|
||||||
|
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
|
||||||
|
import {
|
||||||
|
type FindSessionsByUserQuery,
|
||||||
|
useFindSessionsByUserQuery,
|
||||||
|
useRemoveSessionMutation,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
import { getBrowserIcon } from '@/utils/get-browser-icon';
|
||||||
|
|
||||||
|
import { SessionModal } from './SessionModal';
|
||||||
|
|
||||||
|
type SessionItemProps = {
|
||||||
|
session: FindSessionsByUserQuery['findSessionsByUser'][0];
|
||||||
|
isCurrentSession?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionItem = ({ session, isCurrentSession }: SessionItemProps) => {
|
||||||
|
const t = useTranslations('dashboard.settings.sessions.sessionItem');
|
||||||
|
|
||||||
|
const { refetch } = useFindSessionsByUserQuery();
|
||||||
|
|
||||||
|
const [remove, { loading: isLoadingRemove }] = useRemoveSessionMutation({
|
||||||
|
onCompleted() {
|
||||||
|
void refetch();
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
toast.error(t('errorMessage'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Icon = getBrowserIcon(session.metadata.device.browser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContainer
|
||||||
|
description={`${session.metadata.location.country}, ${session.metadata.location.city}`}
|
||||||
|
heading={`${session.metadata.device.browser}, ${session.metadata.device.os}`}
|
||||||
|
Icon={Icon}
|
||||||
|
rightContent={(
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{!isCurrentSession && (
|
||||||
|
<ConfirmModal
|
||||||
|
heading={t('confirmModal.heading')}
|
||||||
|
message={t('confirmModal.message')}
|
||||||
|
onConfirm={async () => remove({ variables: { id: session.id } })}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={isLoadingRemove}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{t('deleteButton')}
|
||||||
|
</Button>
|
||||||
|
</ConfirmModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SessionModal session={session}>
|
||||||
|
<Button>
|
||||||
|
{t('detailsButton')}
|
||||||
|
</Button>
|
||||||
|
</SessionModal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
frontend/src/components/features/user/sessions/SessionModal.tsx
Normal file
110
frontend/src/components/features/user/sessions/SessionModal.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { Map, Placemark, YMaps } from '@pbe/react-yandex-maps';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/common/Dialog';
|
||||||
|
import { formatDate } from '@/utils/format-date';
|
||||||
|
|
||||||
|
import type { FindSessionsByUserQuery } from '@/graphql/generated/output';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
type SessionModalProps = {
|
||||||
|
session: FindSessionsByUserQuery['findSessionsByUser'][0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionModal = ({
|
||||||
|
children,
|
||||||
|
session,
|
||||||
|
}: PropsWithChildren<SessionModalProps>) => {
|
||||||
|
const t = useTranslations('dashboard.settings.sessions.sessionModal');
|
||||||
|
|
||||||
|
const center = [
|
||||||
|
session.metadata.location.latitude,
|
||||||
|
session.metadata.location.longitude,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
{t('heading')}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('device')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{session.metadata.device.browser}
|
||||||
|
|
||||||
|
,
|
||||||
|
{' '}
|
||||||
|
|
||||||
|
{session.metadata.device.os}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('location')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{session.metadata.location.country}
|
||||||
|
|
||||||
|
,
|
||||||
|
{' '}
|
||||||
|
|
||||||
|
{session.metadata.location.city}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('ipAddress')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{session.metadata.ip}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('createdAt')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{formatDate(session.createdAt, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<YMaps>
|
||||||
|
<div style={{ width: '100%', height: '300px' }}>
|
||||||
|
<Map
|
||||||
|
defaultState={{
|
||||||
|
center,
|
||||||
|
zoom: 11,
|
||||||
|
}}
|
||||||
|
height="100%"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Placemark geometry={center} />
|
||||||
|
</Map>
|
||||||
|
</div>
|
||||||
|
</YMaps>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Heading } from '@/components/ui/elements/Heading';
|
||||||
|
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
|
||||||
|
import {
|
||||||
|
useFindCurrentSessionQuery,
|
||||||
|
useFindSessionsByUserQuery,
|
||||||
|
} from '@/graphql/generated/output';
|
||||||
|
|
||||||
|
import { SessionItem } from './SessionItem';
|
||||||
|
|
||||||
|
export const SessionsList = () => {
|
||||||
|
const t = useTranslations('dashboard.settings.sessions');
|
||||||
|
|
||||||
|
const { data: sessionData, loading: isLoadingCurrent } = useFindCurrentSessionQuery();
|
||||||
|
const currentSession = sessionData?.findCurrentSession;
|
||||||
|
|
||||||
|
const { data: sessionsData, loading: isLoadingSessions } = useFindSessionsByUserQuery();
|
||||||
|
const sessions = sessionsData?.findSessionsByUser ?? [];
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Heading size="sm" title={t('info.current')} />
|
||||||
|
|
||||||
|
{isLoadingCurrent
|
||||||
|
? (
|
||||||
|
<ToggleCardSkeleton />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<SessionItem isCurrentSession session={currentSession} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Heading size="sm" title={t('info.active')} />
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{isLoadingSessions
|
||||||
|
? Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<ToggleCardSkeleton key={index} />
|
||||||
|
))
|
||||||
|
: sessions.length
|
||||||
|
? sessions.map((session, index) => (
|
||||||
|
<SessionItem key={session.id} session={session} />
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t('info.notFound')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
frontend/src/components/ui/common/AlertDialog.tsx
Normal file
147
frontend/src/components/ui/common/AlertDialog.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
import { buttonVariants } from './Button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = forwardRef<
|
||||||
|
ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: 'secondary' }),
|
||||||
|
'mt-2 sm:mt-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
130
frontend/src/components/ui/common/Dialog.tsx
Normal file
130
frontend/src/components/ui/common/Dialog.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = forwardRef<
|
||||||
|
ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = forwardRef<
|
||||||
|
ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="size-4" />
|
||||||
|
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = forwardRef<
|
||||||
|
ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-wide',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = forwardRef<
|
||||||
|
ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('textcn-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
170
frontend/src/components/ui/common/Select.tsx
Normal file
170
frontend/src/components/ui/common/Select.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({
|
||||||
|
className, children, position = 'popper', ...props
|
||||||
|
}, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper'
|
||||||
|
&& 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.Label>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.Item>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = forwardRef<
|
||||||
|
ComponentRef<typeof SelectPrimitive.Separator>,
|
||||||
|
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
33
frontend/src/components/ui/common/Switch.tsx
Normal file
33
frontend/src/components/ui/common/Switch.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Switch = forwardRef<
|
||||||
|
ComponentRef<typeof SwitchPrimitives.Root>,
|
||||||
|
ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
124
frontend/src/components/ui/common/Table.tsx
Normal file
124
frontend/src/components/ui/common/Table.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
type HTMLAttributes,
|
||||||
|
type TdHTMLAttributes,
|
||||||
|
type ThHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableFooter = forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = 'TableFooter';
|
||||||
|
|
||||||
|
const TableRow = forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-card data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
const TableCaption = forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = 'TableCaption';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
};
|
||||||
61
frontend/src/components/ui/common/Tabs.tsx
Normal file
61
frontend/src/components/ui/common/Tabs.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ComponentRef,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = forwardRef<
|
||||||
|
ComponentRef<typeof TabsPrimitive.List>,
|
||||||
|
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-md bg-card p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = forwardRef<
|
||||||
|
ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'data-data-[state=active]:shadow-sm inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium text-foreground ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = forwardRef<
|
||||||
|
ComponentRef<typeof TabsPrimitive.Content>,
|
||||||
|
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||||
|
};
|
||||||
19
frontend/src/components/ui/common/Textarea.tsx
Normal file
19
frontend/src/components/ui/common/Textarea.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { type ComponentProps, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const Textarea = forwardRef<HTMLTextAreaElement, ComponentProps<'textarea'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex max-h-[80px] min-h-[80px] w-full rounded-md border border-border bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
68
frontend/src/components/ui/elements/CardContainer.tsx
Normal file
68
frontend/src/components/ui/elements/CardContainer.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
import { Card } from '../common/Card';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
import type { IconType } from 'react-icons';
|
||||||
|
|
||||||
|
type CardContainerProps = {
|
||||||
|
heading: string;
|
||||||
|
description?: string;
|
||||||
|
Icon?: IconType | LucideIcon;
|
||||||
|
isRightContentFull?: boolean;
|
||||||
|
rightContent?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardContainer = ({
|
||||||
|
heading,
|
||||||
|
description,
|
||||||
|
Icon,
|
||||||
|
isRightContentFull,
|
||||||
|
rightContent,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<CardContainerProps>) => (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-row items-center gap-x-4">
|
||||||
|
{Icon
|
||||||
|
? (
|
||||||
|
<div className="rounded-full bg-foreground p-2.5">
|
||||||
|
<Icon className="size-7 text-secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="font-semibold tracking-wide">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{description
|
||||||
|
? (
|
||||||
|
<p className="max-w-4xl text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rightContent
|
||||||
|
? (
|
||||||
|
<div className={cn(isRightContentFull && 'ml-6 w-full')}>
|
||||||
|
{rightContent}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children
|
||||||
|
? (
|
||||||
|
<div className="mt-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@ -34,10 +34,14 @@ export const ChannelAvatar = ({ size, channel, isLive }: ChannelAvatarProps) =>
|
|||||||
isLive && 'ring-2 ring-rose-500',
|
isLive && 'ring-2 ring-rose-500',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AvatarImage
|
{channel.avatar
|
||||||
className="object-cover"
|
? (
|
||||||
src={getMediaSource(channel.avatar)}
|
<AvatarImage
|
||||||
/>
|
className="object-cover"
|
||||||
|
src={getMediaSource(channel.avatar)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
23
frontend/src/components/ui/elements/ColorSwitcher.tsx
Normal file
23
frontend/src/components/ui/elements/ColorSwitcher.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useConfig } from '@/hooks/useConfig';
|
||||||
|
|
||||||
|
export const ColorSwitcher = () => {
|
||||||
|
const { theme } = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.forEach((className) => {
|
||||||
|
if ((/^theme.*/).exec(className)) {
|
||||||
|
document.body.classList.remove(className);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (theme) {
|
||||||
|
document.body.classList.add(`theme-${theme}`);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
62
frontend/src/components/ui/elements/ConfirmModal.tsx
Normal file
62
frontend/src/components/ui/elements/ConfirmModal.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '../common/AlertDialog';
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
type ConfirmModalProps = {
|
||||||
|
heading: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfirmModal = ({
|
||||||
|
children,
|
||||||
|
heading,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
}: PropsWithChildren<ConfirmModalProps>) => {
|
||||||
|
const t = useTranslations('components.confirmModal');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
{children}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{heading}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{message}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t('cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
{t('continue')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
frontend/src/components/ui/elements/CopyButton.tsx
Normal file
40
frontend/src/components/ui/elements/CopyButton.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '../common/Button';
|
||||||
|
|
||||||
|
type CopyButtonProps = {
|
||||||
|
value?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CopyButton = ({ value }: CopyButtonProps) => {
|
||||||
|
const t = useTranslations('components.copyButton');
|
||||||
|
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
function onCopy() {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
setIsCopied(true);
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
toast.success(t('successMessage'));
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = isCopied ? Check : Copy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
disabled={!value || isCopied}
|
||||||
|
size="lgIcon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { onCopy(); }}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
99
frontend/src/components/ui/elements/DataTable.tsx
Normal file
99
frontend/src/components/ui/elements/DataTable.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { Card } from '../common/Card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '../common/Table';
|
||||||
|
|
||||||
|
type DataTableProps<TData, TValue> = {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTable = <TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: DataTableProps<TData, TValue>) => {
|
||||||
|
const t = useTranslations('components.dataTable');
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length
|
||||||
|
? table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
className="h-24 text-center"
|
||||||
|
colSpan={columns.length}
|
||||||
|
>
|
||||||
|
{t('notFound')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableSkeleton = () => (
|
||||||
|
<div className="mx-auto mb-10 w-full max-w-(--breakpoint-2xl)">
|
||||||
|
<Card className="mt-6 flex h-[500px] w-full items-center justify-center">
|
||||||
|
<Loader className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
26
frontend/src/components/ui/elements/FormWrapper.tsx
Normal file
26
frontend/src/components/ui/elements/FormWrapper.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Card, CardContent, CardHeader, CardTitle,
|
||||||
|
} from '../common/Card';
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
type FormWrapperProps = {
|
||||||
|
heading: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormWrapper = ({
|
||||||
|
children,
|
||||||
|
heading,
|
||||||
|
}: PropsWithChildren<FormWrapperProps>) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{heading}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
43
frontend/src/components/ui/elements/Heading.tsx
Normal file
43
frontend/src/components/ui/elements/Heading.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tw-merge';
|
||||||
|
|
||||||
|
const headingSizes = cva('', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'text-lg',
|
||||||
|
default: 'text-2xl',
|
||||||
|
lg: 'text-4xl',
|
||||||
|
xl: 'text-5xl',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type HeadingProps = VariantProps<typeof headingSizes> & {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Heading = ({ size, title, description }: HeadingProps) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
'font-semibold text-foreground',
|
||||||
|
headingSizes({ size }),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{description
|
||||||
|
? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
34
frontend/src/components/ui/elements/ToggleCard.tsx
Normal file
34
frontend/src/components/ui/elements/ToggleCard.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Skeleton } from '../common/Skeleton';
|
||||||
|
import { Switch } from '../common/Switch';
|
||||||
|
|
||||||
|
import { CardContainer } from './CardContainer';
|
||||||
|
|
||||||
|
type ToggleCardProps = {
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
value: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToggleCard = ({
|
||||||
|
heading,
|
||||||
|
description,
|
||||||
|
isDisabled,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: ToggleCardProps) => (
|
||||||
|
<CardContainer
|
||||||
|
description={description}
|
||||||
|
heading={heading}
|
||||||
|
rightContent={(
|
||||||
|
<Switch
|
||||||
|
checked={value}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ToggleCardSkeleton = () => <Skeleton className="mt-6 h-20 w-full" />;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,8 @@
|
|||||||
|
mutation DeactivateAccount($data: DeactivateAccountInput!) {
|
||||||
|
deactivateAccount(data: $data) {
|
||||||
|
user {
|
||||||
|
isDeactivated
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation ChangeChatSettings($data: ChangeChatSettingsInput!) {
|
||||||
|
changeChatSettings(data: $data) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation SendChatMessage($data: SendMessageInput!) {
|
||||||
|
sendChatMessage(data: $data) {
|
||||||
|
streamId
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateSponsorshipPlan($data: CreatePlanInput!) {
|
||||||
|
createSponsorshipPlan(data: $data) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation RemoveSponsorshipPlan($planId: String!) {
|
||||||
|
removeSponsorshipPlan(planId: $planId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation MakePayment($planId: String!) {
|
||||||
|
makePayment(planId: $planId) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation CreateIngress($ingressType: Float!) {
|
||||||
|
createIngress(ingressType: $ingressType)
|
||||||
|
}
|
||||||
6
frontend/src/graphql/mutations/user/ChangeEmail.graphql
Normal file
6
frontend/src/graphql/mutations/user/ChangeEmail.graphql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
mutation ChangeEmail($data: ChangeEmailInput!) {
|
||||||
|
changeEmail(data: $data) {
|
||||||
|
email
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
mutation ChangeNotificationsSettings($data: ChangeNotificationSettingsInput!) {
|
||||||
|
changeNotificationSettings(data: $data) {
|
||||||
|
notificationSettings {
|
||||||
|
siteNotifications
|
||||||
|
telegramNotifications
|
||||||
|
}
|
||||||
|
telegramAuthToken
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
mutation ChangePassword($data: ChangePasswordInput!) {
|
||||||
|
changePassword(data: $data) {
|
||||||
|
name
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation ChangeProfileAvatar($avatar: Upload!) {
|
||||||
|
changeProfileAvatar(avatar: $avatar)
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
mutation ChangeProfileInfo($data: ChangeProfileInfoInput!) {
|
||||||
|
changeProfileInfo(data: $data) {
|
||||||
|
name
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation ClearSessionCookie {
|
||||||
|
clearSessionCookie
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateSocialLink($data: SocialLinkInput!) {
|
||||||
|
createSocialLink(data: $data) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/src/graphql/mutations/user/DisableTotp.graphql
Normal file
3
frontend/src/graphql/mutations/user/DisableTotp.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation DisableTotp {
|
||||||
|
disableTotp
|
||||||
|
}
|
||||||
3
frontend/src/graphql/mutations/user/EnableTotp.graphql
Normal file
3
frontend/src/graphql/mutations/user/EnableTotp.graphql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mutation EnableTotp($data: EnableTotpInput!) {
|
||||||
|
enableTotp(data: $data)
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation RemoveProfileAvatar {
|
||||||
|
removeProfileAvatar
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation RemoveSession($id: String!) {
|
||||||
|
removeSession(id: $id)
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation RemoveSocialLink($id: String!) {
|
||||||
|
removeSocialLink(id: $id)
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
mutation ReorderSocialLinks($list: [SocialLinkOrderInput!]!) {
|
||||||
|
reorderSocialLink(list: $list)
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
mutation UpdateSocialLink($id: String!, $data: SocialLinkInput!) {
|
||||||
|
updateSocialLink(id: $id, data: $data) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/graphql/queries/follow/FindMyFollowers.graphql
Normal file
10
frontend/src/graphql/queries/follow/FindMyFollowers.graphql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
query FindMyFollowers {
|
||||||
|
findMyFollowers {
|
||||||
|
createdAt
|
||||||
|
follower {
|
||||||
|
name
|
||||||
|
avatar
|
||||||
|
isVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
query FindMyFollowings {
|
||||||
|
findMyFollowings {
|
||||||
|
createdAt
|
||||||
|
followingId
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
query FindMySponsorshipPlans {
|
||||||
|
findMySponsorshipPlans {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
title
|
||||||
|
price
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
query FindMySponsors {
|
||||||
|
findMySponsors {
|
||||||
|
expiresAt
|
||||||
|
user {
|
||||||
|
name
|
||||||
|
avatar
|
||||||
|
isVerified
|
||||||
|
}
|
||||||
|
plan {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
query FindMyTransactions {
|
||||||
|
findMyTransactions {
|
||||||
|
createdAt
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/src/graphql/queries/user/FindCurrentSession.graphql
Normal file
19
frontend/src/graphql/queries/user/FindCurrentSession.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
query FindCurrentSession {
|
||||||
|
findCurrentSession {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
metadata {
|
||||||
|
location {
|
||||||
|
country
|
||||||
|
city
|
||||||
|
latitude
|
||||||
|
longitude
|
||||||
|
}
|
||||||
|
device {
|
||||||
|
browser
|
||||||
|
os
|
||||||
|
}
|
||||||
|
ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,5 +16,16 @@ query FindProfile {
|
|||||||
url
|
url
|
||||||
position
|
position
|
||||||
}
|
}
|
||||||
|
notificationSettings {
|
||||||
|
siteNotifications
|
||||||
|
telegramNotifications
|
||||||
|
}
|
||||||
|
stream {
|
||||||
|
serverUrl
|
||||||
|
key
|
||||||
|
isChatEnable
|
||||||
|
isChatFollowersOnly
|
||||||
|
isChatPremiumFollowersOnly
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/src/graphql/queries/user/FindSessionsByUser.graphql
Normal file
19
frontend/src/graphql/queries/user/FindSessionsByUser.graphql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
query FindSessionsByUser {
|
||||||
|
findSessionsByUser {
|
||||||
|
id
|
||||||
|
createdAt
|
||||||
|
metadata {
|
||||||
|
location {
|
||||||
|
country
|
||||||
|
city
|
||||||
|
latitude
|
||||||
|
longitude
|
||||||
|
}
|
||||||
|
device {
|
||||||
|
browser
|
||||||
|
os
|
||||||
|
}
|
||||||
|
ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
query FindSocialLinks {
|
||||||
|
findSocialLinks {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
url
|
||||||
|
position
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
query GenerateTotpSecret {
|
||||||
|
generateTotpSecret {
|
||||||
|
qrcodeUrl
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useConfig.ts
Normal file
11
frontend/src/hooks/useConfig.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { configStore } from '@/store/config/config.store';
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
const theme = configStore((state) => state.theme);
|
||||||
|
const setTheme = configStore((state) => state.setTheme);
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,13 +1,41 @@
|
|||||||
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
|
import { ApolloClient, InMemoryCache, split } from '@apollo/client';
|
||||||
|
import { WebSocketLink } from '@apollo/client/link/ws';
|
||||||
|
import { getMainDefinition } from '@apollo/client/utilities';
|
||||||
|
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
|
||||||
|
import { OperationTypeNode } from 'graphql/language';
|
||||||
|
import { Kind } from 'graphql/language/kinds';
|
||||||
|
|
||||||
import { SERVER_URL } from './constants/url.constants';
|
import { SERVER_URL, WEBSOCKET_URL } from './constants/url.constants';
|
||||||
|
|
||||||
const httpLink = createHttpLink({
|
const httpLink = createUploadLink({
|
||||||
uri: SERVER_URL,
|
uri: SERVER_URL,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'apollo-require-preflight': 'true',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wsLink = new WebSocketLink({
|
||||||
|
uri: WEBSOCKET_URL,
|
||||||
|
options: {
|
||||||
|
reconnect: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitLink = split(
|
||||||
|
({ query }) => {
|
||||||
|
const definition = getMainDefinition(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
definition.kind === Kind.OPERATION_DEFINITION
|
||||||
|
&& definition.operation === OperationTypeNode.SUBSCRIPTION
|
||||||
|
);
|
||||||
|
},
|
||||||
|
wsLink,
|
||||||
|
httpLink,
|
||||||
|
);
|
||||||
|
|
||||||
export const client = new ApolloClient({
|
export const client = new ApolloClient({
|
||||||
link: httpLink,
|
link: splitLink,
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache(),
|
||||||
});
|
});
|
||||||
|
|||||||
36
frontend/src/libs/constants/colors.constants.ts
Normal file
36
frontend/src/libs/constants/colors.constants.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export const BASE_COLORS = [
|
||||||
|
{
|
||||||
|
name: 'violet',
|
||||||
|
color: '262.1 83.3% 57.8%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blue',
|
||||||
|
color: '204, 70%, 53%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'turquoise',
|
||||||
|
color: '176, 77%, 41%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'yellow',
|
||||||
|
color: '48, 89%, 50%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'peach',
|
||||||
|
color: '17, 94%, 67%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pink',
|
||||||
|
color: '330.4 81.2% 60.4%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rose',
|
||||||
|
color: '340, 82%, 52%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'red',
|
||||||
|
color: '0 72.2% 50.6%',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TypeBaseColor = (typeof BASE_COLORS)[number]['name'];
|
||||||
15
frontend/src/libs/constants/seo.constants.ts
Normal file
15
frontend/src/libs/constants/seo.constants.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const NO_INDEX_PAGE = { robots: { index: false, follow: false } };
|
||||||
|
|
||||||
|
export const SITE_NAME = 'TeaStream';
|
||||||
|
|
||||||
|
export const SITE_DESCRIPTION = 'TeaStream — это платформа для прямых трансляций, которая соединяет стримеров и зрителей. Делитесь своими увлечениями и присоединяйтесь к сообществу TeaStream!';
|
||||||
|
|
||||||
|
export const SITE_KEYWORDS = [
|
||||||
|
'TeaStream',
|
||||||
|
'платформа для прямых трансляций',
|
||||||
|
'стриминг',
|
||||||
|
'прямые трансляции',
|
||||||
|
'взаимодействие со зрителями',
|
||||||
|
'видео-контент',
|
||||||
|
'онлайн-сообщество',
|
||||||
|
];
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL as string;
|
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL as string;
|
||||||
|
export const WEBSOCKET_URL = process.env.NEXT_PUBLIC_WEBSOCKET_URL as string;
|
||||||
export const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL as string;
|
export const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL as string;
|
||||||
|
|||||||
9
frontend/src/schemas/auth/deactivate.schema.ts
Normal file
9
frontend/src/schemas/auth/deactivate.schema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const deactivateSchema = z.object({
|
||||||
|
email: z.email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
pin: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TypeDeactivateSchema = z.infer<typeof deactivateSchema>;
|
||||||
11
frontend/src/schemas/chat/change-chat.settings.schema.ts
Normal file
11
frontend/src/schemas/chat/change-chat.settings.schema.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const changeChatSettingsSchema = z.object({
|
||||||
|
isChatEnable: z.boolean(),
|
||||||
|
isChatFollowersOnly: z.boolean(),
|
||||||
|
isChatPremiumFollowersOnly: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TypeChangeChatSettingsSchema = z.infer<
|
||||||
|
typeof changeChatSettingsSchema
|
||||||
|
>;
|
||||||
7
frontend/src/schemas/chat/send-message.schema.ts
Normal file
7
frontend/src/schemas/chat/send-message.schema.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const sendMessageSchema = z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TypeSendMessageSchema = z.infer<typeof sendMessageSchema>;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user