🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import Link from 'next/link';
import LanguageSwitch from '@/components/language-switch';
import ThemeSwitch from '@/components/theme-switch';
import useGlobalStore from '@/config/use-global';
import { LoginIcon } from '@repo/ui/lotties';
import UserAuthForm from './user-auth-form';
export default function Page() {
const t = useTranslations('auth');
const { common } = useGlobalStore();
const { site } = common;
return (
<main className='bg-muted/50 flex h-full min-h-screen items-center'>
<div className='flex size-full flex-auto flex-col lg:flex-row'>
<div className='flex bg-cover bg-center lg:w-1/2 lg:flex-auto'>
<div className='lg:py-15 md:px-15 flex w-full flex-col items-center justify-center px-5 py-7'>
<Link className='mb-0 flex flex-col items-center lg:mb-12' href='/'>
<Image src={site.site_logo} height={48} width={48} alt='logo' />
<span className='text-2xl font-semibold'>{site.site_name}</span>
</Link>
<LoginIcon className='mx-auto hidden w-[275px] md:w-1/2 lg:block xl:w-[500px]' />
<p className='hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]'>
{site.site_desc}
</p>
</div>
</div>
<div className='flex flex-initial justify-center p-12 lg:flex-auto lg:justify-end'>
<div className='lg:bg-background flex flex-col items-center rounded-2xl p-10 md:w-[600px] lg:flex-auto lg:shadow'>
<div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'>
<div className='pb-15 flex flex-col justify-center lg:flex-auto lg:pb-20'>
<UserAuthForm />
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-5'>
<LanguageSwitch />
<ThemeSwitch />
</div>
<div className='text-primary flex gap-5 text-sm font-semibold'>
<Link href='/tos'>{t('tos')}</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import Turnstile, { useTurnstile } from 'react-turnstile';
import useGlobalStore from '@/config/use-global';
export default function CloudFlareTurnstile({
id,
value,
onChange,
}: {
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}) {
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const locale = useLocale();
const turnstile = useTurnstile();
useEffect(() => {
if (value === '') {
turnstile.reset();
}
}, [turnstile, value]);
return (
verify.turnstile_site_key && (
<Turnstile
id={id}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as 'light' | 'dark'}
language={locale.toLowerCase()}
fixedSize
onVerify={(token) => onChange(token)}
// onError={() => {
// onChange();
// turnstile.reset();
// }}
onExpire={() => {
onChange();
turnstile.reset();
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
/>
)
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkUser, resetPassword, userLogin, userRegister } from '@/services/common/auth';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { toast } from '@shadcn/ui/lib/sonner';
import { cn } from '@shadcn/ui/lib/utils';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { ReactNode, useState, useTransition } from 'react';
import {
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
} from '@/config/constants';
import { getRedirectUrl, setAuthorization } from '@/utils/common';
import UserCheckForm from './user-check-form';
import UserLoginForm from './user-login-form';
import UserRegisterForm from './user-register-form';
import UserResetForm from './user-reset-form';
export default function UserAuthForm() {
const t = useTranslations('auth');
const { common } = useGlobalStore();
const { register } = common;
const router = useRouter();
const [type, setType] = useState<'login' | 'register' | 'reset'>();
const [loading, startTransition] = useTransition();
const [initialValues, setInitialValues] = useState<{
email?: string;
password?: string;
}>({
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
});
const handleFormSubmit = async (params: any) => {
const onLogin = async (token?: string) => {
if (!token) return;
setAuthorization(token);
router.replace(getRedirectUrl());
router.refresh();
};
startTransition(async () => {
try {
switch (type) {
case 'login':
// eslint-disable-next-line no-case-declarations
const login = await userLogin(params);
toast.success(t('login.success'));
onLogin(login.data.data?.token);
break;
case 'register':
// eslint-disable-next-line no-case-declarations
const create = await userRegister(params);
toast.success(t('register.success'));
onLogin(create.data.data?.token);
break;
case 'reset':
await resetPassword(params);
toast.success(t('reset.success'));
setType('login');
break;
default:
if (type === 'reset') break;
// eslint-disable-next-line no-case-declarations
const response = await checkUser(params);
setInitialValues({
...initialValues,
...params,
});
setType(response.data.data?.exist ? 'login' : 'register');
break;
}
} catch (error) {
/* empty */
}
});
};
let UserForm: ReactNode = null;
switch (type) {
case 'login':
UserForm = (
<UserLoginForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
case 'register':
UserForm = (
<UserRegisterForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
case 'reset':
UserForm = (
<UserResetForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
default:
UserForm = (
<UserCheckForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
/>
);
break;
}
return (
<>
<div className='mb-11 text-center'>
<h1 className='mb-3 text-2xl font-bold'>{t(`${type || 'check'}.title`)}</h1>
<div className='text-muted-foreground font-medium'>
{t(`${type || 'check'}.description`)}
</div>
</div>
{!((type === 'register' && register.stop_register) || type === 'reset') && (
<>
<div className='mb-3 flex flex-wrap items-center justify-center gap-3 font-bold'>
<Button type='button' variant='outline'>
<Icon icon='uil:telegram' className='mr-2 size-5' />
Telegram
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:google' className='mr-2 size-5' />
Google
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:apple' className='mr-2 size-5' />
Apple
</Button>
</div>
<div
className={cn(
'my-14 flex h-0 items-center text-center',
'before:mr-4 before:block before:w-1/2 before:border-b-[1px]',
'after:ml-4 after:w-1/2 after:border-b-[1px]',
)}
>
<span className='text-muted-foreground w-[125px] text-sm'>{t('orWithEmail')}</span>
</div>
</>
)}
{UserForm}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
export default function UserCheckForm({
loading,
onSubmit,
initialValues,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
}) {
const t = useTranslations('auth.check');
const { common } = useGlobalStore();
const { register } = common;
const formSchema = z.object({
email: z
.string()
.email(t('email'))
.refine(
(email) => {
if (!register.enable_email_domain_suffix) return true;
const domain = email.split('@')[1];
return register.email_domain_suffix_list.split('\n').includes(domain || '');
},
{
message: t('whitelist'),
},
),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleEmailChange = (value: string) => {
form.setValue('email', value);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(async () => {
const isValid = await form.trigger('email');
if (isValid) {
setIsSubmitting(true);
form.handleSubmit(onSubmit)();
} else {
setIsSubmitting(false);
}
}, 500);
};
useEffect(() => {
if (initialValues && initialValues.email) {
handleEmailChange(initialValues.email);
}
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your email...'
type='email'
{...field}
onChange={(e) => handleEmailChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={!isSubmitting}>
{isSubmitting ? (
<>
<Icon icon='mdi:loading' className='mr-2 size-5 animate-spin' />
{t('checking')}
</>
) : (
t('continue')
)}
</Button>
</form>
</Form>
);
}
+109
View File
@@ -0,0 +1,109 @@
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserLoginForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.login');
const { common } = useGlobalStore();
const { verify } = common;
const formSchema = z.object({
email: z.string(),
password: z.string(),
cf_token: verify.enable_login_verify ? z.string() : z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_login_verify && (
<FormField
control={form.control}
name='cf_token'
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile id='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
<div className='mt-4 flex w-full justify-between text-sm'>
<Button variant='link' type='button' className='p-0' onClick={() => onSwitchForm('reset')}>
{t('forgotPassword')}
</Button>
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchAccount')}
</Button>
</div>
</>
);
}
+204
View File
@@ -0,0 +1,204 @@
import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Markdown } from '@repo/ui/markdown';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserRegisterForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.register');
const { common } = useGlobalStore();
const { verify, register, invite } = common;
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const handleSendCode = async () => {
await sendEmailCode({
email: initialValues.email,
type: 1,
});
setTargetDate(Date.now() + 60000);
};
const formSchema = z
.object({
email: z.string(),
password: z.string(),
repeat_password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string() : z.string().nullish(),
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('passwordMismatch'),
path: ['repeat_password'],
});
}
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
invite: sessionStorage.getItem('invite'),
},
});
return (
<>
{register.stop_register ? (
<Markdown>{t('message')}</Markdown>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='repeat_password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter password again...'
type='password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{register.enable_email_verify && (
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormControl>
<div className='flex items-center gap-2'>
<Input
disabled={loading}
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='invite'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder={t('invite')}
{...field}
value={field.value || ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_register_verify && (
<FormField
control={form.control}
name='cf_token'
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile id='register' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
)}
<div className='mt-4 text-right text-sm'>
{t('existingAccount')}
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchToLogin')}
</Button>
</div>
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserResetForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.reset');
const { common } = useGlobalStore();
const { verify, register } = common;
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const handleSendCode = async () => {
await sendEmailCode({
email: initialValues.email,
type: 2,
});
setTargetDate(Date.now() + 60000); // 60秒倒计时
};
const formSchema = z.object({
email: z.string(),
password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(),
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{register.enable_email_verify && (
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormControl>
<div className='flex items-center gap-2'>
<Input
disabled={loading}
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name='cf_token'
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile id='reset' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
<div className='mt-4 text-right text-sm'>
{t('existingAccount')}
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchToLogin')}
</Button>
</div>
</>
);
}