build ok,
This commit is contained in:
82
002_source/cms/src/components/auth/auth-guard.tsx
Normal file
82
002_source/cms/src/components/auth/auth-guard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { paths } from '@/paths';
|
||||
import { AuthStrategy } from '@/lib/auth/strategy';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
import { useUser } from '@/hooks/use-user';
|
||||
|
||||
export interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps): React.JSX.Element | null {
|
||||
const router = useRouter();
|
||||
const { user, error, isLoading } = useUser();
|
||||
const [isChecking, setIsChecking] = React.useState<boolean>(true);
|
||||
|
||||
const checkPermissions = async (): Promise<void> => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
logger.debug('[AuthGuard]: User is not logged in, redirecting to sign in');
|
||||
|
||||
switch (config.auth.strategy) {
|
||||
case AuthStrategy.CUSTOM: {
|
||||
router.replace(paths.auth.custom.signIn);
|
||||
return;
|
||||
}
|
||||
case AuthStrategy.AUTH0: {
|
||||
router.replace(paths.auth.auth0.signIn);
|
||||
return;
|
||||
}
|
||||
case AuthStrategy.COGNITO: {
|
||||
router.replace(paths.auth.cognito.signIn);
|
||||
return;
|
||||
}
|
||||
case AuthStrategy.FIREBASE: {
|
||||
router.replace(paths.auth.firebase.signIn);
|
||||
return;
|
||||
}
|
||||
case AuthStrategy.SUPABASE: {
|
||||
router.replace(paths.auth.supabase.signIn);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
logger.error('[AuthGuard]: Unknown auth strategy');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
checkPermissions().catch(() => {
|
||||
// noop
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
|
||||
}, [user, error, isLoading]);
|
||||
|
||||
if (isChecking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert color="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
23
002_source/cms/src/components/auth/centered-layout.tsx
Normal file
23
002_source/cms/src/components/auth/centered-layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export interface CenteredLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CenteredLayout({ children }: CenteredLayoutProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: '560px', width: '100%' }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { confirmSignIn } from 'aws-amplify/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod
|
||||
.object({
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
confirmPassword: zod.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { password: '', confirmPassword: '' } satisfies Values;
|
||||
|
||||
export function NewPasswordRequiredForm(): React.JSX.Element {
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const { nextStep } = await confirmSignIn({ challengeResponse: values.password });
|
||||
|
||||
if (nextStep.signInStep === 'DONE') {
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled next step: ${nextStep.signInStep}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">New password required</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmPassword)}>
|
||||
<InputLabel>Confirm password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.confirmPassword ? <FormHelperText>{errors.confirmPassword.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Confirm
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { resetPassword } from '@aws-amplify/auth';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
export interface ResetPasswordButtonProps {
|
||||
children: React.ReactNode;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function ResetPasswordButton({ children, email }: ResetPasswordButtonProps): React.JSX.Element {
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
const [submitError, setSubmitError] = React.useState<string>();
|
||||
|
||||
const handle = React.useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
setSubmitError(undefined);
|
||||
|
||||
try {
|
||||
await resetPassword({ username: email });
|
||||
|
||||
setIsPending(false);
|
||||
toast.success('Recovery code sent');
|
||||
} catch (err) {
|
||||
setSubmitError((err as { message: string }).message);
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [email]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Button disabled={isPending} onClick={handle}>
|
||||
{children}
|
||||
</Button>
|
||||
{submitError ? <Alert color="error">{submitError}</Alert> : null}
|
||||
<Typography sx={{ textAlign: 'center' }} variant="body2">
|
||||
Wait a few minutes then try again
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { resetPassword } from '@aws-amplify/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({ email: zod.string().min(1, { message: 'Email is required' }).email() });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '' } satisfies Values;
|
||||
|
||||
export function ResetPasswordForm(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
await resetPassword({ username: values.email });
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.cognito.updatePassword}?${searchParams.toString()}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Reset password</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Send recovery code
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
161
002_source/cms/src/components/auth/cognito/sign-in-form.tsx
Normal file
161
002_source/cms/src/components/auth/cognito/sign-in-form.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Eye as EyeIcon } from '@phosphor-icons/react/dist/ssr/Eye';
|
||||
import { EyeSlash as EyeSlashIcon } from '@phosphor-icons/react/dist/ssr/EyeSlash';
|
||||
import { resendSignUpCode, signIn } from 'aws-amplify/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(1, { message: 'Password is required' }),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '', password: '' } satisfies Values;
|
||||
|
||||
export function SignInForm(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState<boolean>();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const { nextStep } = await signIn({ username: values.email, password: values.password });
|
||||
|
||||
if (nextStep.signInStep === 'DONE') {
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStep.signInStep === 'CONFIRM_SIGN_UP') {
|
||||
await resendSignUpCode({ username: values.email });
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.cognito.signUpConfirm}?${searchParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED') {
|
||||
router.push(paths.auth.cognito.newPasswordRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled next step: ${nextStep.signInStep}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign in</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Don't have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.cognito.signUp} variant="subtitle2">
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={2}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput
|
||||
{...field}
|
||||
endAdornment={
|
||||
showPassword ? (
|
||||
<EyeIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EyeSlashIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<div>
|
||||
<Link component={RouterLink} href={paths.auth.cognito.resetPassword} variant="subtitle2">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { autoSignIn, confirmSignUp } from 'aws-amplify/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({ confirmationCode: zod.string().min(1, { message: 'Code is required' }) });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { confirmationCode: '' } satisfies Values;
|
||||
|
||||
export interface SignUpConfirmFormProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function SignUpConfirmForm({ email }: SignUpConfirmFormProps): React.JSX.Element {
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const { nextStep } = await confirmSignUp({ username: email, confirmationCode: values.confirmationCode });
|
||||
|
||||
if (nextStep.signUpStep === 'DONE') {
|
||||
// Unless you disabled `autoSignIn` in signUp
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
// Otherwise you should redirect to the sign in page.
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStep.signUpStep === 'COMPLETE_AUTO_SIGN_IN') {
|
||||
await autoSignIn();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled next step: ${nextStep.signUpStep}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[email, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Confirm your email</Typography>
|
||||
<Typography>
|
||||
We've sent a verification email to{' '}
|
||||
<Typography component="span" variant="subtitle1">
|
||||
"{email}"
|
||||
</Typography>
|
||||
.
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmationCode"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmationCode)}>
|
||||
<InputLabel>Confirmation code</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.confirmationCode ? <FormHelperText>{errors.confirmationCode.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Confirm
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
170
002_source/cms/src/components/auth/cognito/sign-up-form.tsx
Normal file
170
002_source/cms/src/components/auth/cognito/sign-up-form.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signUp } from '@aws-amplify/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({
|
||||
firstName: zod.string().min(1, { message: 'First name is required' }),
|
||||
lastName: zod.string().min(1, { message: 'Last name is required' }),
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
terms: zod.boolean().refine((value) => value, 'You must accept the terms and conditions'),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
|
||||
|
||||
export function SignUpForm(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const { nextStep } = await signUp({
|
||||
username: values.email,
|
||||
password: values.password,
|
||||
// @ts-expect-error -- Type error
|
||||
options: { autoSignIn: true },
|
||||
});
|
||||
|
||||
if (nextStep.signUpStep === 'DONE') {
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStep.signUpStep === 'CONFIRM_SIGN_UP') {
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.cognito.signUpConfirm}?${searchParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled next step: ${nextStep.signUpStep}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign up</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Already have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.cognito.signIn} variant="subtitle2">
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.firstName)}>
|
||||
<InputLabel>First name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.lastName)}>
|
||||
<InputLabel>Last name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="terms"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...field} />}
|
||||
label={
|
||||
<React.Fragment>
|
||||
I have read the <Link>terms and conditions</Link>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Create account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { confirmResetPassword } from '@aws-amplify/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
import { ResetPasswordButton } from './reset-password-button';
|
||||
|
||||
const schema = zod
|
||||
.object({
|
||||
confirmationCode: zod.string().min(1, { message: 'Confirmation code is required' }),
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
confirmPassword: zod.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { confirmationCode: '', password: '', confirmPassword: '' } satisfies Values;
|
||||
|
||||
export interface UpdatePasswordFormProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function UpdatePasswordForm({ email }: UpdatePasswordFormProps): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
await confirmResetPassword({
|
||||
username: email,
|
||||
newPassword: values.password,
|
||||
confirmationCode: values.confirmationCode,
|
||||
});
|
||||
toast.success('Password updated');
|
||||
router.push(paths.auth.cognito.signIn);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[router, email, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Update password</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography>
|
||||
If an account exists with email{' '}
|
||||
<Typography component="span" variant="subtitle1">
|
||||
"{email}"
|
||||
</Typography>
|
||||
, you will receive a recovery email.
|
||||
</Typography>
|
||||
<div>
|
||||
<Link component={RouterLink} href={paths.auth.cognito.resetPassword} variant="subtitle2">
|
||||
Use another email
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmationCode"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmationCode)}>
|
||||
<InputLabel>Confirmation Code</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.confirmationCode ? <FormHelperText>{errors.confirmationCode.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>New password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmPassword)}>
|
||||
<InputLabel>Confirm password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.confirmPassword ? <FormHelperText>{errors.confirmPassword.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Update password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<ResetPasswordButton email={email}>Resend</ResetPasswordButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({ email: zod.string().min(1, { message: 'Email is required' }).email() });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '' } satisfies Values;
|
||||
|
||||
export function ResetPasswordForm(): React.JSX.Element {
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await authClient.resetPassword(values);
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
|
||||
// Redirect to confirm password reset
|
||||
},
|
||||
[setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Reset password</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Send recovery link
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
214
002_source/cms/src/components/auth/custom/sign-in-form.tsx
Normal file
214
002_source/cms/src/components/auth/custom/sign-in-form.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Eye as EyeIcon } from '@phosphor-icons/react/dist/ssr/Eye';
|
||||
import { EyeSlash as EyeSlashIcon } from '@phosphor-icons/react/dist/ssr/EyeSlash';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { useUser } from '@/hooks/use-user';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'discord';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [
|
||||
{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' },
|
||||
{ id: 'discord', name: 'Discord', logo: '/assets/logo-discord.svg' },
|
||||
] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(1, { message: 'Password is required' }),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '', password: '' } satisfies Values;
|
||||
|
||||
export function SignInForm(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const { checkSession } = useUser();
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState<boolean>();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await authClient.signInWithOAuth({ provider: providerId });
|
||||
|
||||
if (error) {
|
||||
setIsPending(false);
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
|
||||
// Redirect to OAuth provider
|
||||
}, []);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await authClient.signInWithPassword(values);
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the auth state
|
||||
await checkSession?.();
|
||||
|
||||
// UserProvider, for this case, will not refresh the router
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
router.refresh();
|
||||
},
|
||||
[checkSession, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign in</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Don't have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.custom.signUp} variant="subtitle2">
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<Stack spacing={2}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput
|
||||
{...field}
|
||||
endAdornment={
|
||||
showPassword ? (
|
||||
<EyeIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EyeSlashIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<div>
|
||||
<Link component={RouterLink} href={paths.auth.custom.resetPassword} variant="subtitle2">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Alert color="warning">
|
||||
Use{' '}
|
||||
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit">
|
||||
sofia@devias.io
|
||||
</Typography>{' '}
|
||||
with password{' '}
|
||||
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit">
|
||||
Secret1
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
215
002_source/cms/src/components/auth/custom/sign-up-form.tsx
Normal file
215
002_source/cms/src/components/auth/custom/sign-up-form.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { authClient } from '@/lib/auth/custom/client';
|
||||
import { useUser } from '@/hooks/use-user';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'discord';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [
|
||||
{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' },
|
||||
{ id: 'discord', name: 'Discord', logo: '/assets/logo-discord.svg' },
|
||||
] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
firstName: zod.string().min(1, { message: 'First name is required' }),
|
||||
lastName: zod.string().min(1, { message: 'Last name is required' }),
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
terms: zod.boolean().refine((value) => value, 'You must accept the terms and conditions'),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
|
||||
|
||||
export function SignUpForm(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const { checkSession } = useUser();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await authClient.signInWithOAuth({ provider: providerId });
|
||||
|
||||
if (error) {
|
||||
setIsPending(false);
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
|
||||
// Redirect to OAuth provider
|
||||
}, []);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await authClient.signUp(values);
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the auth state
|
||||
await checkSession?.();
|
||||
|
||||
// UserProvider, for this case, will not refresh the router
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
router.refresh();
|
||||
},
|
||||
[checkSession, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign up</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Already have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.custom.signIn} variant="subtitle2">
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.firstName)}>
|
||||
<InputLabel>First name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.lastName)}>
|
||||
<InputLabel>Last name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="terms"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...field} />}
|
||||
label={
|
||||
<React.Fragment>
|
||||
I have read the <Link>terms and conditions</Link>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Create account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
<Alert color="warning">Created users are not persisted</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { sendPasswordResetEmail } from 'firebase/auth';
|
||||
import type { Auth } from 'firebase/auth';
|
||||
|
||||
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
|
||||
|
||||
export interface ResetPasswordButtonProps {
|
||||
children: React.ReactNode;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export function ResetPasswordButton({ children, email }: ResetPasswordButtonProps): React.JSX.Element {
|
||||
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
const [submitError, setSubmitError] = React.useState<string>();
|
||||
|
||||
const handle = React.useCallback(async (): Promise<void> => {
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(firebaseAuth, email);
|
||||
} catch (err) {
|
||||
setSubmitError((err as { message: string }).message);
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [firebaseAuth, email]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
{submitError ? <Alert color="error">{submitError}</Alert> : null}
|
||||
<Button disabled={!email || isPending} onClick={handle} variant="contained">
|
||||
{children}
|
||||
</Button>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="body2">
|
||||
Wait a few minutes then try again
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { sendPasswordResetEmail } from 'firebase/auth';
|
||||
import type { Auth } from 'firebase/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod.object({ email: zod.string().min(1, { message: 'Email is required' }).email() });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '' } satisfies Values;
|
||||
|
||||
export function ResetPasswordForm(): React.JSX.Element {
|
||||
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
// If you receive a link that opens a page with the error "The selected page mode is invalid"
|
||||
// you might have this issue https://github.com/firebase/firebase-js-sdk/issues/7981
|
||||
// In our case, we had to wait some time and then it started to work as expected.
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(firebaseAuth, values.email);
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.firebase.recoveryLinkSent}?${searchParams.toString()}`);
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[firebaseAuth, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Reset password</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Send recovery link
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
203
002_source/cms/src/components/auth/firebase/sign-in-form.tsx
Normal file
203
002_source/cms/src/components/auth/firebase/sign-in-form.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Eye as EyeIcon } from '@phosphor-icons/react/dist/ssr/Eye';
|
||||
import { EyeSlash as EyeSlashIcon } from '@phosphor-icons/react/dist/ssr/EyeSlash';
|
||||
import { GoogleAuthProvider, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
|
||||
import type { Auth } from 'firebase/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'github';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' }] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(1, { message: 'Password is required' }),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '', password: '' } satisfies Values;
|
||||
|
||||
export function SignInForm(): React.JSX.Element {
|
||||
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState<boolean>();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(
|
||||
async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
let provider: GoogleAuthProvider;
|
||||
|
||||
switch (providerId) {
|
||||
case 'google':
|
||||
provider = new GoogleAuthProvider();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${providerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await signInWithPopup(firebaseAuth, provider);
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
} catch (err) {
|
||||
setIsPending(false);
|
||||
toast.error((err as { message: string }).message);
|
||||
}
|
||||
},
|
||||
[firebaseAuth]
|
||||
);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
await signInWithEmailAndPassword(firebaseAuth, values.email, values.password);
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[firebaseAuth, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign in</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Don't have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.firebase.signUp} variant="subtitle2">
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<Stack spacing={2}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput
|
||||
{...field}
|
||||
endAdornment={
|
||||
showPassword ? (
|
||||
<EyeIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EyeSlashIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<div>
|
||||
<Link component={RouterLink} href={paths.auth.firebase.resetPassword} variant="subtitle2">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
213
002_source/cms/src/components/auth/firebase/sign-up-form.tsx
Normal file
213
002_source/cms/src/components/auth/firebase/sign-up-form.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { createUserWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
|
||||
import type { Auth } from 'firebase/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'github';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' }] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
firstName: zod.string().min(1, { message: 'First name is required' }),
|
||||
lastName: zod.string().min(1, { message: 'Last name is required' }),
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
terms: zod.boolean().refine((value) => value, 'You must accept the terms and conditions'),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
|
||||
|
||||
export function SignUpForm(): React.JSX.Element {
|
||||
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(
|
||||
async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
let provider: GoogleAuthProvider;
|
||||
|
||||
switch (providerId) {
|
||||
case 'google':
|
||||
provider = new GoogleAuthProvider();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${providerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await signInWithPopup(firebaseAuth, provider);
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
} catch (err) {
|
||||
setIsPending(false);
|
||||
toast.error((err as { message: string }).message);
|
||||
}
|
||||
},
|
||||
[firebaseAuth]
|
||||
);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
await createUserWithEmailAndPassword(firebaseAuth, values.email, values.password);
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
} catch (err) {
|
||||
setError('root', { type: 'server', message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
}
|
||||
},
|
||||
[firebaseAuth, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign up</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Already have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.firebase.signIn} variant="subtitle2">
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.firstName)}>
|
||||
<InputLabel>First name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.lastName)}>
|
||||
<InputLabel>Last name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="terms"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...field} />}
|
||||
label={
|
||||
<React.Fragment>
|
||||
I have read the <Link>terms and conditions</Link>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Create account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { confirmPasswordReset } from 'firebase/auth';
|
||||
import type { Auth } from 'firebase/auth';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
const schema = zod
|
||||
.object({
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
confirmPassword: zod.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { password: '', confirmPassword: '' } satisfies Values;
|
||||
|
||||
export interface UpdatePasswordFormProps {
|
||||
oobCode: string;
|
||||
}
|
||||
|
||||
export function UpdatePasswordForm({ oobCode }: UpdatePasswordFormProps): React.JSX.Element {
|
||||
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
await confirmPasswordReset(firebaseAuth, oobCode, values.password);
|
||||
toast.success('Password updated');
|
||||
router.push(paths.auth.firebase.signIn);
|
||||
} catch (err) {
|
||||
setError('root', { message: (err as { message: string }).message });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(paths.dashboard.overview);
|
||||
},
|
||||
[firebaseAuth, oobCode, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Update password</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmPassword)}>
|
||||
<InputLabel>Confirm password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.confirmPassword ? <FormHelperText>{errors.confirmPassword.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Update password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
55
002_source/cms/src/components/auth/guest-guard.tsx
Normal file
55
002_source/cms/src/components/auth/guest-guard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Alert from '@mui/material/Alert';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
import { useUser } from '@/hooks/use-user';
|
||||
|
||||
export interface GuestGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function GuestGuard({ children }: GuestGuardProps): React.JSX.Element | null {
|
||||
const router = useRouter();
|
||||
const { user, error, isLoading } = useUser();
|
||||
const [isChecking, setIsChecking] = React.useState<boolean>(true);
|
||||
|
||||
const checkPermissions = async (): Promise<void> => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
logger.debug('[GuestGuard]: User is logged in, redirecting to dashboard');
|
||||
router.replace(paths.dashboard.overview);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
checkPermissions().catch(() => {
|
||||
// noop
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
|
||||
}, [user, error, isLoading]);
|
||||
|
||||
if (isChecking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert color="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
123
002_source/cms/src/components/auth/split-layout.tsx
Normal file
123
002_source/cms/src/components/auth/split-layout.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export interface SplitLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SplitLayout({ children }: SplitLayoutProps): React.JSX.Element {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 800px' }, minHeight: '100vh' }}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
display: { xs: 'none', lg: 'flex' },
|
||||
flexDirection: 'column',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4} sx={{ maxWidth: '700px' }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4">Welcome to Devias Kit PRO</Typography>
|
||||
<Typography color="text.secondary">
|
||||
A professional template that comes with ready-to-use MUI components developed with one common goal in
|
||||
mind, help you build faster & beautiful applications.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={3}
|
||||
sx={{ alignItems: 'center', color: 'var(--mui-palette-neutral-500)', flexWrap: 'wrap' }}
|
||||
>
|
||||
<svg fill="none" height="36" viewBox="0 0 133 36" width="133" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M77.4069 9.92864L85.0581 7.0798L77.4069 4.14957V0.161194L90.5116 5.45189V8.70771L77.4069 13.9984V9.92864Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5.04645 35.1612C2.27901 35.1612 -6.10352e-05 33.7775 -6.10352e-05 30.6844V30.5216C-6.10352e-05 26.7775 3.25575 25.4751 7.24413 25.4751H9.11622V24.7426C9.11622 23.1961 8.46506 22.3007 6.83715 22.3007C5.37203 22.3007 4.63947 23.1147 4.55808 24.2542H0.488311C0.813893 20.8356 3.49994 19.2077 7.08134 19.2077C10.7441 19.2077 13.4302 20.7542 13.4302 24.5798V34.8356H9.27901V33.0449C8.46506 34.1844 7.08134 35.1612 5.04645 35.1612ZM9.11622 29.7891V28.324H7.40692C5.29064 28.324 4.2325 28.8937 4.2325 30.2775V30.4403C4.2325 31.4984 4.88366 32.231 6.34878 32.231C7.81389 32.1496 9.11622 31.3356 9.11622 29.7891ZM23.1162 35.1612C18.8837 35.1612 15.7906 32.5565 15.7906 27.3472V27.103C15.7906 21.8937 19.0465 19.1263 23.1162 19.1263C26.6162 19.1263 29.4651 20.917 29.7906 24.9054H25.7209C25.4767 23.4403 24.6627 22.4635 23.1976 22.4635C21.4069 22.4635 20.1046 23.9286 20.1046 26.9403V27.4286C20.1046 30.5216 21.2441 31.9054 23.1976 31.9054C24.6627 31.9054 25.7209 30.8472 25.9651 29.1379H29.872C29.6279 32.7193 27.2674 35.1612 23.1162 35.1612ZM39.0697 35.1612C34.8372 35.1612 31.7441 32.5565 31.7441 27.3472V27.103C31.7441 21.8937 34.9999 19.1263 39.0697 19.1263C42.5697 19.1263 45.4186 20.917 45.7441 24.9054H41.6744C41.4302 23.4403 40.6162 22.4635 39.1511 22.4635C37.3604 22.4635 36.0581 23.9286 36.0581 26.9403V27.4286C36.0581 30.5216 37.1976 31.9054 39.1511 31.9054C40.6162 31.9054 41.6744 30.8472 41.9186 29.1379H45.8255C45.5813 32.7193 43.2209 35.1612 39.0697 35.1612ZM55.1046 35.1612C50.7093 35.1612 47.6976 32.5565 47.6976 27.4286V27.103C47.6976 21.9751 50.872 19.1263 55.0232 19.1263C58.8488 19.1263 62.0232 21.2426 62.0232 26.3705V28.2426H52.0116C52.1744 31.01 53.3953 32.0682 55.186 32.0682C56.8139 32.0682 57.7093 31.1728 58.0348 30.1147H62.0232C61.5348 32.9635 59.093 35.1612 55.1046 35.1612ZM52.093 25.3937H57.7907C57.7093 23.1147 56.6511 22.1379 54.9418 22.1379C53.6395 22.2193 52.4186 22.9519 52.093 25.3937ZM64.6279 19.5333H68.9418V21.8123C69.6744 20.3472 71.2209 19.2077 73.5814 19.2077C76.3488 19.2077 78.2209 20.917 78.2209 24.5798V34.8356H73.9069V25.2309C73.9069 23.4403 73.1744 22.6263 71.6279 22.6263C70.1627 22.6263 68.9418 23.5216 68.9418 25.4751V34.8356H64.6279V19.5333ZM86.1162 14.8937V19.5333H89.0465V22.7077H86.1162V29.9519C86.1162 31.0914 86.6046 31.6612 87.6627 31.6612C88.3139 31.6612 88.7209 31.5798 89.1279 31.417V34.7542C88.6395 34.917 87.7441 35.0798 86.686 35.0798C83.3488 35.0798 81.8023 33.5333 81.8023 30.4403V22.7077H80.0116V19.5333H81.8023V16.6844L86.1162 14.8937ZM105.163 34.8356H100.93V32.5565C100.198 34.0216 98.7325 35.1612 96.4535 35.1612C93.686 35.1612 91.6511 33.4519 91.6511 29.8705V19.5333H95.9651V29.3007C95.9651 31.0914 96.6976 31.9054 98.1628 31.9054C99.6279 31.9054 100.849 30.9286 100.849 29.0565V19.5333H105.163V34.8356ZM108.337 19.5333H112.651V22.3821C113.546 20.3472 115.012 19.3705 117.291 19.3705V23.603C114.36 23.603 112.651 24.4984 112.651 27.0216V34.917H108.337V19.5333ZM126 35.1612C121.605 35.1612 118.593 32.5565 118.593 27.4286V27.103C118.593 21.9751 121.767 19.1263 125.919 19.1263C129.744 19.1263 132.919 21.2426 132.919 26.3705V28.2426H122.988C123.151 31.01 124.372 32.0682 126.163 32.0682C127.791 32.0682 128.686 31.1728 129.012 30.1147H133C132.349 32.9635 129.988 35.1612 126 35.1612ZM122.907 25.3937H128.686C128.605 23.1147 127.546 22.1379 125.837 22.1379C124.535 22.2193 123.314 22.9519 122.907 25.3937Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<svg fill="none" height="33" viewBox="0 0 78 33" width="78" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M72.216 23.0743C72.009 23.0743 71.8665 22.9299 71.8665 22.7221V12.4056H68.386C68.179 12.4056 68.0365 12.2618 68.0365 12.0539V10.6308C68.0365 10.4224 68.179 10.2783 68.386 10.2783H77.651C77.8575 10.2783 78 10.4226 78 10.6308V12.0538C78 12.2616 77.8575 12.4056 77.651 12.4056H74.1705V22.722C74.1705 22.9299 74.0275 23.0743 73.821 23.0743H72.216ZM45.5534 17.9236L43.7262 12.6458L41.8827 17.9236H45.5534ZM49.6058 22.6573C49.6856 22.8659 49.5583 23.0743 49.3358 23.0743H47.6834C47.4448 23.0743 47.3016 22.9625 47.2219 22.7376L46.2847 20.0195H41.1523L40.2134 22.7376C40.1346 22.9627 39.991 23.0743 39.7531 23.0743H38.1963C37.9894 23.0743 37.8462 22.8657 37.9256 22.6573L42.2322 10.5988C42.3119 10.3742 42.4548 10.2788 42.6927 10.2788H44.8222C45.0608 10.2788 45.2198 10.3742 45.2991 10.5988L49.6058 22.6573ZM61.95 21.3465C62.9665 21.3465 63.65 20.8515 64.2065 20.0033L61.6325 17.22C60.6465 17.7803 60.011 18.3393 60.011 19.4592C60.011 20.5631 60.9005 21.3465 61.95 21.3465ZM62.665 11.9736C61.8385 11.9736 61.362 12.502 61.362 13.2056C61.362 13.7493 61.6475 14.2292 62.2995 14.9332C63.4275 14.2769 63.9045 13.8773 63.9045 13.1737C63.9045 12.5174 63.4915 11.9736 62.665 11.9736ZM69.7055 22.6263C69.9115 22.8504 69.785 23.0743 69.53 23.0743H67.5115C67.2415 23.0743 67.0985 23.0099 66.924 22.8017L65.716 21.4588C64.9055 22.5465 63.7765 23.362 61.902 23.362C59.582 23.362 57.7535 21.9545 57.7535 19.5399C57.7535 17.6839 58.7395 16.692 60.2335 15.8605C59.502 15.0129 59.169 14.1171 59.169 13.3338C59.169 11.3499 60.5515 9.9906 62.6325 9.9906C64.7625 9.9906 66.0655 11.2547 66.0655 13.1256C66.0655 14.7251 64.9215 15.62 63.7135 16.2923L65.4935 18.2283L66.4945 16.4681C66.6215 16.2605 66.7645 16.1803 67.0185 16.1803H68.5595C68.8145 16.1803 68.9575 16.3567 68.799 16.6284L67.019 19.6989L69.7055 22.6263ZM53.846 23.0743C54.0525 23.0743 54.1965 22.9299 54.1965 22.7221V12.4056H57.676C57.8825 12.4056 58.0255 12.2618 58.0255 12.0539V10.6308C58.0255 10.4224 57.8825 10.2783 57.676 10.2783H48.4111C48.2042 10.2783 48.0615 10.4226 48.0615 10.6308V12.0538C48.0615 12.2616 48.2043 12.4056 48.4111 12.4056H51.8905V22.722C51.8905 22.9299 52.0345 23.0743 52.2405 23.0743H53.846Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.81529 29.2973C9.51034 31.3978 12.8931 32.6598 16.5632 32.6598C20.5794 32.6598 24.2409 31.1585 27.0353 28.694C27.0692 28.6638 27.0525 28.644 27.0192 28.6638C25.7652 29.507 22.1913 31.3477 16.5633 31.3477C11.6724 31.3477 8.58149 30.2489 6.83599 29.268C6.80259 29.2513 6.79024 29.2765 6.81529 29.2973ZM17.6422 30.115C21.5541 30.115 25.8528 29.0414 28.4237 26.916C29.1273 26.3369 29.7974 25.5662 30.3976 24.5304C30.7432 23.9345 31.081 23.2264 31.3562 22.5304C31.3685 22.4964 31.3477 22.48 31.3224 22.5181C28.9316 26.0606 22.0082 28.2702 14.8596 28.2702C9.80669 28.2702 4.36979 26.6433 2.24122 23.5368C2.22027 23.508 2.19931 23.5204 2.21207 23.5533C4.19499 27.7969 10.2107 30.115 17.6422 30.115ZM13.3681 23.0752C5.23224 23.0752 1.39597 19.26 0.700069 16.6562C0.691489 16.6186 0.666824 16.6269 0.666824 16.6608C0.666824 17.5373 0.753954 18.6684 0.903864 19.4192C0.975379 19.7846 1.27079 20.3582 1.7039 20.8154C3.67398 22.883 8.58569 25.7802 17.092 25.7802C28.6813 25.7802 31.3313 21.8932 31.8724 20.6149C32.2593 19.7006 32.4598 18.0486 32.4598 16.6608C32.4598 16.325 32.4514 16.0568 32.4388 15.7934C32.4388 15.7506 32.4142 15.7472 32.4057 15.7888C31.8266 18.9166 21.9248 23.0752 13.3681 23.0752ZM2.19931 9.79709C1.73313 10.7287 1.21636 12.3002 1.06274 13.1136C0.995404 13.4621 1.02409 13.6295 1.14547 13.8896C2.12062 15.9728 7.05309 19.3058 18.5586 19.3058C25.5778 19.3058 31.0306 17.5696 31.914 14.401C32.0766 13.8178 32.0853 13.202 31.8764 12.3722C31.6429 11.4449 31.2056 10.3636 30.8356 9.60429C30.8233 9.57954 30.8017 9.58319 30.8061 9.61244C30.9436 13.7682 19.4334 16.4466 13.626 16.4466C7.33544 16.4466 2.08737 13.923 2.08737 10.7366C2.08737 10.4304 2.1503 10.1242 2.22885 9.8055C2.23674 9.7764 2.21199 9.77159 2.19931 9.79709ZM27.061 4.69469C27.1278 4.80009 27.1612 4.91257 27.1612 5.06398C27.1612 6.84164 21.7577 9.98634 13.1561 9.98634C6.83599 9.98634 5.65274 7.62564 5.65274 6.12434C5.65274 5.58771 5.85714 5.03861 6.30734 4.48093C6.33194 4.44777 6.31104 4.43501 6.28269 4.45967C5.46159 5.16027 4.70719 5.94859 4.04539 6.79959C3.72918 7.20204 3.53289 7.55854 3.53289 7.77214C3.53289 10.8833 11.2809 13.1391 18.5254 13.1391C26.2446 13.1391 29.6897 10.6018 29.6897 8.37209C29.6897 7.57524 29.3817 7.11004 28.5936 6.20814C28.082 5.62142 27.598 5.14369 27.0858 4.67343C27.061 4.65289 27.0438 4.66962 27.061 4.69469ZM24.6946 2.91743C22.3124 1.47966 19.5458 0.661682 16.5633 0.661682C13.56 0.661682 10.7102 1.50823 8.31934 2.98407C7.60214 3.42849 7.19854 3.78462 7.19854 4.24242C7.19854 5.59191 10.3308 7.04294 15.888 7.04294C21.3874 7.04294 25.653 5.45358 25.653 3.92373C25.653 3.55855 25.336 3.30307 24.6946 2.91743Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<svg fill="none" height="29" viewBox="0 0 50 29" width="50" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M39.9999 0V21.8032H34.8354V1.08992L39.9999 0ZM25.2944 23.8694C26.7203 23.8694 27.8771 25.0181 27.8771 26.4351C27.8763 27.8513 26.7203 29 25.2944 29C23.8686 29 22.7118 27.8513 22.7118 26.4351C22.7118 25.0181 23.8678 23.8694 25.2944 23.8694ZM25.2944 6.1915C29.692 6.1915 33.2638 9.73353 33.2638 14.1085C33.2638 18.4827 29.692 22.0247 25.2944 22.0247C20.8897 22.0247 17.3243 18.4835 17.3243 14.1077C17.3243 9.73353 20.8969 6.1915 25.2944 6.1915ZM25.2944 16.6734C25.6325 16.6748 25.9675 16.6095 26.2803 16.4813C26.5931 16.3531 26.8776 16.1645 27.1174 15.9263C27.3573 15.6881 27.5478 15.4049 27.6782 15.093C27.8085 14.7811 27.8761 14.4466 27.8771 14.1085C27.8762 13.7704 27.8087 13.4357 27.6784 13.1237C27.5481 12.8117 27.3576 12.5285 27.1177 12.2902C26.8778 12.0519 26.5933 11.8632 26.2805 11.735C25.9676 11.6067 25.6326 11.5414 25.2944 11.5428C24.9564 11.5414 24.6214 11.6067 24.3086 11.7349C23.9958 11.8631 23.7113 12.0517 23.4715 12.2899C23.2316 12.5281 23.041 12.8113 22.9107 13.1232C22.7804 13.4351 22.7128 13.7696 22.7118 14.1077C22.7127 14.4458 22.7802 14.7804 22.9105 15.0925C23.0408 15.4045 23.2313 15.6877 23.4712 15.926C23.711 16.1643 23.9955 16.353 24.3084 16.4812C24.6213 16.6095 24.9563 16.6748 25.2944 16.6734ZM9.04639 16.6734C9.47271 16.674 9.8819 16.5057 10.1843 16.2052C10.4868 15.9047 10.6578 15.4966 10.6599 15.0703C10.6578 14.6439 10.4867 14.2357 10.1841 13.9352C9.88145 13.6347 9.47204 13.4664 9.04558 13.4673H5.17247V16.6734H9.04639ZM5.17167 5.12978V8.33589H8.04186C8.46818 8.33653 8.87738 8.16818 9.17981 7.86771C9.48224 7.56724 9.65326 7.15915 9.65539 6.73283C9.65326 6.30652 9.48224 5.89843 9.17981 5.59796C8.87738 5.29749 8.46818 5.12913 8.04186 5.12978H5.17167ZM13.8024 10.2749C15.051 11.5001 15.8243 13.1958 15.8179 15.0703C15.8179 18.7896 12.7834 21.8032 9.03914 21.8032H0V0H8.03461C11.7788 0 14.8134 3.01439 14.8134 6.73364C14.8134 8.03058 14.4476 9.24858 13.8024 10.2749ZM49.7559 11.6145H47.1814V15.6399C47.1814 16.8587 47.5761 17.7561 48.6096 17.7561C49.2766 17.7561 49.764 17.6062 49.764 17.6062V21.3827C49.764 21.3827 48.695 22.0247 47.2466 22.0247H47.1822C47.1178 22.0247 47.0598 22.0166 46.9953 22.0166H46.9446C46.9164 22.0166 46.8801 22.0102 46.8519 22.0102C43.968 21.8604 42.0097 20.0575 42.0097 16.9296V3.65561L47.1741 2.56489V6.48472H49.7568V11.6153L49.7559 11.6145Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg fill="none" height="19" viewBox="0 0 119 19" width="119" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M64.965 8.55517C64.965 8.55517 63.8656 7.85669 63.1858 7.58642C63.1858 7.58642 61.0815 6.58434 60.664 6.10757C60.664 6.10757 59.8439 5.31343 60.3241 4.39105C60.3241 4.39105 60.5251 3.78749 61.3918 3.78749C61.3918 3.78749 62.5201 3.85125 62.5201 4.80406V6.14814H66.5637L66.5567 4.16861C66.5567 4.16861 66.8676 0.926878 61.7938 0.784137C61.7938 0.784137 57.8031 0.514593 56.6585 2.72309C56.6585 2.72309 56.2093 3.2158 56.2093 4.93159V6.17133C56.2093 6.17133 56.1635 7.60164 56.8743 8.52329C56.8743 8.52329 57.2763 9.12758 58.2664 9.79419C58.2664 9.79419 60.2769 10.9064 61.4835 11.5433C61.4835 11.5433 62.7013 12.2577 62.5413 13.3946C62.5413 13.3946 62.4418 14.5619 61.2656 14.5141C61.2656 14.5141 60.1901 14.4648 60.1901 13.32V11.9766H55.8814V13.9279C55.8814 13.9279 55.7608 17.6594 61.2973 17.6594C61.2973 17.6594 66.5877 17.7696 66.8345 13.8301V12.2411C66.8338 12.2411 67.0214 9.8268 64.965 8.55517ZM44.2633 1.24859L42.9093 9.96664H42.5927L41.2994 1.32684H34.545L34.2065 17.1145H38.2057L38.2536 5.21924H38.5681L40.688 17.1124H44.8952L46.9889 5.22359H47.2674L47.3641 17.1145H51.3844L50.9514 1.24859H44.2633ZM20.4573 1.31453L17.7966 17.0964H22.1018L23.678 4.85986H24.032L25.6074 17.0964H29.9119L27.2519 1.31453H20.4573ZM110.794 8.3378V10.6659H111.899V13.1649C111.899 14.3865 110.882 14.409 110.882 14.409C109.647 14.409 109.693 13.2482 109.693 13.2482V4.60408C109.693 3.71358 110.79 3.66576 110.79 3.66576C111.842 3.66576 111.849 4.72798 111.849 4.72798V6.04235H115.903C116.035 3.47664 115.537 2.85569 115.537 2.85569C114.532 0.53561 110.728 0.66241 110.728 0.66241C104.883 0.66241 105.531 5.27069 105.531 5.27069V13.6286C105.657 17.9507 111.417 17.5116 111.514 17.508C114.076 17.2167 114.85 16.3798 114.85 16.3798C115.562 15.8364 115.745 15.0836 115.745 15.0836C115.949 14.6452 116 13.2482 116 13.2482V8.3378H110.794ZM97.3582 10.9064H97.1819L93.0685 1.25293H88.1469V17.116H92.1524L91.9127 7.46397H92.0918L96.3533 17.116H101.125V1.25148H97.069L97.3582 10.9064ZM78.3694 13.1635C78.3694 13.1635 78.4272 14.4655 77.2524 14.4655C77.2524 14.4655 76.0168 14.5336 76.0168 13.1961L76.0042 1.26235H71.6108V13.1011C71.6108 13.1011 71.1602 17.5819 77.3144 17.5819C77.3144 17.5819 82.6626 17.6471 82.6626 13.3069V1.26308H78.3694V13.1635ZM12.0817 8.55517C12.0817 8.55517 10.9837 7.85669 10.3032 7.58714C10.3032 7.58714 8.20032 6.58579 7.78285 6.10829C7.78285 6.10829 6.9627 5.31271 7.44365 4.3925C7.44365 4.3925 7.64392 3.78893 8.5099 3.78893C8.5099 3.78893 9.63892 3.85197 9.63892 4.80478V6.14887H13.6839L13.6762 4.16861C13.6762 4.16861 13.985 0.926878 8.91327 0.784861C8.91327 0.784861 8.53106 0.759499 7.97043 0.788482C7.97043 0.788482 4.89789 0.958033 3.7992 2.68976C3.79285 2.7028 3.78368 2.71223 3.77734 2.72382C3.77734 2.72382 3.32883 3.2158 3.32883 4.93231V6.17205C3.32883 6.17205 3.28229 7.60236 3.99383 8.52402C3.99383 8.52402 4.39579 9.12831 5.38589 9.79491C5.38589 9.79491 7.39569 10.9071 8.60228 11.544C8.60228 11.544 9.82156 12.257 9.66008 13.3953C9.66008 13.3953 9.56064 14.5626 8.38508 14.5148C8.38508 14.5148 7.30966 14.4655 7.30966 13.3207V11.9766H3.00021V13.9293C3.00021 13.9293 2.87962 17.6609 8.41611 17.6609C8.41611 17.6609 13.7051 17.771 13.954 13.8315V12.244C13.954 12.2425 14.1388 9.8268 12.0817 8.55517Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg fill="none" height="36" viewBox="0 0 59 36" width="59" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16.7684 12.8869C16.7684 13.6049 16.8461 14.1872 16.9819 14.6142C17.1372 15.0411 17.3313 15.5069 17.603 16.0115C17.7 16.1668 17.7388 16.3221 17.7388 16.4579C17.7388 16.652 17.6224 16.8461 17.3701 17.0401L16.1474 17.8553C15.9727 17.9717 15.798 18.0299 15.6428 18.0299C15.4487 18.0299 15.2546 17.9329 15.0605 17.7582C14.7888 17.4671 14.5559 17.1566 14.3619 16.8461C14.1678 16.5161 13.9737 16.1474 13.7602 15.701C12.2464 17.4865 10.3444 18.3793 8.05429 18.3793C6.42402 18.3793 5.12369 17.9135 4.17271 16.9819C3.22172 16.0503 2.73652 14.8082 2.73652 13.2556C2.73652 11.6059 3.31876 10.2668 4.50264 9.25758C5.68652 8.24837 7.25856 7.74376 9.25758 7.74376C9.91744 7.74376 10.5967 7.80199 11.3148 7.89903C12.0329 7.99607 12.7704 8.15133 13.5467 8.326V6.90922C13.5467 5.43422 13.2362 4.40561 12.6345 3.80396C12.0135 3.20232 10.9655 2.9112 9.47106 2.9112C8.79179 2.9112 8.0931 2.98883 7.37501 3.1635C6.65692 3.33817 5.95823 3.55166 5.27896 3.82337C4.96843 3.95922 4.73554 4.03686 4.59968 4.07567C4.46383 4.11449 4.36679 4.1339 4.28916 4.1339C4.01744 4.1339 3.88159 3.93982 3.88159 3.53225V2.58126C3.88159 2.27074 3.9204 2.03784 4.01744 1.90199C4.11448 1.76613 4.28915 1.63028 4.56087 1.49442C5.24014 1.14508 6.05527 0.853961 7.00626 0.621067C7.95725 0.368764 8.96646 0.252317 10.0339 0.252317C12.3434 0.252317 14.0319 0.77633 15.1188 1.82436C16.1862 2.87238 16.7296 4.46383 16.7296 6.5987V12.8869H16.7684ZM8.88883 15.8369C9.52929 15.8369 10.1892 15.7204 10.8878 15.4875C11.5865 15.2546 12.2076 14.8276 12.7316 14.2454C13.0421 13.8767 13.275 13.4691 13.3915 13.0033C13.5079 12.5375 13.5855 11.9747 13.5855 11.3148V10.4997C13.0227 10.3638 12.4211 10.2474 11.8 10.1698C11.179 10.0921 10.5773 10.0533 9.97567 10.0533C8.67534 10.0533 7.72435 10.3056 7.08389 10.8296C6.44343 11.3536 6.13291 12.0911 6.13291 13.0615C6.13291 13.9737 6.3658 14.653 6.851 15.1188C7.31679 15.604 7.99606 15.8369 8.88883 15.8369ZM24.4734 17.9329C24.124 17.9329 23.8911 17.8747 23.7359 17.7388C23.5806 17.6224 23.4447 17.3507 23.3283 16.9819L18.7674 1.97962C18.651 1.59146 18.5928 1.33916 18.5928 1.2033C18.5928 0.892777 18.748 0.718106 19.0586 0.718106H20.9605C21.3293 0.718106 21.5816 0.77633 21.7174 0.912185C21.8727 1.02863 21.9892 1.30034 22.1056 1.66909L25.3661 14.5171L28.3938 1.66909C28.4908 1.28094 28.6072 1.02863 28.7625 0.912185C28.9178 0.795738 29.1895 0.718106 29.5388 0.718106H31.0915C31.4602 0.718106 31.7125 0.77633 31.8678 0.912185C32.023 1.02863 32.1589 1.30034 32.2365 1.66909L35.303 14.6724L38.6605 1.66909C38.777 1.28094 38.9128 1.02863 39.0487 0.912185C39.204 0.795738 39.4563 0.718106 39.8056 0.718106H41.6105C41.9211 0.718106 42.0957 0.873369 42.0957 1.2033C42.0957 1.30034 42.0763 1.39738 42.0569 1.51383C42.0375 1.63028 41.9987 1.78554 41.9211 1.99903L37.2438 17.0013C37.1273 17.3895 36.9915 17.6418 36.8362 17.7582C36.6809 17.8747 36.4286 17.9523 36.0987 17.9523H34.4296C34.0609 17.9523 33.8086 17.8941 33.6533 17.7582C33.498 17.6224 33.3622 17.3701 33.2845 16.9819L30.2763 4.46383L27.2875 16.9625C27.1905 17.3507 27.074 17.603 26.9188 17.7388C26.7635 17.8747 26.4918 17.9329 26.1424 17.9329H24.4734ZM49.4125 18.4569C48.4033 18.4569 47.3941 18.3405 46.4237 18.1076C45.4533 17.8747 44.6964 17.6224 44.1918 17.3313C43.8813 17.1566 43.6678 16.9625 43.5901 16.7878C43.5125 16.6132 43.4737 16.4191 43.4737 16.2444V15.2546C43.4737 14.8471 43.629 14.653 43.9201 14.653C44.0365 14.653 44.153 14.6724 44.2694 14.7112C44.3859 14.75 44.5605 14.8276 44.7546 14.9053C45.4145 15.1964 46.1326 15.4293 46.8895 15.5846C47.6658 15.7398 48.4227 15.8174 49.199 15.8174C50.4217 15.8174 51.3727 15.604 52.0326 15.177C52.6924 14.75 53.0418 14.129 53.0418 13.3332C53.0418 12.7898 52.8671 12.3434 52.5178 11.9747C52.1684 11.6059 51.5086 11.276 50.5576 10.9655L47.7434 10.0921C46.3267 9.64574 45.2786 8.98587 44.6382 8.11251C43.9977 7.25857 43.6678 6.30758 43.6678 5.29837C43.6678 4.48324 43.8424 3.76515 44.1918 3.14409C44.5411 2.52304 45.0069 1.97962 45.5891 1.55265C46.1714 1.10626 46.8313 0.77633 47.6076 0.543435C48.3839 0.31054 49.199 0.213501 50.053 0.213501C50.4799 0.213501 50.9263 0.232909 51.3533 0.291133C51.7997 0.349356 52.2072 0.426988 52.6148 0.504619C53.003 0.601659 53.3717 0.698698 53.7211 0.815146C54.0704 0.931593 54.3421 1.04804 54.5362 1.16449C54.8079 1.31975 55.002 1.47501 55.1184 1.64969C55.2349 1.80495 55.2931 2.01844 55.2931 2.29015V3.20232C55.2931 3.60988 55.1378 3.82337 54.8467 3.82337C54.6915 3.82337 54.4391 3.74574 54.1092 3.59047C53.003 3.08587 51.7609 2.83357 50.3829 2.83357C49.2767 2.83357 48.4033 3.00824 47.8017 3.37699C47.2 3.74574 46.8895 4.30857 46.8895 5.10429C46.8895 5.64771 47.0836 6.1135 47.4717 6.48225C47.8599 6.851 48.578 7.21975 49.6066 7.54969L52.3625 8.42304C53.7599 8.86942 54.7691 9.49047 55.3707 10.2862C55.9724 11.0819 56.2635 11.9941 56.2635 13.0033C56.2635 13.8378 56.0888 14.5948 55.7589 15.2546C55.4095 15.9145 54.9438 16.4967 54.3421 16.9625C53.7405 17.4477 53.0224 17.7971 52.1878 18.0494C51.3145 18.3211 50.4023 18.4569 49.4125 18.4569Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M53.0806 27.8892C46.6954 32.6053 37.4184 35.1089 29.4418 35.1089C18.2628 35.1089 8.19014 30.975 0.582247 24.1046C-0.0193978 23.5612 0.524023 22.8237 1.24212 23.2507C9.47106 28.025 19.6214 30.9168 30.1211 30.9168C37.2049 30.9168 44.9875 29.4418 52.149 26.4142C53.2165 25.929 54.1286 27.1128 53.0806 27.8892Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M55.7395 24.8615C54.9244 23.8135 50.3441 24.3569 48.2674 24.6092C47.6464 24.6869 47.5494 24.1434 48.1122 23.7359C51.7609 21.174 57.7579 21.9115 58.4566 22.7655C59.1553 23.6388 58.2625 29.6359 54.8467 32.5082C54.3227 32.9546 53.8181 32.7217 54.051 32.1395C54.8273 30.2181 56.5546 25.8901 55.7395 24.8615Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg fill="none" height="18" viewBox="0 0 91 18" width="91" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9.31948 0.161179C14.2843 0.641702 19.7297 3.12384 22.8531 6.72777C26.8569 11.2922 25.496 15.8571 19.8098 16.8977C14.1241 18.0189 6.27616 15.1364 2.27237 10.5714C-0.77095 7.04755 -0.690299 3.52428 2.11219 1.60275L17.8877 13.2944L9.31948 0.161179Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M87.4761 16.8977L85.4745 8.88956C85.2331 7.92908 85.0735 6.96803 84.9139 6.08707H84.8332C84.6736 7.04812 84.593 7.92908 84.3527 8.96965L82.35 16.8977H78.9068L83.4718 0.24127H86.1947L91 16.8977H87.4761ZM34.9445 0.24127L36.9467 8.24887C37.2671 9.20991 37.3472 10.1715 37.5073 11.0514H37.5874C37.7476 10.0909 37.8277 9.12926 38.0679 8.16878L40.0701 0.24127H43.5133L38.9489 16.8977H36.2259L31.4213 0.24127H34.9445ZM45.9949 0.24127H49.2785V16.8977H45.9949V0.24127ZM67.8576 0.24127L69.5383 10.0914L70.8998 0.24127H75.304L76.9858 16.8977H73.6227L72.8219 4.88577L72.2612 8.96965L70.8998 16.8977H68.3375L66.7358 8.88956L66.1752 5.20612V4.88577H66.0951L65.6146 16.8977H62.0907L63.5329 0.24127H67.8576ZM57.4468 0.24127C56.8862 0.641706 56.4863 1.36193 56.166 2.08271C55.285 4.08489 56.2455 5.68607 57.0464 7.12764C57.127 7.28782 57.2877 7.52808 57.3667 7.76834C59.2087 10.8111 59.8494 13.053 58.5686 16.2559L58.2482 16.8972H54.2439C54.7244 16.4962 55.2044 15.9367 55.4446 15.2954C56.4062 13.1331 55.285 11.1314 54.2439 9.1287C53.0431 6.80673 51.7612 4.40468 53.1226 1.1211C53.2033 0.800751 53.5237 0.240143 53.5237 0.240143H57.4468V0.24127Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box sx={{ boxShadow: 'var(--mui-shadows-8)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 auto',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: '420px', width: '100%' }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
25
002_source/cms/src/components/auth/strategy-guard.tsx
Normal file
25
002_source/cms/src/components/auth/strategy-guard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
import { config } from '@/config';
|
||||
import type { AuthStrategy } from '@/lib/auth/strategy';
|
||||
|
||||
interface StrategyGuardProps {
|
||||
children: React.ReactNode;
|
||||
expected: keyof typeof AuthStrategy;
|
||||
}
|
||||
|
||||
export function StrategyGuard({ children, expected }: StrategyGuardProps): React.JSX.Element {
|
||||
if (config.auth.strategy !== expected) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert color="error">
|
||||
To render this page, you need to configure the auth strategy to "{expected}"
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
export interface ResetPasswordButtonProps {
|
||||
children: React.ReactNode;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export function ResetPasswordButton({ children, email }: ResetPasswordButtonProps): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState<SupabaseClient>(createSupabaseClient());
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
const [submitError, setSubmitError] = React.useState<string>();
|
||||
|
||||
const handle = React.useCallback(async (): Promise<void> => {
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setSubmitError(undefined);
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.pkce, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.auth.supabase.updatePassword);
|
||||
|
||||
const { error } = await supabaseClient.auth.resetPasswordForEmail(email, { redirectTo: redirectToUrl.href });
|
||||
|
||||
if (error) {
|
||||
setSubmitError(error.message);
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
toast.success('Recovery link sent');
|
||||
}, [supabaseClient, email]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
{submitError ? <Alert color="error">{submitError}</Alert> : null}
|
||||
<Button disabled={!email || isPending} onClick={handle} variant="contained">
|
||||
{children}
|
||||
</Button>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="body2">
|
||||
Wait a few minutes then try again
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
|
||||
const schema = zod.object({ email: zod.string().min(1, { message: 'Email is required' }).email() });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '' } satisfies Values;
|
||||
|
||||
export function ResetPasswordForm(): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState(createSupabaseClient());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.pkce, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.auth.supabase.updatePassword);
|
||||
|
||||
const { error } = await supabaseClient.auth.resetPasswordForEmail(values.email, {
|
||||
redirectTo: redirectToUrl.href,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error.message });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.supabase.recoveryLinkSent}?${searchParams.toString()}`);
|
||||
},
|
||||
[supabaseClient, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Send recovery link
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
216
002_source/cms/src/components/auth/supabase/sign-in-form.tsx
Normal file
216
002_source/cms/src/components/auth/supabase/sign-in-form.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Eye as EyeIcon } from '@phosphor-icons/react/dist/ssr/Eye';
|
||||
import { EyeSlash as EyeSlashIcon } from '@phosphor-icons/react/dist/ssr/EyeSlash';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'discord';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [
|
||||
{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' },
|
||||
{ id: 'discord', name: 'Discord', logo: '/assets/logo-discord.svg' },
|
||||
] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(1, { message: 'Password is required' }),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { email: '', password: '' } satisfies Values;
|
||||
|
||||
export function SignInForm(): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState<SupabaseClient>(createSupabaseClient());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState<boolean>();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(
|
||||
async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.pkce, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.dashboard.overview);
|
||||
|
||||
const { data, error } = await supabaseClient.auth.signInWithOAuth({
|
||||
provider: providerId,
|
||||
options: { redirectTo: redirectToUrl.href },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setIsPending(false);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = data.url;
|
||||
},
|
||||
[supabaseClient]
|
||||
);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await supabaseClient.auth.signInWithPassword({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('Email not confirmed')) {
|
||||
// You should resend the verification email.
|
||||
// For the sake of simplicity, we will just redirect to the confirmation page.
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.supabase.signUpConfirm}?${searchParams.toString()}`);
|
||||
} else {
|
||||
setError('root', { type: 'server', message: error.message });
|
||||
setIsPending(false);
|
||||
}
|
||||
} else {
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
}
|
||||
},
|
||||
[supabaseClient, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign in</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Don't have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.supabase.signUp} variant="subtitle2">
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<Stack spacing={2}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput
|
||||
{...field}
|
||||
endAdornment={
|
||||
showPassword ? (
|
||||
<EyeIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EyeSlashIcon
|
||||
cursor="pointer"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
onClick={(): void => {
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<div>
|
||||
<Link component={RouterLink} href={paths.auth.supabase.resetPassword} variant="subtitle2">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
240
002_source/cms/src/components/auth/supabase/sign-up-form.tsx
Normal file
240
002_source/cms/src/components/auth/supabase/sign-up-form.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
interface OAuthProvider {
|
||||
id: 'google' | 'discord';
|
||||
name: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
const oAuthProviders = [
|
||||
{ id: 'google', name: 'Google', logo: '/assets/logo-google.svg' },
|
||||
{ id: 'discord', name: 'Discord', logo: '/assets/logo-discord.svg' },
|
||||
] satisfies OAuthProvider[];
|
||||
|
||||
const schema = zod.object({
|
||||
firstName: zod.string().min(1, { message: 'First name is required' }),
|
||||
lastName: zod.string().min(1, { message: 'Last name is required' }),
|
||||
email: zod.string().min(1, { message: 'Email is required' }).email(),
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
terms: zod.boolean().refine((value) => value, 'You must accept the terms and conditions'),
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
|
||||
|
||||
export function SignUpForm(): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState(createSupabaseClient());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onAuth = React.useCallback(
|
||||
async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.pkce, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.dashboard.overview);
|
||||
|
||||
const { data, error } = await supabaseClient.auth.signInWithOAuth({
|
||||
provider: providerId,
|
||||
options: { redirectTo: redirectToUrl.href },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setIsPending(false);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = data.url;
|
||||
},
|
||||
[supabaseClient]
|
||||
);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
// It is really important that you read the official notes
|
||||
// under "If signUp() is called for an existing confirmed user"
|
||||
// https://supabase.com/docs/reference/javascript/auth-signup
|
||||
// If a user already exists with this email, they will not
|
||||
// receive a confirmation email.
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.pkce, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.dashboard.overview);
|
||||
|
||||
const { data, error } = await supabaseClient.auth.signUp({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
options: { emailRedirectTo: redirectToUrl.href },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error.message });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
// UserProvider will handle Router refresh
|
||||
// After refresh, GuestGuard will handle the redirect
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
const searchParams = new URLSearchParams({ email: values.email });
|
||||
router.push(`${paths.auth.supabase.signUpConfirm}?${searchParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
},
|
||||
[supabaseClient, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h5">Sign up</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Already have an account?{' '}
|
||||
<Link component={RouterLink} href={paths.auth.supabase.signIn} variant="subtitle2">
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
{oAuthProviders.map(
|
||||
(provider): React.JSX.Element => (
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={isPending}
|
||||
endIcon={<Box alt="" component="img" height={24} src={provider.logo} width={24} />}
|
||||
key={provider.id}
|
||||
onClick={(): void => {
|
||||
onAuth(provider.id).catch(() => {
|
||||
// noop
|
||||
});
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
<Divider>or</Divider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.firstName)}>
|
||||
<InputLabel>First name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.lastName)}>
|
||||
<InputLabel>Last name</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.email)}>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput {...field} type="email" />
|
||||
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="terms"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<FormControlLabel
|
||||
control={<Checkbox {...field} />}
|
||||
label={
|
||||
<React.Fragment>
|
||||
I have read the <Link>terms and conditions</Link>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Create account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
export interface SignUpResendButtonProps {
|
||||
children: React.ReactNode;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function SignUpResendButton({ children, email }: SignUpResendButtonProps): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState(createSupabaseClient());
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
const [submitError, setSubmitError] = React.useState<string>();
|
||||
|
||||
const handleAction = React.useCallback(async (): Promise<void> => {
|
||||
setIsPending(true);
|
||||
setSubmitError(undefined);
|
||||
|
||||
const redirectToUrl = new URL(paths.auth.supabase.callback.implicit, window.location.origin);
|
||||
redirectToUrl.searchParams.set('next', paths.dashboard.overview);
|
||||
|
||||
const { error } = await supabaseClient.auth.resend({
|
||||
email,
|
||||
type: 'signup',
|
||||
options: { emailRedirectTo: redirectToUrl.href },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setSubmitError(error.message);
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(false);
|
||||
toast.success('Verification email sent');
|
||||
}, [supabaseClient, email]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Button disabled={isPending} onClick={handleAction} variant="contained">
|
||||
{children}
|
||||
</Button>
|
||||
{submitError ? <Alert color="error">{submitError}</Alert> : null}
|
||||
<Typography sx={{ textAlign: 'center' }} variant="body2">
|
||||
Wait a few minutes then try again
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
|
||||
import { DynamicLogo } from '@/components/core/logo';
|
||||
|
||||
const schema = zod
|
||||
.object({
|
||||
password: zod.string().min(6, { message: 'Password should be at least 6 characters' }),
|
||||
confirmPassword: zod.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
const defaultValues = { password: '', confirmPassword: '' } satisfies Values;
|
||||
|
||||
export function UpdatePasswordForm(): React.JSX.Element {
|
||||
const [supabaseClient] = React.useState(createSupabaseClient());
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
setIsPending(true);
|
||||
|
||||
const { error } = await supabaseClient.auth.updateUser({ password: values.password });
|
||||
|
||||
if (error) {
|
||||
setError('root', { type: 'server', message: error.message });
|
||||
setIsPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(paths.dashboard.overview);
|
||||
},
|
||||
[supabaseClient, router, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<div>
|
||||
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</div>
|
||||
<Typography variant="h5">Update password</Typography>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing={2}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.password)}>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.confirmPassword)}>
|
||||
<InputLabel>Confirm password</InputLabel>
|
||||
<OutlinedInput {...field} type="password" />
|
||||
{errors.confirmPassword ? <FormHelperText>{errors.confirmPassword.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||
<Button disabled={isPending} type="submit" variant="contained">
|
||||
Update password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
44
002_source/cms/src/components/core/analytics.tsx
Normal file
44
002_source/cms/src/components/core/analytics.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { GTMProvider, useGTMDispatch } from '@elgorditosalsero/react-gtm-hook';
|
||||
|
||||
import { config } from '@/config';
|
||||
|
||||
export interface PageViewTrackerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function PageViewTracker({ children }: PageViewTrackerProps): React.JSX.Element {
|
||||
const dispatch = useGTMDispatch();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch({ event: 'page_view', page: pathname });
|
||||
}, [dispatch, pathname, searchParams]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
|
||||
export interface AnalyticsProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* This loads GTM and tracks the page views.
|
||||
*
|
||||
* If GTM ID is not configured, this will no track any event.
|
||||
*/
|
||||
export function Analytics({ children }: AnalyticsProps): React.JSX.Element {
|
||||
if (!config.gtm?.id) {
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GTMProvider state={{ id: config.gtm.id }}>
|
||||
<PageViewTracker>{children}</PageViewTracker>
|
||||
</GTMProvider>
|
||||
);
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export function BreadcrumbsSeparator(): React.JSX.Element {
|
||||
return <Box sx={{ bgcolor: 'var(--mui-palette-neutral-500)', borderRadius: '50%', height: '4px', width: '4px' }} />;
|
||||
}
|
110
002_source/cms/src/components/core/code-highlighter.tsx
Normal file
110
002_source/cms/src/components/core/code-highlighter.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
|
||||
const codeStyle: Record<string, React.CSSProperties> = {
|
||||
'code[class*="language-"]': {
|
||||
color: 'var(--mui-palette-neutral-50)',
|
||||
background: 'none',
|
||||
textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
||||
fontFamily: "'Roboto Mono', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace",
|
||||
fontSize: 'var(--fontSize-sm)',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal',
|
||||
wordWrap: 'normal',
|
||||
lineHeight: 1.5,
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none',
|
||||
MozHyphens: 'none',
|
||||
msHyphens: 'none',
|
||||
hyphens: 'none',
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
color: 'var(--mui-palette-neutral-50)',
|
||||
background: 'var(--mui-palette-neutral-800)',
|
||||
textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
||||
fontFamily: "'Roboto Mono', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace",
|
||||
fontSize: 'var(--fontSize-sm)',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal',
|
||||
wordWrap: 'normal',
|
||||
lineHeight: 1.5,
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none',
|
||||
MozHyphens: 'none',
|
||||
msHyphens: 'none',
|
||||
hyphens: 'none',
|
||||
padding: '1em',
|
||||
margin: '.5em 0',
|
||||
overflow: 'auto',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
':not(pre) > code[class*="language-"]': {
|
||||
background: 'var(--mui-palette-neutral-800)',
|
||||
padding: '.1em',
|
||||
borderRadius: '.3em',
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
comment: { color: '#6272a4' },
|
||||
prolog: { color: '#6272a4' },
|
||||
doctype: { color: '#6272a4' },
|
||||
cdata: { color: '#6272a4' },
|
||||
punctuation: { color: '#f8f8f2' },
|
||||
'.namespace': { opacity: '.7' },
|
||||
property: { color: '#ff79c6' },
|
||||
tag: { color: '#ff79c6' },
|
||||
constant: { color: '#ff79c6' },
|
||||
symbol: { color: '#ff79c6' },
|
||||
deleted: { color: '#ff79c6' },
|
||||
boolean: { color: '#bd93f9' },
|
||||
number: { color: '#bd93f9' },
|
||||
selector: { color: '#50fa7b' },
|
||||
'attr-name': { color: '#50fa7b' },
|
||||
string: { color: '#50fa7b' },
|
||||
char: { color: '#50fa7b' },
|
||||
builtin: { color: '#50fa7b' },
|
||||
inserted: { color: '#50fa7b' },
|
||||
operator: { color: '#f8f8f2' },
|
||||
entity: { color: '#f8f8f2', cursor: 'help' },
|
||||
url: { color: '#f8f8f2' },
|
||||
'.language-css .token.string': { color: '#f8f8f2' },
|
||||
'.style .token.string': { color: '#f8f8f2' },
|
||||
variable: { color: '#f8f8f2' },
|
||||
atrule: { color: '#f1fa8c' },
|
||||
'attr-value': { color: '#f1fa8c' },
|
||||
function: { color: '#f1fa8c' },
|
||||
'class-name': { color: '#f1fa8c' },
|
||||
keyword: { color: '#8be9fd' },
|
||||
regex: { color: '#ffb86c' },
|
||||
important: { color: '#ffb86c', fontWeight: 'bold' },
|
||||
bold: { fontWeight: 'bold' },
|
||||
italic: { fontStyle: 'italic' },
|
||||
};
|
||||
|
||||
export interface CodeHighlighterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function CodeHighlighter({ children, className, inline, ...props }: CodeHighlighterProps): React.JSX.Element {
|
||||
const language = (className ?? '').split('language-')[1];
|
||||
const canHighlight = !inline && Boolean(language);
|
||||
|
||||
return canHighlight ? (
|
||||
<SyntaxHighlighter PreTag="div" language={language} style={codeStyle} {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
147
002_source/cms/src/components/core/data-table.tsx
Normal file
147
002_source/cms/src/components/core/data-table.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Table from '@mui/material/Table';
|
||||
import type { TableProps } from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
export interface ColumnDef<TRowModel> {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
field?: keyof TRowModel;
|
||||
formatter?: (row: TRowModel, index: number) => React.ReactNode;
|
||||
hideName?: boolean;
|
||||
name: string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
type RowId = number | string;
|
||||
|
||||
export interface DataTableProps<TRowModel> extends Omit<TableProps, 'onClick'> {
|
||||
columns: ColumnDef<TRowModel>[];
|
||||
hideHead?: boolean;
|
||||
hover?: boolean;
|
||||
onClick?: (event: React.MouseEvent, row: TRowModel) => void;
|
||||
onDeselectAll?: (event: React.ChangeEvent) => void;
|
||||
onDeselectOne?: (event: React.ChangeEvent, row: TRowModel) => void;
|
||||
onSelectAll?: (event: React.ChangeEvent) => void;
|
||||
onSelectOne?: (event: React.ChangeEvent, row: TRowModel) => void;
|
||||
rows: TRowModel[];
|
||||
selectable?: boolean;
|
||||
selected?: Set<RowId>;
|
||||
uniqueRowId?: (row: TRowModel) => RowId;
|
||||
}
|
||||
|
||||
export function DataTable<TRowModel extends object & { id?: RowId | null }>({
|
||||
columns,
|
||||
hideHead,
|
||||
hover,
|
||||
onClick,
|
||||
onDeselectAll,
|
||||
onDeselectOne,
|
||||
onSelectOne,
|
||||
onSelectAll,
|
||||
rows,
|
||||
selectable,
|
||||
selected,
|
||||
uniqueRowId,
|
||||
...props
|
||||
}: DataTableProps<TRowModel>): React.JSX.Element {
|
||||
const selectedSome = (selected?.size ?? 0) > 0 && (selected?.size ?? 0) < rows.length;
|
||||
const selectedAll = rows.length > 0 && selected?.size === rows.length;
|
||||
|
||||
return (
|
||||
<Table {...props}>
|
||||
<TableHead sx={{ ...(hideHead && { visibility: 'collapse', '--TableCell-borderWidth': 0 }) }}>
|
||||
<TableRow>
|
||||
{selectable ? (
|
||||
<TableCell padding="checkbox" sx={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<Checkbox
|
||||
checked={selectedAll}
|
||||
indeterminate={selectedSome}
|
||||
onChange={(event: React.ChangeEvent) => {
|
||||
if (selectedAll) {
|
||||
onDeselectAll?.(event);
|
||||
} else {
|
||||
onSelectAll?.(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{columns.map(
|
||||
(column): React.JSX.Element => (
|
||||
<TableCell
|
||||
key={column.name}
|
||||
sx={{
|
||||
width: column.width,
|
||||
minWidth: column.width,
|
||||
maxWidth: column.width,
|
||||
...(column.align && { textAlign: column.align }),
|
||||
}}
|
||||
>
|
||||
{column.hideName ? null : column.name}
|
||||
</TableCell>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, index): React.JSX.Element => {
|
||||
const rowId = row.id ? row.id : uniqueRowId?.(row);
|
||||
const rowSelected = rowId ? selected?.has(rowId) : false;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
hover={hover}
|
||||
key={rowId ?? index}
|
||||
selected={rowSelected}
|
||||
{...(onClick && {
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
onClick(event, row);
|
||||
},
|
||||
})}
|
||||
sx={{ ...(onClick && { cursor: 'pointer' }) }}
|
||||
>
|
||||
{selectable ? (
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={rowId ? rowSelected : false}
|
||||
onChange={(event: React.ChangeEvent) => {
|
||||
if (rowSelected) {
|
||||
onDeselectOne?.(event, row);
|
||||
} else {
|
||||
onSelectOne?.(event, row);
|
||||
}
|
||||
}}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{columns.map(
|
||||
(column): React.JSX.Element => (
|
||||
<TableCell key={column.name} sx={{ ...(column.align && { textAlign: column.align }) }}>
|
||||
{
|
||||
(column.formatter
|
||||
? column.formatter(row, index)
|
||||
: column.field
|
||||
? row[column.field]
|
||||
: null) as React.ReactNode
|
||||
}
|
||||
</TableCell>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function noop(..._: unknown[]): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
export interface ContextValue {
|
||||
anchorEl: HTMLElement | null;
|
||||
onPopoverMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onPopoverMouseLeave: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onPopoverEscapePressed: () => void;
|
||||
onTriggerMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onTriggerMouseLeave: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onTriggerKeyUp: (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export const DropdownContext = React.createContext<ContextValue>({
|
||||
anchorEl: null,
|
||||
onPopoverMouseEnter: noop,
|
||||
onPopoverMouseLeave: noop,
|
||||
onPopoverEscapePressed: noop,
|
||||
onTriggerMouseEnter: noop,
|
||||
onTriggerMouseLeave: noop,
|
||||
onTriggerKeyUp: noop,
|
||||
open: false,
|
||||
});
|
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import type { PaperProps } from '@mui/material/Paper';
|
||||
import type { PopoverOrigin } from '@mui/material/Popover';
|
||||
import Popover from '@mui/material/Popover';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownPopoverProps {
|
||||
anchorOrigin?: PopoverOrigin;
|
||||
children?: React.ReactNode;
|
||||
disableScrollLock?: boolean;
|
||||
PaperProps?: PaperProps;
|
||||
transformOrigin?: PopoverOrigin;
|
||||
}
|
||||
|
||||
export function DropdownPopover({ children, PaperProps, ...props }: DropdownPopoverProps): React.JSX.Element {
|
||||
const { anchorEl, onPopoverMouseEnter, onPopoverMouseLeave, onPopoverEscapePressed, open } =
|
||||
React.useContext(DropdownContext);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={(_, reason) => {
|
||||
if (reason === 'escapeKeyDown') {
|
||||
onPopoverEscapePressed?.();
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
slotProps={{
|
||||
paper: {
|
||||
...PaperProps,
|
||||
onMouseEnter: onPopoverMouseEnter,
|
||||
onMouseLeave: onPopoverMouseLeave,
|
||||
sx: { ...PaperProps?.sx, pointerEvents: 'auto' },
|
||||
},
|
||||
}}
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownButtonProps {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function DropdownTrigger({ children }: DropdownButtonProps): React.JSX.Element {
|
||||
const { onTriggerMouseEnter, onTriggerMouseLeave, onTriggerKeyUp } = React.useContext(DropdownContext);
|
||||
|
||||
return React.cloneElement(children, {
|
||||
onKeyUp: (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
(children.props as { onKeyUp?: (event: React.KeyboardEvent<HTMLElement>) => void }).onKeyUp?.(event);
|
||||
onTriggerKeyUp(event);
|
||||
},
|
||||
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
|
||||
(children.props as { onMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void }).onMouseEnter?.(event);
|
||||
onTriggerMouseEnter(event);
|
||||
},
|
||||
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
|
||||
(children.props as { onMouseLeave?: (event: React.MouseEvent<HTMLElement>) => void }).onMouseLeave?.(event);
|
||||
onTriggerMouseLeave(event);
|
||||
},
|
||||
});
|
||||
}
|
69
002_source/cms/src/components/core/dropdown/dropdown.tsx
Normal file
69
002_source/cms/src/components/core/dropdown/dropdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode[];
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function Dropdown({ children, delay = 50 }: DropdownProps): React.JSX.Element {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||
const cleanupRef = React.useRef<number>();
|
||||
|
||||
const handleTriggerMouseEnter = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
clearTimeout(cleanupRef.current);
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleTriggerMouseLeave = React.useCallback(
|
||||
(_: React.MouseEvent<HTMLElement>) => {
|
||||
cleanupRef.current = setTimeout(() => {
|
||||
setAnchorEl(null);
|
||||
}, delay) as unknown as number;
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
|
||||
const handleTriggerKeyUp = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
setAnchorEl(event.currentTarget as unknown as HTMLElement);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePopoverMouseEnter = React.useCallback((_: React.MouseEvent<HTMLElement>) => {
|
||||
clearTimeout(cleanupRef.current);
|
||||
}, []);
|
||||
|
||||
const handlePopoverMouseLeave = React.useCallback(
|
||||
(_: React.MouseEvent<HTMLElement>) => {
|
||||
cleanupRef.current = setTimeout(() => {
|
||||
setAnchorEl(null);
|
||||
}, delay) as unknown as number;
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
|
||||
const handlePopoverEscapePressed = React.useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider
|
||||
value={{
|
||||
anchorEl,
|
||||
onPopoverMouseEnter: handlePopoverMouseEnter,
|
||||
onPopoverMouseLeave: handlePopoverMouseLeave,
|
||||
onPopoverEscapePressed: handlePopoverEscapePressed,
|
||||
onTriggerMouseEnter: handleTriggerMouseEnter,
|
||||
onTriggerMouseLeave: handleTriggerMouseLeave,
|
||||
onTriggerKeyUp: handleTriggerKeyUp,
|
||||
open,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
73
002_source/cms/src/components/core/file-dropzone.tsx
Normal file
73
002_source/cms/src/components/core/file-dropzone.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { CloudArrowUp as CloudArrowUpIcon } from '@phosphor-icons/react/dist/ssr/CloudArrowUp';
|
||||
import type { DropzoneOptions, FileWithPath } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
export type File = FileWithPath;
|
||||
|
||||
export interface FileDropzoneProps extends DropzoneOptions {
|
||||
caption?: string;
|
||||
files?: File[];
|
||||
onRemove?: (file: File) => void;
|
||||
onRemoveAll?: () => void;
|
||||
onUpload?: () => void;
|
||||
}
|
||||
|
||||
export function FileDropzone({ caption, ...props }: FileDropzoneProps): React.JSX.Element {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone(props);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
outline: 'none',
|
||||
p: 6,
|
||||
...(isDragActive && { bgcolor: 'var(--mui-palette-action-selected)', opacity: 0.5 }),
|
||||
'&:hover': { ...(!isDragActive && { bgcolor: 'var(--mui-palette-action-hover)' }) },
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
'--Avatar-size': '64px',
|
||||
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
|
||||
bgcolor: 'var(--mui-palette-background-paper)',
|
||||
boxShadow: 'var(--mui-shadows-8)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6">
|
||||
<Typography component="span" sx={{ textDecoration: 'underline' }} variant="inherit">
|
||||
Click to upload
|
||||
</Typography>{' '}
|
||||
or drag and drop
|
||||
</Typography>
|
||||
{caption ? (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{caption}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
40
002_source/cms/src/components/core/file-icon.tsx
Normal file
40
002_source/cms/src/components/core/file-icon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
jpeg: '/assets/icon-jpg.svg',
|
||||
jpg: '/assets/icon-jpg.svg',
|
||||
mp4: '/assets/icon-mp4.svg',
|
||||
pdf: '/assets/icon-pdf.svg',
|
||||
png: '/assets/icon-png.svg',
|
||||
svg: '/assets/icon-svg.svg',
|
||||
};
|
||||
|
||||
export interface FileIconProps {
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ extension }: FileIconProps): React.JSX.Element {
|
||||
let icon: string;
|
||||
|
||||
if (!extension) {
|
||||
icon = '/assets/icon-other.svg';
|
||||
} else {
|
||||
icon = icons[extension] ?? '/assets/icon-other.svg';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
flex: '0 0 auto',
|
||||
justifyContent: 'center',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
<Box alt="File" component="img" src={icon} sx={{ height: '100%', width: 'auto' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
146
002_source/cms/src/components/core/filter-button.tsx
Normal file
146
002_source/cms/src/components/core/filter-button.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MinusCircle as MinusCircleIcon } from '@phosphor-icons/react/dist/ssr/MinusCircle';
|
||||
import { PlusCircle as PlusCircleIcon } from '@phosphor-icons/react/dist/ssr/PlusCircle';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
function noop(..._: unknown[]): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
export interface FilterContextValue<T = unknown> {
|
||||
anchorEl: HTMLElement | null;
|
||||
onApply: (value: unknown) => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
export const FilterContext = React.createContext<FilterContextValue>({
|
||||
anchorEl: null,
|
||||
onApply: noop,
|
||||
onClose: noop,
|
||||
open: false,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
export function useFilterContext(): FilterContextValue {
|
||||
const context = React.useContext(FilterContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFilterContext must be used within a FilterProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export interface FilterButtonProps {
|
||||
displayValue?: string;
|
||||
label: string;
|
||||
onFilterApply?: (value: unknown) => void;
|
||||
onFilterDelete?: () => void;
|
||||
popover: React.ReactNode;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
// Currently, the `value` prop can be string | number | boolean | undefined
|
||||
export function FilterButton({
|
||||
displayValue,
|
||||
label,
|
||||
onFilterApply,
|
||||
onFilterDelete,
|
||||
popover,
|
||||
value,
|
||||
}: FilterButtonProps): React.JSX.Element {
|
||||
const { anchorRef, handleOpen, handleClose, open } = usePopover<HTMLButtonElement>();
|
||||
|
||||
const handleApply = React.useCallback(
|
||||
(newValue: unknown) => {
|
||||
handleClose();
|
||||
onFilterApply?.(newValue);
|
||||
},
|
||||
[handleClose, onFilterApply]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{ anchorEl: anchorRef.current, onApply: handleApply, onClose: handleClose, open, value }}
|
||||
>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={handleOpen}
|
||||
ref={anchorRef}
|
||||
startIcon={
|
||||
value ? (
|
||||
<Box
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onFilterDelete?.();
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onFilterDelete?.();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
sx={{ display: 'flex' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Box>
|
||||
) : (
|
||||
<PlusCircleIcon />
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
<span>
|
||||
{label}
|
||||
{displayValue ? (
|
||||
<React.Fragment>
|
||||
:{' '}
|
||||
<Box component="span" sx={{ color: 'var(--mui-palette-primary-main)' }}>
|
||||
{displayValue}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
{popover}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterPopoverProps {
|
||||
anchorEl: HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function FilterPopover({ children, title, onClose, anchorEl, open }: FilterPopoverProps): React.JSX.Element {
|
||||
return (
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={onClose}
|
||||
open={Boolean(anchorEl && open)}
|
||||
sx={{ '& .MuiPopover-paper': { mt: '4px', width: '280px' } }}
|
||||
>
|
||||
<Stack spacing={2} sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</Popover>
|
||||
);
|
||||
}
|
25
002_source/cms/src/components/core/i18n-provider.tsx
Normal file
25
002_source/cms/src/components/core/i18n-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import '@/lib/i18n';
|
||||
|
||||
export interface I18nProviderProps {
|
||||
children: React.ReactNode;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children, language = 'en' }: I18nProviderProps): React.JSX.Element {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
i18n.changeLanguage(language).catch(() => {
|
||||
logger.error(`Failed to change language to ${language}`);
|
||||
});
|
||||
}, [i18n, language]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
13
002_source/cms/src/components/core/localization-provider.tsx
Normal file
13
002_source/cms/src/components/core/localization-provider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
|
||||
export interface LocalizationProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LocalizationProvider({ children }: LocalizationProviderProps): React.JSX.Element {
|
||||
return <Provider dateAdapter={AdapterDayjs}>{children}</Provider>;
|
||||
}
|
56
002_source/cms/src/components/core/logo.tsx
Normal file
56
002_source/cms/src/components/core/logo.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
const HEIGHT = 60;
|
||||
const WIDTH = 60;
|
||||
|
||||
type Color = 'dark' | 'light';
|
||||
|
||||
export interface LogoProps {
|
||||
color?: Color;
|
||||
emblem?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function Logo({ color = 'dark', emblem, height = HEIGHT, width = WIDTH }: LogoProps): React.JSX.Element {
|
||||
let url: string;
|
||||
|
||||
if (emblem) {
|
||||
url = color === 'light' ? '/assets/logo-emblem.svg' : '/assets/logo-emblem--dark.svg';
|
||||
} else {
|
||||
url = color === 'light' ? '/assets/logo.svg' : '/assets/logo--dark.svg';
|
||||
}
|
||||
|
||||
return <Box alt="logo" component="img" height={height} src={url} width={width} />;
|
||||
}
|
||||
|
||||
export interface DynamicLogoProps {
|
||||
colorDark?: Color;
|
||||
colorLight?: Color;
|
||||
emblem?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function DynamicLogo({
|
||||
colorDark = 'light',
|
||||
colorLight = 'dark',
|
||||
height = HEIGHT,
|
||||
width = WIDTH,
|
||||
...props
|
||||
}: DynamicLogoProps): React.JSX.Element {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const color = colorScheme === 'dark' ? colorDark : colorLight;
|
||||
|
||||
return (
|
||||
<NoSsr fallback={<Box sx={{ height: `${height}px`, width: `${width}px` }} />}>
|
||||
<Logo color={color} height={height} width={width} {...props} />
|
||||
</NoSsr>
|
||||
);
|
||||
}
|
75
002_source/cms/src/components/core/multi-select.tsx
Normal file
75
002_source/cms/src/components/core/multi-select.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
// `T` should be `string`, `number` or `boolean`
|
||||
export interface MultiSelectProps<T = string> {
|
||||
label: string;
|
||||
onChange?: (value: T[]) => void;
|
||||
options: readonly { label: string; value: T }[];
|
||||
value: T[];
|
||||
}
|
||||
|
||||
export function MultiSelect<T = string>({
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value = [],
|
||||
}: MultiSelectProps<T>): React.JSX.Element {
|
||||
const popover = usePopover<HTMLButtonElement>();
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(v: T, checked: boolean) => {
|
||||
let updateValue = [...value] as T[];
|
||||
|
||||
if (checked) {
|
||||
updateValue.push(v);
|
||||
} else {
|
||||
updateValue = updateValue.filter((item) => item !== v);
|
||||
}
|
||||
|
||||
onChange?.(updateValue);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
color="secondary"
|
||||
endIcon={<CaretDownIcon />}
|
||||
onClick={popover.handleOpen}
|
||||
ref={popover.anchorRef}
|
||||
sx={{ '& .MuiButton-endIcon svg': { fontSize: 'var(--icon-fontSize-sm)' } }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={popover.anchorRef.current}
|
||||
onClose={popover.handleClose}
|
||||
open={popover.open}
|
||||
slotProps={{ paper: { sx: { width: '250px' } } }}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = value.includes(option.value);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
handleValueChange(option.value, !selected);
|
||||
}}
|
||||
selected={selected}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
31
002_source/cms/src/components/core/no-ssr.tsx
Normal file
31
002_source/cms/src/components/core/no-ssr.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
|
||||
|
||||
export interface NoSsrProps {
|
||||
children?: React.ReactNode;
|
||||
defer?: boolean;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
// https://github.com/mui/material-ui/blob/master/packages/mui-base/src/NoSsr/NoSsr.tsx
|
||||
// without prop-types
|
||||
export function NoSsr(props: NoSsrProps): React.JSX.Element {
|
||||
const { children, defer = false, fallback = null } = props;
|
||||
const [mountedState, setMountedState] = React.useState(false);
|
||||
|
||||
useEnhancedEffect((): void => {
|
||||
if (!defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
if (defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
return <React.Fragment>{mountedState ? children : fallback}</React.Fragment>;
|
||||
}
|
12
002_source/cms/src/components/core/option.tsx
Normal file
12
002_source/cms/src/components/core/option.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
||||
export interface OptionProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export function Option({ children, ...props }: OptionProps): React.JSX.Element {
|
||||
return <MenuItem {...props}>{children}</MenuItem>;
|
||||
}
|
9
002_source/cms/src/components/core/pdf-viewer.tsx
Normal file
9
002_source/cms/src/components/core/pdf-viewer.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type ReactPDF from '@react-pdf/renderer';
|
||||
|
||||
export const PDFViewer = dynamic<ReactPDF.PDFViewerProps>(
|
||||
() => import('@react-pdf/renderer').then((module) => module.PDFViewer),
|
||||
{ ssr: false }
|
||||
);
|
37
002_source/cms/src/components/core/presence.tsx
Normal file
37
002_source/cms/src/components/core/presence.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
type Size = 'small' | 'medium' | 'large';
|
||||
|
||||
type Status = 'online' | 'offline' | 'away' | 'busy';
|
||||
|
||||
const sizes = { small: 8, medium: 16, large: 24 };
|
||||
|
||||
export interface PresenceProps {
|
||||
size?: Size;
|
||||
status?: Status;
|
||||
}
|
||||
|
||||
export function Presence({ size = 'medium', status = 'offline' }: PresenceProps): React.JSX.Element {
|
||||
const colors = {
|
||||
offline: 'var(--mui-palette-neutral-100)',
|
||||
away: 'var(--mui-palette-warning-main)',
|
||||
busy: 'var(--mui-palette-error-main)',
|
||||
online: 'var(--mui-palette-success-main)',
|
||||
} as Record<Status, string>;
|
||||
|
||||
const color = colors[status];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
flex: '0 0 auto',
|
||||
height: sizes[size],
|
||||
width: sizes[size],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
37
002_source/cms/src/components/core/property-item.tsx
Normal file
37
002_source/cms/src/components/core/property-item.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export interface PropertyItemProps {
|
||||
name: string;
|
||||
value: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export function PropertyItem({ name, value }: PropertyItemProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'grid',
|
||||
gridGap: 'var(--PropertyItem-gap, 8px)',
|
||||
gridTemplateColumns: 'var(--PropertyItem-columns)',
|
||||
p: 'var(--PropertyItem-padding, 8px)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{name}
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
{typeof value === 'string' ? (
|
||||
<Typography color={value ? 'text.primary' : 'text.secondary'} variant="subtitle2">
|
||||
{value || 'None'}
|
||||
</Typography>
|
||||
) : (
|
||||
<React.Fragment>{value}</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
35
002_source/cms/src/components/core/property-list.tsx
Normal file
35
002_source/cms/src/components/core/property-list.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import type { SxProps } from '@mui/system/styleFunctionSx';
|
||||
|
||||
export interface PropertyListProps {
|
||||
children: React.ReactNode;
|
||||
divider?: React.ReactNode;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
stripe?: 'even' | 'odd';
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
export function PropertyList({
|
||||
children,
|
||||
divider,
|
||||
orientation = 'horizontal',
|
||||
stripe,
|
||||
sx,
|
||||
}: PropertyListProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack
|
||||
divider={divider}
|
||||
sx={{
|
||||
'--PropertyItem-columns': orientation === 'horizontal' ? '150px minmax(0, 1fr)' : '1fr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--PropertyList-gap)',
|
||||
...(stripe && { [`& > *:nth-child(${stripe})`]: { bgcolor: 'var(--mui-palette-background-level1)' } }),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
32
002_source/cms/src/components/core/settings/option.tsx
Normal file
32
002_source/cms/src/components/core/settings/option.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
export interface OptionProps {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function Option({ selected, ...props }: OptionProps): React.JSX.Element {
|
||||
return (
|
||||
<Chip
|
||||
{...props}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
borderRadius: 'inherit',
|
||||
bottom: 0,
|
||||
content: '" "',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
...(selected && { boxShadow: '0 0 0 2px var(--mui-palette-primary-main)' }),
|
||||
},
|
||||
}}
|
||||
variant="soft"
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Moon as MoonIcon } from '@phosphor-icons/react/dist/ssr/Moon';
|
||||
import { Sun as SunIcon } from '@phosphor-icons/react/dist/ssr/Sun';
|
||||
|
||||
import type { ColorScheme } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsColorSchemeProps {
|
||||
onChange?: (value: ColorScheme) => void;
|
||||
value?: ColorScheme;
|
||||
}
|
||||
|
||||
export function OptionsColorScheme({ onChange, value }: OptionsColorSchemeProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Color scheme</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Light', value: 'light', icon: <SunIcon /> },
|
||||
{ label: 'Dark', value: 'dark', icon: <MoonIcon /> },
|
||||
] satisfies { label: string; value: string; icon: React.ReactNode }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={option.icon}
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value as ColorScheme);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { TextAlignLeft as TextAlignLeftIcon } from '@phosphor-icons/react/dist/ssr/TextAlignLeft';
|
||||
import { TextAlignRight as TextAlignRightIcon } from '@phosphor-icons/react/dist/ssr/TextAlignRight';
|
||||
|
||||
import type { Direction } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsDirectionProps {
|
||||
onChange?: (value: Direction) => void;
|
||||
value?: Direction;
|
||||
}
|
||||
|
||||
export function OptionsDirection({ onChange, value }: OptionsDirectionProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Orientation</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Left-to-right', value: 'ltr', icon: <TextAlignLeftIcon /> },
|
||||
{ label: 'Right-to-left', value: 'rtl', icon: <TextAlignRightIcon /> },
|
||||
] satisfies { label: string; value: Direction; icon: React.ReactElement }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={option.icon}
|
||||
key={option.label}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
143
002_source/cms/src/components/core/settings/options-layout.tsx
Normal file
143
002_source/cms/src/components/core/settings/options-layout.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Info as InfoIcon } from '@phosphor-icons/react/dist/ssr/Info';
|
||||
|
||||
import type { Layout } from '@/types/settings';
|
||||
|
||||
export interface OptionsLayoutProps {
|
||||
onChange?: (value: Layout) => void;
|
||||
value?: Layout;
|
||||
}
|
||||
|
||||
export function OptionsLayout({ onChange, value }: OptionsLayoutProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<InputLabel>Layout</InputLabel>
|
||||
<Tooltip placement="top" title="Dashboard only">
|
||||
<InfoIcon color="var(--mui-palette-text-secondary)" fontSize="var(--icon-fontSize-md)" weight="fill" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: 'repeat(2, minmax(0, 140px))' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Vertical', value: 'vertical', icon: <VerticalIcon /> },
|
||||
{ label: 'Horizontal', value: 'horizontal', icon: <HorizontalIcon /> },
|
||||
] satisfies { label: string; value: Layout; icon: React.ReactElement }[]
|
||||
).map((option) => (
|
||||
<Stack key={option.value} spacing={1}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
height: '88px',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
borderRadius: 'inherit',
|
||||
bottom: 0,
|
||||
content: '" "',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
...(option.value === value && { boxShadow: '0 0 0 2px var(--mui-palette-primary-main)' }),
|
||||
},
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{option.icon}
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="subtitle2">
|
||||
{option.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalIcon(): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 'inherit',
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ borderRight: '1px dashed var(--mui-palette-divider)', px: 1, py: 0.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ bgcolor: 'var(--mui-palette-primary-main)', borderRadius: '2px', height: '4px', width: '26px' }} />
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '26px' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '26px' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', p: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalIcon(): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 'inherit',
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: 'center', borderBottom: '1px dashed var(--mui-palette-divider)', px: 1, py: '4px' }}
|
||||
>
|
||||
<Box sx={{ bgcolor: 'var(--mui-palette-primary-main)', borderRadius: '2px', height: '4px', width: '16px' }} />
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '16px' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '16px' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', p: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Info as InfoIcon } from '@phosphor-icons/react/dist/ssr/Info';
|
||||
|
||||
import type { NavColor } from '@/types/settings';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsNavColorProps {
|
||||
onChange?: (value: NavColor) => void;
|
||||
value?: NavColor;
|
||||
}
|
||||
|
||||
export function OptionsNavColor({ onChange, value }: OptionsNavColorProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<InputLabel>Nav color</InputLabel>
|
||||
<Tooltip placement="top" title="Dashboard only">
|
||||
<InfoIcon color="var(--mui-palette-text-secondary)" fontSize="var(--icon-fontSize-md)" weight="fill" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Blend-in', value: 'blend_in' },
|
||||
{ label: 'Discrete', value: 'discrete' },
|
||||
{ label: 'Evident', value: 'evident' },
|
||||
] as { label: string; value: NavColor }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
key={option.label}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import type { PrimaryColor } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsPrimaryColorProps {
|
||||
onChange?: (value: PrimaryColor) => void;
|
||||
value?: PrimaryColor;
|
||||
}
|
||||
|
||||
export function OptionsPrimaryColor({ onChange, value }: OptionsPrimaryColorProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Primary color</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Chateau Green', value: 'chateauGreen', color: '#16b364' },
|
||||
{ label: 'Neon Blue', value: 'neonBlue', color: '#635bff' },
|
||||
{ label: 'Royal Blue', value: 'royalBlue', color: '#5265ff' },
|
||||
{ label: 'Tomato Orange', value: 'tomatoOrange', color: '#ff6c47' },
|
||||
] satisfies { label: string; value: PrimaryColor; color: string }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={
|
||||
<Box
|
||||
sx={{ bgcolor: option.color, borderRadius: '50%', flex: '0 0 auto', height: '24px', width: '24px' }}
|
||||
/>
|
||||
}
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { GearSix as GearSixIcon } from '@phosphor-icons/react/dist/ssr/GearSix';
|
||||
|
||||
import type { Settings } from '@/types/settings';
|
||||
import { config } from '@/config';
|
||||
import { setSettings as setPersistedSettings } from '@/lib/settings/set-settings';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
|
||||
import { SettingsDrawer } from './settings-drawer';
|
||||
|
||||
export function SettingsButton(): React.JSX.Element {
|
||||
const { settings } = useSettings();
|
||||
const { setColorScheme } = useColorScheme();
|
||||
const router = useRouter();
|
||||
|
||||
const [openDrawer, setOpenDrawer] = React.useState<boolean>(false);
|
||||
|
||||
const handleUpdate = async (values: Partial<Settings>): Promise<void> => {
|
||||
if (values.colorScheme) {
|
||||
setColorScheme(values.colorScheme);
|
||||
}
|
||||
|
||||
const updatedSettings = { ...settings, ...values } satisfies Settings;
|
||||
|
||||
await setPersistedSettings(updatedSettings);
|
||||
|
||||
// Refresh the router to apply the new settings.
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleReset = async (): Promise<void> => {
|
||||
setColorScheme(config.site.colorScheme);
|
||||
|
||||
await setPersistedSettings({});
|
||||
|
||||
// Refresh the router to apply the new settings.
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Tooltip title="Settings">
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => {
|
||||
setOpenDrawer(true);
|
||||
}}
|
||||
sx={{
|
||||
animation: 'spin 4s linear infinite',
|
||||
background: 'var(--mui-palette-neutral-900)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
bottom: 0,
|
||||
color: 'var(--mui-palette-common-white)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: '40px',
|
||||
m: 4,
|
||||
p: '10px',
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
width: '40px',
|
||||
zIndex: 'var(--mui-zIndex-speedDial)',
|
||||
'&:hover': { bgcolor: 'var(--mui-palette-neutral-700)' },
|
||||
'@keyframes spin': { '0%': { rotate: '0' }, '100%': { rotate: '360deg' } },
|
||||
}}
|
||||
>
|
||||
<GearSixIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<SettingsDrawer
|
||||
onClose={() => {
|
||||
setOpenDrawer(false);
|
||||
}}
|
||||
onReset={handleReset}
|
||||
onUpdate={handleUpdate}
|
||||
open={openDrawer}
|
||||
values={settings}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
105
002_source/cms/src/components/core/settings/settings-drawer.tsx
Normal file
105
002_source/cms/src/components/core/settings/settings-drawer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowCounterClockwise as ArrowCounterClockwiseIcon } from '@phosphor-icons/react/dist/ssr/ArrowCounterClockwise';
|
||||
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
|
||||
|
||||
import type { Settings } from '@/types/settings';
|
||||
|
||||
import { OptionsColorScheme } from './options-color-scheme';
|
||||
import { OptionsDirection } from './options-direction';
|
||||
import { OptionsLayout } from './options-layout';
|
||||
import { OptionsNavColor } from './options-nav-color';
|
||||
import { OptionsPrimaryColor } from './options-primary-color';
|
||||
|
||||
export interface SettingsDrawerProps {
|
||||
canReset?: boolean;
|
||||
onClose?: () => void;
|
||||
onReset?: () => void;
|
||||
onUpdate?: (settings: Partial<Settings>) => void;
|
||||
open?: boolean;
|
||||
values?: Partial<Settings>;
|
||||
}
|
||||
|
||||
export function SettingsDrawer({
|
||||
canReset = true,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onReset,
|
||||
open,
|
||||
values = {},
|
||||
}: SettingsDrawerProps): React.JSX.Element {
|
||||
const handleChange = React.useCallback(
|
||||
(field: keyof Settings, value: unknown) => {
|
||||
onUpdate?.({ [field]: value });
|
||||
},
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
ModalProps={{ BackdropProps: { invisible: true }, sx: { zIndex: 1400 } }}
|
||||
PaperProps={{ elevation: 24, sx: { display: 'flex', flexDirection: 'column', maxWidth: '100%', width: '440px' } }}
|
||||
anchor="right"
|
||||
disableScrollLock
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2 }}>
|
||||
<Typography variant="h6">App settings</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<Badge
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
color="error"
|
||||
sx={{ '& .MuiBadge-badge': { top: 6, right: 6, ...(!canReset && { display: 'none' }) } }}
|
||||
variant="dot"
|
||||
>
|
||||
<IconButton onClick={onReset}>
|
||||
<ArrowCounterClockwiseIcon />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
<IconButton onClick={onClose}>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={5} sx={{ overflowY: 'auto', p: 3 }}>
|
||||
<OptionsPrimaryColor
|
||||
onChange={(value) => {
|
||||
handleChange('primaryColor', value);
|
||||
}}
|
||||
value={values.primaryColor}
|
||||
/>
|
||||
<OptionsColorScheme
|
||||
onChange={(value) => {
|
||||
handleChange('colorScheme', value);
|
||||
}}
|
||||
value={values.colorScheme}
|
||||
/>
|
||||
<OptionsNavColor
|
||||
onChange={(value) => {
|
||||
handleChange('navColor', value);
|
||||
}}
|
||||
value={values.navColor}
|
||||
/>
|
||||
<OptionsLayout
|
||||
onChange={(value) => {
|
||||
handleChange('layout', value);
|
||||
}}
|
||||
value={values.layout}
|
||||
/>
|
||||
<OptionsDirection
|
||||
onChange={(value) => {
|
||||
handleChange('direction', value);
|
||||
}}
|
||||
value={values.direction}
|
||||
/>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Select from '@mui/material/Select';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Code as CodeIcon } from '@phosphor-icons/react/dist/ssr/Code';
|
||||
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
|
||||
import { LinkBreak as LinkBreakIcon } from '@phosphor-icons/react/dist/ssr/LinkBreak';
|
||||
import { ListDashes as ListDashesIcon } from '@phosphor-icons/react/dist/ssr/ListDashes';
|
||||
import { ListNumbers as ListNumbersIcon } from '@phosphor-icons/react/dist/ssr/ListNumbers';
|
||||
import { TextB as TextBIcon } from '@phosphor-icons/react/dist/ssr/TextB';
|
||||
import { TextItalic as TextItalicIcon } from '@phosphor-icons/react/dist/ssr/TextItalic';
|
||||
import { TextStrikethrough as TextStrikethroughIcon } from '@phosphor-icons/react/dist/ssr/TextStrikethrough';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
import { Option } from '@/components/core/option';
|
||||
|
||||
type HeadlingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface TextEditorToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export function TextEditorToolbar({ editor }: TextEditorToolbarProps): React.JSX.Element {
|
||||
const linkPopover = usePopover<HTMLButtonElement>();
|
||||
const [link, setLink] = React.useState<string>('');
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack
|
||||
className="tiptap-toolbar"
|
||||
spacing={1}
|
||||
sx={{ borderBottom: '1px solid var(--mui-palette-divider)', p: '8px', minHeight: '57px' }}
|
||||
>
|
||||
{editor ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
onChange={(event: SelectChangeEvent) => {
|
||||
const value = event.target.value;
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'p') {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('h')) {
|
||||
const level = parseInt(value.replace('h', '')) as HeadlingLevel;
|
||||
|
||||
if (!isNaN(level) && level >= 1 && level <= 6) {
|
||||
editor.chain().focus().setHeading({ level }).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
value={getFontValue(editor)}
|
||||
>
|
||||
<Option disabled={!editor.can().chain().focus().setParagraph().run()} value="p">
|
||||
Paragrah
|
||||
</Option>
|
||||
{([1, 2, 3, 4, 5, 6] as const).map((level) => (
|
||||
<Option
|
||||
disabled={!editor.can().chain().focus().setHeading({ level }).run()}
|
||||
key={level}
|
||||
value={`h${level}`}
|
||||
>
|
||||
Heading {level}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('bold')}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleBold().run();
|
||||
}}
|
||||
>
|
||||
<TextBIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('italic')}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleItalic().run();
|
||||
}}
|
||||
>
|
||||
<TextItalicIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('strike')}
|
||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleStrike().run();
|
||||
}}
|
||||
>
|
||||
<TextStrikethroughIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('codeBlock')}
|
||||
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleCodeBlock();
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('bulletList')}
|
||||
disabled={!editor.can().chain().focus().toggleBulletList().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
}}
|
||||
>
|
||||
<ListDashesIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('orderedList')}
|
||||
disabled={!editor.can().chain().focus().toggleOrderedList().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
}}
|
||||
>
|
||||
<ListNumbersIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
setLink((editor.getAttributes('link').href as string) ?? '');
|
||||
linkPopover.handleOpen();
|
||||
}}
|
||||
ref={linkPopover.anchorRef}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('link')}
|
||||
disabled={!editor.can().chain().focus().unsetLink().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
}}
|
||||
>
|
||||
<LinkBreakIcon />
|
||||
</ToolbarButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Popover
|
||||
anchorEl={linkPopover.anchorRef.current}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={() => {
|
||||
linkPopover.handleClose();
|
||||
setLink('');
|
||||
}}
|
||||
open={linkPopover.open}
|
||||
slotProps={{ paper: { sx: { p: 2 } } }}
|
||||
>
|
||||
<FormControl>
|
||||
<InputLabel>URL</InputLabel>
|
||||
<OutlinedInput
|
||||
name="url"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLink(event.target.value);
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (link === '') {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
editor?.chain().focus().setLink({ href: link }).run();
|
||||
linkPopover.handleClose();
|
||||
setLink('');
|
||||
}}
|
||||
value={link}
|
||||
/>
|
||||
</FormControl>
|
||||
</Popover>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function getFontValue(editor: Editor): string {
|
||||
return editor.isActive('paragraph')
|
||||
? 'p'
|
||||
: editor.isActive('heading', { level: 1 })
|
||||
? 'h1'
|
||||
: editor.isActive('heading', { level: 2 })
|
||||
? 'h2'
|
||||
: editor.isActive('heading', { level: 3 })
|
||||
? 'h3'
|
||||
: editor.isActive('heading', { level: 4 })
|
||||
? 'h4'
|
||||
: editor.isActive('heading', { level: 5 })
|
||||
? 'h5'
|
||||
: editor.isActive('heading', { level: 6 })
|
||||
? 'h6'
|
||||
: 'p';
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(function ToolbarButton(
|
||||
{ active, children, disabled, onClick },
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<IconButton color={active ? 'primary' : 'secondary'} disabled={disabled} onClick={onClick} ref={ref}>
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
});
|
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import type { EditorOptions, Extension } from '@tiptap/react';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
|
||||
import { TextEditorToolbar } from './text-editor-toolbar';
|
||||
|
||||
export interface TextEditorProps {
|
||||
content: EditorOptions['content'];
|
||||
editable?: EditorOptions['editable'];
|
||||
hideToolbar?: boolean;
|
||||
onUpdate?: EditorOptions['onUpdate'];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin wrapper around tiptap.
|
||||
*
|
||||
* How to get the updated content:
|
||||
* ```ts
|
||||
* <TextEditor
|
||||
* onUpdate={({ editor }) => {
|
||||
* console.log(editor.getHTML());
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function TextEditor({
|
||||
content,
|
||||
editable = true,
|
||||
hideToolbar,
|
||||
onUpdate = () => {
|
||||
// noop
|
||||
},
|
||||
placeholder,
|
||||
}: TextEditorProps): React.JSX.Element {
|
||||
const extensions = [
|
||||
StarterKit,
|
||||
Placeholder.configure({ emptyEditorClass: 'is-editor-empty', placeholder }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
] as Extension[];
|
||||
|
||||
const editor = useEditor({ extensions, content, editable, onUpdate });
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tiptap-root"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...(editable && {
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
boxShadow: 'var(--mui-shadows-1)',
|
||||
}),
|
||||
'& .tiptap-container': { display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 },
|
||||
'& .tiptap': {
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
flex: '1 1 auto',
|
||||
overflow: 'auto',
|
||||
p: '8px 16px',
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
'&.resize-cursor': { cursor: 'ew-resize', '& table': { cursor: 'col-resize' } },
|
||||
'& .is-editor-empty:before': {
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
content: 'attr(data-placeholder)',
|
||||
float: 'left',
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!hideToolbar ? <TextEditorToolbar editor={editor} /> : <div />}
|
||||
<EditorContent className="tiptap-container" editor={editor} />
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import createCache from '@emotion/cache';
|
||||
import type { EmotionCache, Options as OptionsOfCreateCache } from '@emotion/cache';
|
||||
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
|
||||
|
||||
interface Registry {
|
||||
cache: EmotionCache;
|
||||
flush: () => { name: string; isGlobal: boolean }[];
|
||||
}
|
||||
|
||||
export interface NextAppDirEmotionCacheProviderProps {
|
||||
options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
|
||||
CacheProvider?: (props: { value: EmotionCache; children: React.ReactNode }) => React.JSX.Element | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
|
||||
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps): React.JSX.Element {
|
||||
const { options, CacheProvider = DefaultCacheProvider, children } = props;
|
||||
|
||||
const [registry] = React.useState<Registry>(() => {
|
||||
const cache = createCache(options);
|
||||
cache.compat = true;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- Expected
|
||||
const prevInsert = cache.insert;
|
||||
let inserted: { name: string; isGlobal: boolean }[] = [];
|
||||
cache.insert = (...args) => {
|
||||
const [selector, serialized] = args;
|
||||
|
||||
if (cache.inserted[serialized.name] === undefined) {
|
||||
inserted.push({ name: serialized.name, isGlobal: !selector });
|
||||
}
|
||||
|
||||
return prevInsert(...args);
|
||||
};
|
||||
const flush = (): { name: string; isGlobal: boolean }[] => {
|
||||
const prevInserted = inserted;
|
||||
inserted = [];
|
||||
return prevInserted;
|
||||
};
|
||||
return { cache, flush };
|
||||
});
|
||||
|
||||
useServerInsertedHTML((): React.JSX.Element | null => {
|
||||
const inserted = registry.flush();
|
||||
|
||||
if (inserted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let styles = '';
|
||||
let dataEmotionAttribute = registry.cache.key;
|
||||
|
||||
const globals: { name: string; style: string }[] = [];
|
||||
|
||||
inserted.forEach(({ name, isGlobal }) => {
|
||||
const style = registry.cache.inserted[name];
|
||||
|
||||
if (typeof style !== 'boolean') {
|
||||
if (isGlobal) {
|
||||
globals.push({ name, style });
|
||||
} else {
|
||||
styles += style;
|
||||
dataEmotionAttribute += ` ${name}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{globals.map(
|
||||
({ name, style }): React.JSX.Element => (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{ __html: style }}
|
||||
data-emotion={`${registry.cache.key}-global ${name}`}
|
||||
key={name}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{styles ? <style dangerouslySetInnerHTML={{ __html: styles }} data-emotion={dataEmotionAttribute} /> : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
|
||||
}
|
30
002_source/cms/src/components/core/theme-provider/rtl.tsx
Normal file
30
002_source/cms/src/components/core/theme-provider/rtl.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import createCache from '@emotion/cache';
|
||||
import type { EmotionCache } from '@emotion/cache';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import stylisRTLPlugin from 'stylis-plugin-rtl';
|
||||
|
||||
import type { Direction } from '@/styles/theme/types';
|
||||
|
||||
function styleCache(): EmotionCache {
|
||||
return createCache({ key: 'rtl', prepend: true, stylisPlugins: [stylisRTLPlugin] });
|
||||
}
|
||||
|
||||
export interface RTLProps {
|
||||
children: React.ReactNode;
|
||||
direction?: Direction;
|
||||
}
|
||||
|
||||
export function Rtl({ children, direction = 'ltr' }: RTLProps): React.JSX.Element {
|
||||
React.useEffect(() => {
|
||||
document.dir = direction;
|
||||
}, [direction]);
|
||||
|
||||
if (direction === 'rtl') {
|
||||
return <CacheProvider value={styleCache()}>{children}</CacheProvider>;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
|
||||
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { createTheme } from '@/styles/theme/create-theme';
|
||||
|
||||
import EmotionCache from './emotion-cache';
|
||||
import { Rtl } from './rtl';
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: settings.primaryColor,
|
||||
colorScheme: settings.colorScheme,
|
||||
direction: settings.direction,
|
||||
});
|
||||
|
||||
return (
|
||||
<EmotionCache options={{ key: 'mui' }}>
|
||||
<CssVarsProvider defaultColorScheme={settings.colorScheme} defaultMode={settings.colorScheme} theme={theme}>
|
||||
<CssBaseline />
|
||||
<Rtl direction={settings.direction}>{children}</Rtl>
|
||||
</CssVarsProvider>
|
||||
</EmotionCache>
|
||||
);
|
||||
}
|
26
002_source/cms/src/components/core/tip.tsx
Normal file
26
002_source/cms/src/components/core/tip.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Lightbulb as LightbulbIcon } from '@phosphor-icons/react/dist/ssr/Lightbulb';
|
||||
|
||||
export interface TipProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function Tip({ message }: TipProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: 'center', bgcolor: 'var(--mui-palette-background-level1)', borderRadius: 1, p: 1 }}
|
||||
>
|
||||
<LightbulbIcon />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit">
|
||||
Tip.
|
||||
</Typography>{' '}
|
||||
{message}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
5
002_source/cms/src/components/core/toaster.tsx
Normal file
5
002_source/cms/src/components/core/toaster.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { toast, Toaster } from 'sonner';
|
||||
|
||||
export { Toaster, toast };
|
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import type { StepIconProps } from '@mui/material/StepIcon';
|
||||
import { Check as CheckIcon } from '@phosphor-icons/react/dist/ssr/Check';
|
||||
|
||||
export function ChapterStepIcon({ active, completed, icon }: StepIconProps): React.JSX.Element {
|
||||
const highlight = active || completed;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
sx={{
|
||||
'--Avatar-size': '24px',
|
||||
...(highlight
|
||||
? { bgcolor: 'var(--mui-palette-primary-main)', color: 'var(--mui-palette-primary-contrastText)' }
|
||||
: { bgcolor: 'var(--mui-palette-background-level2)', color: 'var(--mui-palette-text-primary)' }),
|
||||
}}
|
||||
variant="circular"
|
||||
>
|
||||
{completed ? <CheckIcon /> : icon}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
|
||||
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
|
||||
|
||||
import { Lesson } from './lesson';
|
||||
import type { Chapter } from './types';
|
||||
|
||||
export interface ChapterViewProps {
|
||||
chapter: Chapter;
|
||||
}
|
||||
|
||||
export function ChapterView({ chapter }: ChapterViewProps): React.JSX.Element {
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pb: 6 }}>
|
||||
<Card>
|
||||
<CardHeader subheader={chapter.description} title={chapter.title} />
|
||||
<Tabs sx={{ px: 3 }} value="lesson">
|
||||
<Tab label="Lesson" tabIndex={0} value="lesson" />
|
||||
<Tab label="Resources" tabIndex={0} value="resources" />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Lesson content={chapter.lesson} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box
|
||||
sx={{
|
||||
bottom: 20,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Card sx={{ boxShadow: 'var(--mui-shadows-16)' }}>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', p: 1 }}>
|
||||
<Button color="secondary" size="small" startIcon={<ArrowLeftIcon />}>
|
||||
Prev
|
||||
</Button>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
1/3
|
||||
</Typography>
|
||||
<Button color="secondary" size="small" startIcon={<ArrowRightIcon />}>
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
|
||||
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
|
||||
import type { Course } from './types';
|
||||
|
||||
export interface CourseCardProps {
|
||||
course: Course;
|
||||
}
|
||||
|
||||
export function CourseCard({ course }: CourseCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardMedia
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.academy.details('1')}
|
||||
image={course.media}
|
||||
sx={{ height: '180px' }}
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Link
|
||||
color="text.primary"
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.academy.details('1')}
|
||||
underline="none"
|
||||
variant="subtitle1"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{course.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<ClockIcon fontSize="var(--icon-fontSize-sm)" />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{course.duration}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
<LinearProgress value={course.progress} variant="determinate" />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 1 }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
component={RouterLink}
|
||||
endIcon={<ArrowRightIcon />}
|
||||
href={paths.dashboard.academy.details('1')}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Step from '@mui/material/Step';
|
||||
import StepLabel from '@mui/material/StepLabel';
|
||||
import Stepper from '@mui/material/Stepper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
|
||||
|
||||
import { ChapterStepIcon } from './chapter-step-icon';
|
||||
import type { Chapter, Course } from './types';
|
||||
|
||||
export interface CourseSummaryProps {
|
||||
chapters: Chapter[];
|
||||
course: Course;
|
||||
currentChapterNumber?: number;
|
||||
}
|
||||
|
||||
export function CourseSummary({ chapters, course, currentChapterNumber }: CourseSummaryProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<LinearProgress sx={{ flex: '1 1 auto', height: '8px' }} value={course.progress} variant="determinate" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(
|
||||
course.progress / 100
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<ClockIcon fontSize="var(--icon-fontSize-sm)" />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{course.duration}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<div>
|
||||
<Typography variant="subtitle1">{course.title}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{course.description}
|
||||
</Typography>
|
||||
</div>
|
||||
<Stepper
|
||||
activeStep={currentChapterNumber ? currentChapterNumber - 1 : 0}
|
||||
orientation="vertical"
|
||||
sx={{
|
||||
'& .MuiStepLabel-iconContainer': { pr: 3 },
|
||||
'& .MuiStepConnector-line': { borderLeft: '2px solid var(--mui-palette-divider)' },
|
||||
}}
|
||||
>
|
||||
{chapters.map((chapter, index) => {
|
||||
const isCompleted = currentChapterNumber ? index < currentChapterNumber - 1 : false;
|
||||
|
||||
return (
|
||||
<Step key={chapter.title}>
|
||||
<StepLabel StepIconComponent={ChapterStepIcon}>
|
||||
<Typography color={isCompleted ? 'primary.main' : 'text.primary'} variant="subtitle2">
|
||||
{chapter.title}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{chapter.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Select from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
import { Option } from '@/components/core/option';
|
||||
|
||||
export function CoursesFilters(): React.JSX.Element {
|
||||
return (
|
||||
<Card>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-end', flexWrap: 'wrap', p: 3 }}>
|
||||
<FormControl sx={{ maxWidth: '100%', width: '240px' }}>
|
||||
<InputLabel>Search</InputLabel>
|
||||
<OutlinedInput fullWidth name="query" placeholder="Keywords" />
|
||||
</FormControl>
|
||||
<FormControl sx={{ maxWidth: '100%', width: '240px' }}>
|
||||
<InputLabel>Platform</InputLabel>
|
||||
<Select defaultValue="" fullWidth name="category">
|
||||
<Option value="">All categories</Option>
|
||||
<Option value="fullstack">Fullstack</Option>
|
||||
<Option value="devops">DevOps</Option>
|
||||
<Option value="design">Design</Option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<DatePicker
|
||||
format="MMM D, YYYY"
|
||||
label="From"
|
||||
onChange={() => {
|
||||
// noop
|
||||
}}
|
||||
sx={{ maxWidth: '100%', width: '240px' }}
|
||||
value={dayjs().subtract(1, 'month')}
|
||||
/>
|
||||
<DatePicker
|
||||
format="MMM D, YYYY"
|
||||
label="To"
|
||||
onChange={() => {
|
||||
// noop
|
||||
}}
|
||||
sx={{ maxWidth: '100%', width: '240px' }}
|
||||
value={dayjs()}
|
||||
/>
|
||||
<Button startIcon={<MagnifyingGlassIcon />} variant="contained">
|
||||
Search
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { RadialBar, RadialBarChart } from 'recharts';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
export interface DailyProgressProps {
|
||||
timeCurrent: number;
|
||||
timeGoal: number;
|
||||
}
|
||||
|
||||
export function DailyProgress({ timeCurrent, timeGoal }: DailyProgressProps): React.JSX.Element {
|
||||
const chartSize = 250;
|
||||
|
||||
const timeLeft = timeGoal - timeCurrent;
|
||||
const progress = Math.round((timeCurrent / timeGoal) * 100);
|
||||
|
||||
const data = [
|
||||
{ name: 'Empty', value: 100 },
|
||||
{ name: 'Usage', value: progress },
|
||||
] satisfies { name: string; value: number }[];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<NoSsr fallback={<Box sx={{ height: `${chartSize}px` }} />}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
// hide the empty bar
|
||||
'& .recharts-layer path[name="Empty"]': { display: 'none' },
|
||||
'& .recharts-layer .recharts-radial-bar-background-sector': {
|
||||
fill: 'var(--mui-palette-neutral-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RadialBarChart
|
||||
barSize={20}
|
||||
data={data}
|
||||
endAngle={-10}
|
||||
height={chartSize}
|
||||
innerRadius={166}
|
||||
startAngle={190}
|
||||
width={chartSize}
|
||||
>
|
||||
<RadialBar
|
||||
animationDuration={300}
|
||||
background
|
||||
cornerRadius={10}
|
||||
dataKey="value"
|
||||
endAngle={-320}
|
||||
fill="var(--mui-palette-primary-main)"
|
||||
startAngle={20}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', mt: '-50px' }}>
|
||||
<Typography variant="subtitle1">Time left</Typography>
|
||||
<Typography variant="body2">{timeLeft} min</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NoSsr>
|
||||
</Box>
|
||||
<Box sx={{ mt: '-80px' }}>
|
||||
<Typography variant="h6">Today's progress of your {timeGoal}-minutes goal</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
You have used{' '}
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(progress / 100)} of
|
||||
your available spots. Upgrade plan to create more projects.
|
||||
</Typography>
|
||||
</Box>
|
||||
<div>
|
||||
<Button variant="contained">Continue: React and Redux Tutorial</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
58
002_source/cms/src/components/dashboard/academy/help.tsx
Normal file
58
002_source/cms/src/components/dashboard/academy/help.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
|
||||
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
|
||||
import { UserPlus as UserPlusIcon } from '@phosphor-icons/react/dist/ssr/UserPlus';
|
||||
|
||||
export function Help(): React.JSX.Element {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }} variant="outlined">
|
||||
<CardContent>
|
||||
<Stack divider={<Divider />} spacing={2}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: '0 0 auto' }}>
|
||||
<FileIcon fontSize="var(--icon-fontSize-lg)" />
|
||||
</Box>
|
||||
<Stack spacing={2}>
|
||||
<div>
|
||||
<Typography variant="subtitle1">Find courses</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Browse the latest written articles
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Button endIcon={<ArrowRightIcon />} size="small" variant="contained">
|
||||
Courses
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Box sx={{ flex: '0 0 auto' }}>
|
||||
<UserPlusIcon fontSize="var(--icon-fontSize-lg)" />
|
||||
</Box>
|
||||
<Stack spacing={2}>
|
||||
<div>
|
||||
<Typography variant="subtitle1">Find tutors</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Find tutors to help you with your studies
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Button color="secondary" endIcon={<ArrowRightIcon />} size="small" variant="contained">
|
||||
Tutors
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
26
002_source/cms/src/components/dashboard/academy/lesson.tsx
Normal file
26
002_source/cms/src/components/dashboard/academy/lesson.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
import { CodeHighlighter } from '@/components/core/code-highlighter';
|
||||
|
||||
const components = { code: CodeHighlighter as Components['code'] } satisfies Components;
|
||||
|
||||
export interface LessonProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Lesson({ content }: LessonProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'& h2': { fontWeight: 500, fontSize: '1.5rem', lineHeight: 1.2, mb: 3 },
|
||||
'& h3': { fontWeight: 500, fontSize: '1.25rem', lineHeight: 1.2, mb: 3 },
|
||||
'& p': { fontWeight: 400, fontSize: '1rem', lineHeight: 1.5, mb: 2, mt: 0 },
|
||||
}}
|
||||
>
|
||||
<Markdown components={components}>{content}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
16
002_source/cms/src/components/dashboard/academy/types.d.ts
vendored
Normal file
16
002_source/cms/src/components/dashboard/academy/types.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
media?: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
number: number;
|
||||
description: string;
|
||||
lesson: string;
|
||||
title: string;
|
||||
}
|
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { ShareNetwork as ShareNetworkIcon } from '@phosphor-icons/react/dist/ssr/ShareNetwork';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
const bars = [
|
||||
{ name: 'Sessions', dataKey: 'v1', color: 'var(--mui-palette-primary-main)' },
|
||||
{ name: 'Bounce rate', dataKey: 'v2', color: 'var(--mui-palette-primary-100)' },
|
||||
] satisfies { name: string; dataKey: string; color: string }[];
|
||||
|
||||
export interface ChannelSessionsVsBounceProps {
|
||||
data: { name: string; v1: number; v2: number }[];
|
||||
}
|
||||
|
||||
export function ChannelSessionsVsBounce({ data }: ChannelSessionsVsBounceProps): React.JSX.Element {
|
||||
const chartHeight = 300;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<IconButton>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
}
|
||||
avatar={
|
||||
<Avatar>
|
||||
<ShareNetworkIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
}
|
||||
title="Sessions vs bounce rate by channel"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack divider={<Divider />} spacing={3}>
|
||||
<NoSsr fallback={<Box sx={{ height: `${chartHeight}px` }} />}>
|
||||
<ResponsiveContainer height={chartHeight}>
|
||||
<BarChart barGap={12} data={data} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="2 4" vertical={false} />
|
||||
<XAxis axisLine={false} dataKey="name" tickLine={false} type="category" />
|
||||
<YAxis axisLine={false} hide type="number" />
|
||||
{bars.map(
|
||||
(bar): React.JSX.Element => (
|
||||
<Bar
|
||||
animationDuration={300}
|
||||
barSize={24}
|
||||
dataKey={bar.dataKey}
|
||||
fill={bar.color}
|
||||
key={bar.name}
|
||||
name={bar.name}
|
||||
radius={[5, 5, 0, 0]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Tooltip animationDuration={50} content={<TooltipContent />} cursor={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</NoSsr>
|
||||
<Legend />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend(): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
{bars.map((bar) => (
|
||||
<Stack direction="row" key={bar.name} spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box sx={{ bgcolor: bar.color, borderRadius: '2px', height: '4px', width: '16px' }} />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{bar.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface TooltipContentProps {
|
||||
active?: boolean;
|
||||
payload?: { fill: string; name: string; value: number }[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function TooltipContent({ active, payload }: TooltipContentProps): React.JSX.Element | null {
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)', p: 1 }}>
|
||||
<Stack spacing={2}>
|
||||
{payload?.map(
|
||||
(entry): React.JSX.Element => (
|
||||
<Stack direction="row" key={entry.name} spacing={3} sx={{ alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
|
||||
<Box sx={{ bgcolor: entry.fill, borderRadius: '2px', height: '8px', width: '8px' }} />
|
||||
<Typography sx={{ whiteSpace: 'nowrap' }}>{entry.name}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{new Intl.NumberFormat('en-US').format(entry.value)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ChartPie as ChartPieIcon } from '@phosphor-icons/react/dist/ssr/ChartPie';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
const countries = {
|
||||
ca: { name: 'Canada', flag: '/assets/flag-ca.svg' },
|
||||
de: { name: 'Germany', flag: '/assets/flag-de.svg' },
|
||||
ru: { name: 'Russia', flag: '/assets/flag-ru.svg' },
|
||||
uk: { name: 'United Kingdom', flag: '/assets/flag-uk.svg' },
|
||||
us: { name: 'United States', flag: '/assets/flag-us.svg' },
|
||||
} as const;
|
||||
|
||||
const bars = [
|
||||
{ name: 'Sessions', dataKey: 'v1', color: 'var(--mui-palette-primary-main)' },
|
||||
{ name: 'Bounce rate', dataKey: 'v2', color: 'var(--mui-palette-primary-100)' },
|
||||
] satisfies { name: string; dataKey: string; color: string }[];
|
||||
|
||||
export interface CountrySessionsVsBounceProps {
|
||||
data: { name: string; v1: number; v2: number }[];
|
||||
}
|
||||
|
||||
export function CountrySessionsVsBounce({ data }: CountrySessionsVsBounceProps): React.JSX.Element {
|
||||
const chartHeight = 300;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<IconButton>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
}
|
||||
avatar={
|
||||
<Avatar>
|
||||
<ChartPieIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
}
|
||||
title="Sessions vs bounce rate by country"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack divider={<Divider />} spacing={3}>
|
||||
<NoSsr fallback={<Box sx={{ height: `${chartHeight}px` }} />}>
|
||||
<ResponsiveContainer height={chartHeight}>
|
||||
<BarChart barGap={10} data={data} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: 100 }}>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="2 4" syncWithTicks />
|
||||
<XAxis axisLine={false} tickLine={false} type="number" />
|
||||
<YAxis axisLine={false} dataKey="name" tick={<Tick />} tickLine={false} type="category" />
|
||||
{bars.map(
|
||||
(bar): React.JSX.Element => (
|
||||
<Bar
|
||||
animationDuration={300}
|
||||
barSize={12}
|
||||
dataKey={bar.dataKey}
|
||||
fill={bar.color}
|
||||
key={bar.name}
|
||||
name={bar.name}
|
||||
radius={[5, 5, 5, 5]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Tooltip animationDuration={50} content={<TooltipContent />} cursor={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</NoSsr>
|
||||
<Legend />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface TickProps {
|
||||
height?: number;
|
||||
payload?: { name: string; value: string };
|
||||
width?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
function Tick({ height, payload, width, x, y }: TickProps): React.JSX.Element {
|
||||
const { name, flag } = countries[payload?.value as keyof typeof countries] ?? { name: 'Unknown', flag: '' };
|
||||
|
||||
return (
|
||||
<foreignObject height={width} width={height} x={(x ?? 0) - 150} y={(y ?? 0) - 16}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box sx={{ height: '1rem', width: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box alt={name} component="img" src={flag} sx={{ height: 'auto', width: '100%' }} />
|
||||
</Box>
|
||||
<Typography noWrap variant="body2">
|
||||
{name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend(): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" spacing={2}>
|
||||
{bars.map((bar) => (
|
||||
<Stack direction="row" key={bar.name} spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box sx={{ bgcolor: bar.color, borderRadius: '2px', height: '4px', width: '16px' }} />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{bar.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface TooltipContentProps {
|
||||
active?: boolean;
|
||||
payload?: { fill: string; name: string; value: number }[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function TooltipContent({ active, payload }: TooltipContentProps): React.JSX.Element | null {
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)', p: 1 }}>
|
||||
<Stack spacing={2}>
|
||||
{payload?.map(
|
||||
(entry): React.JSX.Element => (
|
||||
<Stack direction="row" key={entry.name} spacing={3} sx={{ alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
|
||||
<Box sx={{ bgcolor: entry.fill, borderRadius: '2px', height: '8px', width: '8px' }} />
|
||||
<Typography sx={{ whiteSpace: 'nowrap' }}>{entry.name}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{new Intl.NumberFormat('en-US').format(entry.value)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
132
002_source/cms/src/components/dashboard/analytics/devices.tsx
Normal file
132
002_source/cms/src/components/dashboard/analytics/devices.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Devices as DevicesIcon } from '@phosphor-icons/react/dist/ssr/Devices';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
export interface DevicesProps {
|
||||
data: { name: string; value: number; color: string }[];
|
||||
}
|
||||
|
||||
export function Devices({ data }: DevicesProps): React.JSX.Element {
|
||||
const chartSize = 200;
|
||||
const chartTickness = 30;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
action={
|
||||
<IconButton>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
}
|
||||
avatar={
|
||||
<Avatar>
|
||||
<DevicesIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
}
|
||||
title="Devices"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack divider={<Divider />} spacing={3}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<NoSsr fallback={<Box sx={{ height: `${chartSize}px`, width: `${chartSize}px` }} />}>
|
||||
<PieChart height={chartSize} margin={{ top: 0, right: 0, bottom: 0, left: 0 }} width={chartSize}>
|
||||
<Pie
|
||||
animationDuration={300}
|
||||
cx={chartSize / 2}
|
||||
cy={chartSize / 2}
|
||||
data={data}
|
||||
dataKey="value"
|
||||
innerRadius={chartSize / 2 - chartTickness}
|
||||
nameKey="name"
|
||||
outerRadius={chartSize / 2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data.map(
|
||||
(entry): React.JSX.Element => (
|
||||
<Cell fill={entry.color} key={entry.name} />
|
||||
)
|
||||
)}
|
||||
</Pie>
|
||||
<Tooltip animationDuration={50} content={<TooltipContent />} />
|
||||
</PieChart>
|
||||
</NoSsr>
|
||||
</Box>
|
||||
<Legend payload={data} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegendProps {
|
||||
payload?: { name: string; value: number; color: string }[];
|
||||
}
|
||||
|
||||
function Legend({ payload }: LegendProps): React.JSX.Element {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3, gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))' }}>
|
||||
{payload?.map(
|
||||
(entry): React.JSX.Element => (
|
||||
<div key={entry.name}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box sx={{ bgcolor: entry.color, borderRadius: '2px', height: '4px', width: '16px' }} />
|
||||
<Typography variant="body2">{entry.name}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="h5">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(entry.value / 100)}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface TooltipContentProps {
|
||||
active?: boolean;
|
||||
payload?: { name: string; payload: { fill: string }; value: number }[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function TooltipContent({ active, payload }: TooltipContentProps): React.JSX.Element | null {
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ border: '1px solid var(--mui-palette-divider)', boxShadow: 'var(--mui-shadows-16)', p: 1 }}>
|
||||
<Stack spacing={2}>
|
||||
{payload?.map(
|
||||
(entry): React.JSX.Element => (
|
||||
<Stack direction="row" key={entry.name} spacing={3} sx={{ alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
|
||||
<Box sx={{ bgcolor: entry.payload.fill, borderRadius: '2px', height: '8px', width: '8px' }} />
|
||||
<Typography sx={{ whiteSpace: 'nowrap' }}>{entry.name}</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(
|
||||
entry.value / 100
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowsDownUp as ArrowsDownUpIcon } from '@phosphor-icons/react/dist/ssr/ArrowsDownUp';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { TrendDown as TrendDownIcon } from '@phosphor-icons/react/dist/ssr/TrendDown';
|
||||
import { TrendUp as TrendUpIcon } from '@phosphor-icons/react/dist/ssr/TrendUp';
|
||||
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
export interface InboundOutboundProps {
|
||||
inbound: { color: string; data: number[]; diff: number; trend: 'up' | 'down'; value: number };
|
||||
outbound: { color: string; data: number[]; diff: number; trend: 'up' | 'down'; value: number };
|
||||
}
|
||||
|
||||
export function InboundOutbound({ inbound, outbound }: InboundOutboundProps): React.JSX.Element {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardHeader
|
||||
action={
|
||||
<IconButton>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
}
|
||||
avatar={
|
||||
<Avatar>
|
||||
<ArrowsDownUpIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
}
|
||||
title="Average"
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Segment name="Inbound" {...inbound} />
|
||||
<Segment name="Outbound" {...outbound} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
color: string;
|
||||
data: number[];
|
||||
diff: number;
|
||||
name: string;
|
||||
trend: 'up' | 'down';
|
||||
value: number;
|
||||
}
|
||||
|
||||
function Segment({ color, data: dataRaw, diff, name, trend, value }: SegmentProps): React.JSX.Element {
|
||||
const chartHeight = 120;
|
||||
|
||||
const data = dataRaw.map((item, index) => ({ name: index, value: item }));
|
||||
|
||||
const sortedData = [...dataRaw].sort((a, b) => a - b);
|
||||
const min = sortedData[0] ?? 0;
|
||||
const max = sortedData[sortedData.length - 1] ?? 0;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={3}
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
borderRadius: '12px',
|
||||
justifyContent: 'space-between',
|
||||
p: '8px 16px',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1} sx={{ width: '200px' }}>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="h3">{value}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: trend === 'up' ? 'var(--mui-palette-success-main)' : 'var(--mui-palette-error-main)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{trend === 'up' ? (
|
||||
<TrendUpIcon fontSize="var(--icon-fontSize-md)" />
|
||||
) : (
|
||||
<TrendDownIcon fontSize="var(--icon-fontSize-md)" />
|
||||
)}
|
||||
</Box>
|
||||
<Typography color={trend === 'up' ? 'success.main' : 'error.main'} variant="subtitle2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(diff / 100)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<NoSsr fallback={<Box sx={{ height: `${chartHeight}px` }} />}>
|
||||
<ResponsiveContainer height={chartHeight} width="100%">
|
||||
<AreaChart data={data} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`area-${name}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="75%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis axisLine={false} dataKey="name" hide type="category" />
|
||||
<YAxis axisLine={false} domain={[min - 200, max + 50]} hide type="number" />
|
||||
<Area
|
||||
animationDuration={300}
|
||||
dataKey="value"
|
||||
fill={`url(#area-${name})`}
|
||||
fillOpacity={1}
|
||||
name={name}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</NoSsr>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { Lightbulb as LightbulbIcon } from '@phosphor-icons/react/dist/ssr/Lightbulb';
|
||||
|
||||
export interface InsightProps {
|
||||
insights: { id: string; title: string; description: string }[];
|
||||
}
|
||||
|
||||
export function Insight({ insights }: InsightProps): React.JSX.Element {
|
||||
const insight = insights[0];
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="/assets/pulse.svg"
|
||||
sx={{ height: '450px', left: 0, position: 'absolute', top: 0, width: '450px', zIndex: 0 }}
|
||||
/>
|
||||
<CardHeader
|
||||
action={
|
||||
<IconButton color="inherit">
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
}
|
||||
avatar={
|
||||
<Avatar>
|
||||
<LightbulbIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
}
|
||||
sx={{ zIndex: 1 }}
|
||||
title="Insight"
|
||||
/>
|
||||
<CardContent sx={{ zIndex: 1 }}>
|
||||
<Stack key={insight.id} spacing={3}>
|
||||
<Typography color="inherit" variant="h1">
|
||||
{insight.title}
|
||||
</Typography>
|
||||
<Typography color="inherit">{insight.description}</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,98 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { TrendDown as TrendDownIcon } from '@phosphor-icons/react/dist/ssr/TrendDown';
|
||||
import { TrendUp as TrendUpIcon } from '@phosphor-icons/react/dist/ssr/TrendUp';
|
||||
|
||||
export function Summary(): React.JSX.Element {
|
||||
return (
|
||||
<Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gap: 2,
|
||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' },
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
borderRight: { xs: 'none', md: '1px solid var(--mui-palette-divider)' },
|
||||
borderBottom: { xs: '1px solid var(--mui-palette-divider)', md: 'none' },
|
||||
pb: { xs: 2, md: 0 },
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Sessions</Typography>
|
||||
<Typography variant="h3">{new Intl.NumberFormat('en-US').format(16500)}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TrendUpIcon color="var(--mui-palette-success-main)" fontSize="var(--icon-fontSize-md)" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
<Typography color="success.main" component="span" variant="subtitle2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(15 / 100)}
|
||||
</Typography>{' '}
|
||||
increase vs last month
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
borderRight: { xs: 'none', lg: '1px solid var(--mui-palette-divider)' },
|
||||
borderBottom: { xs: '1px solid var(--mui-palette-divider)', md: 'none' },
|
||||
pb: { xs: 2, md: 0 },
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">New users</Typography>
|
||||
<Typography variant="h3">{new Intl.NumberFormat('en-US').format(2160)}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TrendUpIcon color="var(--mui-palette-success-main)" fontSize="var(--icon-fontSize-md)" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
<Typography color="success.main" component="span" variant="subtitle2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(10 / 100)}
|
||||
</Typography>{' '}
|
||||
increase vs last month
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
borderRight: { xs: 'none', md: '1px solid var(--mui-palette-divider)' },
|
||||
borderBottom: { xs: '1px solid var(--mui-palette-divider)', md: 'none' },
|
||||
pb: { xs: 2, md: 0 },
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Bounce rate</Typography>
|
||||
<Typography variant="h3">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(56.4 / 100)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TrendDownIcon color="var(--mui-palette-error-main)" fontSize="var(--icon-fontSize-md)" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
<Typography color="error.main" component="span" variant="subtitle2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(25 / 100)}
|
||||
</Typography>{' '}
|
||||
decrease vs last month
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Typography color="text.secondary">Avg. engagement time</Typography>
|
||||
<Typography variant="h3">3m 02s</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<TrendDownIcon color="var(--mui-palette-error-main)" fontSize="var(--icon-fontSize-md)" />
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
<Typography color="error.main" component="span" variant="subtitle2">
|
||||
{new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 }).format(15 / 100)}
|
||||
</Typography>{' '}
|
||||
decrease vs last month
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
49
002_source/cms/src/components/dashboard/blog/comment-add.tsx
Normal file
49
002_source/cms/src/components/dashboard/blog/comment-add.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Image as ImageIcon } from '@phosphor-icons/react/dist/ssr/Image';
|
||||
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
import { Smiley as SmileyIcon } from '@phosphor-icons/react/dist/ssr/Smiley';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export function CommentAdd(): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Avatar src={user.avatar} />
|
||||
<Stack spacing={3} sx={{ flex: '1 1 auto' }}>
|
||||
<OutlinedInput multiline placeholder="Add a comment" rows={3} />
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<IconButton sx={{ display: { sm: 'none' } }}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<IconButton sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
|
||||
<ImageIcon />
|
||||
</IconButton>
|
||||
<IconButton sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
|
||||
<PaperclipIcon />
|
||||
</IconButton>
|
||||
<IconButton sx={{ display: { xs: 'none', sm: 'inline-flex' } }}>
|
||||
<SmileyIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<div>
|
||||
<Button variant="contained">Send</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
32
002_source/cms/src/components/dashboard/blog/comment-box.tsx
Normal file
32
002_source/cms/src/components/dashboard/blog/comment-box.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Comment } from './types';
|
||||
|
||||
export interface CommentBoxProps {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export function CommentBox({ comment }: CommentBoxProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Avatar src={comment.author.avatar} />
|
||||
<Box sx={{ bgcolor: 'var(--mui-palette-background-level1)', borderRadius: 1, p: 2, flex: '1 1 auto' }}>
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ alignItems: 'flex-start', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2">{comment.author.name}</Typography>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{dayjs(comment.createdAt).fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">{comment.content}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
27
002_source/cms/src/components/dashboard/blog/content.tsx
Normal file
27
002_source/cms/src/components/dashboard/blog/content.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
|
||||
import { CodeHighlighter } from '@/components/core/code-highlighter';
|
||||
|
||||
const components = { code: CodeHighlighter as Components['code'] } satisfies Components;
|
||||
|
||||
export interface ContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Content({ content }: ContentProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'& h2': { fontWeight: 500, fontSize: '1.5rem', lineHeight: 1.2, mb: 3 },
|
||||
'& h3': { fontWeight: 500, fontSize: '1.25rem', lineHeight: 1.2, mb: 3 },
|
||||
'& p': { fontWeight: 400, fontSize: '1rem', lineHeight: 1.5, mb: 2, mt: 0 },
|
||||
'& li': { mb: 1 },
|
||||
}}
|
||||
>
|
||||
<Markdown components={components}>{content}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
39
002_source/cms/src/components/dashboard/blog/newsletter.tsx
Normal file
39
002_source/cms/src/components/dashboard/blog/newsletter.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
|
||||
export function Newsletter(): React.JSX.Element {
|
||||
return (
|
||||
<Card sx={{ boxShadow: 'var(--mui-shadows-16)', py: 10, px: 8 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid md={6} sx={{ order: { xs: 1, md: 0 } }} xs={12}>
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4">Join the developer list</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Subscribe to our newsletter to make sure you don't miss anything.
|
||||
</Typography>
|
||||
</Stack>
|
||||
<FormControl>
|
||||
<InputLabel>Email address</InputLabel>
|
||||
<OutlinedInput name="email" type="email" />
|
||||
</FormControl>
|
||||
<Button size="large" variant="contained">
|
||||
Subscribe
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid md={6} sx={{ display: 'flex', justifyContent: 'center' }} xs={12}>
|
||||
<Box component="img" src="/assets/iconly-glass-volume.svg" sx={{ maxWidth: '100%', width: '260px' }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
}
|
68
002_source/cms/src/components/dashboard/blog/post-card.tsx
Normal file
68
002_source/cms/src/components/dashboard/blog/post-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Post } from './types';
|
||||
|
||||
export interface PostCardProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export function PostCard({ post }: PostCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card>
|
||||
<CardMedia
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.blog.details('1')}
|
||||
image={post.cover}
|
||||
sx={{ height: '280px' }}
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<div>
|
||||
<Chip label={post.category} />
|
||||
</div>
|
||||
<div>
|
||||
<Link color="text.primary" component={RouterLink} href={paths.dashboard.blog.details('1')} variant="h5">
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
height: '48px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
}}
|
||||
variant="body1"
|
||||
>
|
||||
{post.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Avatar src={post.author.avatar} />
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto', flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ flex: '1 1 auto' }} variant="subtitle2">
|
||||
By {post.author.name} • {dayjs(post.publishedAt).format('MMM D, YYYY')}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="body2">
|
||||
{post.readTime} read
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
150
002_source/cms/src/components/dashboard/blog/post-form.tsx
Normal file
150
002_source/cms/src/components/dashboard/blog/post-form.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Unstable_Grid2';
|
||||
|
||||
import type { File } from '@/components/core/file-dropzone';
|
||||
import { FileDropzone } from '@/components/core/file-dropzone';
|
||||
import { TextEditor } from '@/components/core/text-editor/text-editor';
|
||||
|
||||
export function PostForm(): React.JSX.Element {
|
||||
const [cover, setCover] = React.useState<string | null>('/assets/image-abstract-1.png');
|
||||
|
||||
const handleCoverDrop = React.useCallback(async ([file]: File[]) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
setCover(reader.result as string);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCoverRemove = React.useCallback(() => {
|
||||
setCover(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid md={4} xs={12}>
|
||||
<Typography variant="h6">Basic details</Typography>
|
||||
</Grid>
|
||||
<Grid md={8} xs={12}>
|
||||
<Stack spacing={3}>
|
||||
<FormControl>
|
||||
<InputLabel>Post title</InputLabel>
|
||||
<OutlinedInput name="title" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<InputLabel>Short description</InputLabel>
|
||||
<OutlinedInput name="shortDescription" />
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid md={4} xs={12}>
|
||||
<Typography variant="h6">Post cover</Typography>
|
||||
</Grid>
|
||||
<Grid md={8} xs={12}>
|
||||
<Stack spacing={3}>
|
||||
{cover ? (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundImage: `url(${cover})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
borderRadius: 1,
|
||||
height: '230px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
height: '230px',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="h6">
|
||||
Photo not uploaded
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="subtitle1">
|
||||
Image used for the blog post cover and also for Open Graph meta
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
<div>
|
||||
<Button color="secondary" disabled={!cover} onClick={handleCoverRemove} variant="outlined">
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<FileDropzone
|
||||
accept={{ 'image/*': [] }}
|
||||
caption="(SVG, JPG, PNG, or gif maximum 900x400)"
|
||||
maxFiles={1}
|
||||
onDrop={handleCoverDrop}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid md={4} xs={12}>
|
||||
<Typography variant="h6">Content</Typography>
|
||||
</Grid>
|
||||
<Grid md={8} xs={12}>
|
||||
<Box sx={{ '& .tiptap-container': { height: '320px' } }}>
|
||||
<TextEditor content="" placeholder="Write something" />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={3}>
|
||||
<Grid md={4} xs={12}>
|
||||
<Typography variant="h6">Meta</Typography>
|
||||
</Grid>
|
||||
<Grid lg={8} xs={12}>
|
||||
<Stack spacing={3}>
|
||||
<FormControl>
|
||||
<InputLabel>SEO title</InputLabel>
|
||||
<OutlinedInput name="seoTitle" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<InputLabel>SEO description</InputLabel>
|
||||
<OutlinedInput name="seoDescription" />
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
18
002_source/cms/src/components/dashboard/blog/types.d.ts
vendored
Normal file
18
002_source/cms/src/components/dashboard/blog/types.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
cover: string;
|
||||
category: string;
|
||||
author: { name: string; avatar?: string };
|
||||
readTime: string;
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
author: { name: string; avatar?: string };
|
||||
createdAt: Date;
|
||||
}
|
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { Event, EventPriority } from './types';
|
||||
|
||||
function noop(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface EventCreateParams {
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
priority?: EventPriority;
|
||||
}
|
||||
|
||||
interface EventUpdateParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
priority?: EventPriority;
|
||||
}
|
||||
|
||||
export interface CalendarContextValue {
|
||||
events: Map<string, Event>;
|
||||
currentEventId?: string;
|
||||
setCurrentEventId: (eventId?: string) => void;
|
||||
createEvent: (params: EventCreateParams) => void;
|
||||
deleteEvent: (eventId: string) => void;
|
||||
updateEvent: (eventId: string, params: EventUpdateParams) => void;
|
||||
}
|
||||
|
||||
export const CalendarContext = React.createContext<CalendarContextValue>({
|
||||
events: new Map(),
|
||||
setCurrentEventId: noop,
|
||||
createEvent: noop,
|
||||
deleteEvent: noop,
|
||||
updateEvent: noop,
|
||||
});
|
||||
|
||||
export interface CalendarProviderProps {
|
||||
children: React.ReactNode;
|
||||
events: Event[];
|
||||
}
|
||||
|
||||
export function CalendarProvider({ children, events: initialEvents = [] }: CalendarProviderProps): React.JSX.Element {
|
||||
const [events, setEvents] = React.useState(new Map<string, Event>());
|
||||
const [currentEventId, setCurrentEventId] = React.useState<string>();
|
||||
|
||||
React.useEffect((): void => {
|
||||
setEvents(new Map(initialEvents.map((event) => [event.id, event])));
|
||||
}, [initialEvents]);
|
||||
|
||||
const handleCreateEvent = React.useCallback(
|
||||
(params: EventCreateParams) => {
|
||||
const updatedEvents = new Map<string, Event>(events);
|
||||
|
||||
// Create event
|
||||
const event: Event = { id: `EV-${Date.now()}`, ...params };
|
||||
|
||||
// Add event
|
||||
updatedEvents.set(event.id, event);
|
||||
|
||||
// Dispatch update
|
||||
setEvents(updatedEvents);
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
const handleUpdateEvent = React.useCallback(
|
||||
(eventId: string, params: EventUpdateParams) => {
|
||||
const event = events.get(eventId);
|
||||
|
||||
// Event might no longer exist
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedEvents = new Map<string, Event>(events);
|
||||
|
||||
// Update event
|
||||
updatedEvents.set(eventId, { ...event, ...params });
|
||||
|
||||
// Dispatch update
|
||||
setEvents(updatedEvents);
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
const handleDeleteEvent = React.useCallback(
|
||||
(eventId: string) => {
|
||||
const event = events.get(eventId);
|
||||
|
||||
// Event might no longer exist
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedEvents = new Map<string, Event>(events);
|
||||
|
||||
// Delete event
|
||||
updatedEvents.delete(eventId);
|
||||
|
||||
// Dispatch update
|
||||
setEvents(updatedEvents);
|
||||
},
|
||||
[events]
|
||||
);
|
||||
|
||||
return (
|
||||
<CalendarContext.Provider
|
||||
value={{
|
||||
events,
|
||||
currentEventId,
|
||||
setCurrentEventId,
|
||||
createEvent: handleCreateEvent,
|
||||
deleteEvent: handleDeleteEvent,
|
||||
updateEvent: handleUpdateEvent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
}
|
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { DateSelectArg, EventClickArg, EventDropArg } from '@fullcalendar/core';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import type { EventResizeDoneArg } from '@fullcalendar/interaction';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import listPlugin from '@fullcalendar/list';
|
||||
import Calendar from '@fullcalendar/react';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import timelinePlugin from '@fullcalendar/timeline';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { CaretLeft as CaretLeftIcon } from '@phosphor-icons/react/dist/ssr/CaretLeft';
|
||||
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { useDialog } from '@/hooks/use-dialog';
|
||||
|
||||
import { CalendarContext } from './calendar-context';
|
||||
import { EventContent } from './event-content';
|
||||
import { EventDialog } from './event-dialog';
|
||||
import { Toolbar } from './toolbar';
|
||||
import type { ViewMode } from './types';
|
||||
|
||||
interface CreateDialogData {
|
||||
range?: { start: Date; end: Date };
|
||||
}
|
||||
|
||||
const plugins = [dayGridPlugin, interactionPlugin, listPlugin, timeGridPlugin, timelinePlugin];
|
||||
|
||||
export interface CalendarViewProps {
|
||||
view?: ViewMode;
|
||||
}
|
||||
|
||||
export function CalendarView({ view = 'dayGridMonth' }: CalendarViewProps): React.JSX.Element {
|
||||
const { events, currentEventId, setCurrentEventId, createEvent, deleteEvent, updateEvent } =
|
||||
React.useContext(CalendarContext);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const calendarRef = React.useRef<Calendar | null>(null);
|
||||
|
||||
const [date, setDate] = React.useState<Date>(new Date());
|
||||
|
||||
const createDialog = useDialog<CreateDialogData>();
|
||||
|
||||
const currentEvent = currentEventId ? events.get(currentEventId) : undefined;
|
||||
|
||||
const handleViewChange = React.useCallback(
|
||||
(value: ViewMode) => {
|
||||
const calendarApi = calendarRef.current?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
calendarApi.changeView(value);
|
||||
}
|
||||
|
||||
router.push(`${paths.dashboard.calendar}?view=${value}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDateToday = React.useCallback(() => {
|
||||
const calendarApi = calendarRef.current?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
calendarApi.today();
|
||||
setDate(calendarApi.getDate());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDatePrev = React.useCallback(() => {
|
||||
const calendarApi = calendarRef.current?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
calendarApi.prev();
|
||||
setDate(calendarApi.getDate());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDateNext = React.useCallback(() => {
|
||||
const calendarApi = calendarRef.current?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
calendarApi.next();
|
||||
setDate(calendarApi.getDate());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAdd = React.useCallback(() => {
|
||||
createDialog.handleOpen();
|
||||
}, [createDialog]);
|
||||
|
||||
const handleRangeSelect = React.useCallback(
|
||||
(arg: DateSelectArg) => {
|
||||
const calendarApi = calendarRef.current?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
calendarApi.unselect();
|
||||
}
|
||||
|
||||
createDialog.handleOpen({ range: { start: arg.start, end: arg.end } });
|
||||
},
|
||||
[createDialog]
|
||||
);
|
||||
|
||||
const handleEventSelect = React.useCallback(
|
||||
(arg: EventClickArg) => {
|
||||
setCurrentEventId(arg.event.id);
|
||||
},
|
||||
[setCurrentEventId]
|
||||
);
|
||||
|
||||
const handleEventResize = React.useCallback(
|
||||
(arg: EventResizeDoneArg) => {
|
||||
const { event } = arg;
|
||||
updateEvent(event.id, { allDay: event.allDay, start: event.start!, end: event.end! });
|
||||
},
|
||||
[updateEvent]
|
||||
);
|
||||
|
||||
const handleEventDrop = React.useCallback(
|
||||
(arg: EventDropArg) => {
|
||||
const { event } = arg;
|
||||
updateEvent(event.id, { allDay: event.allDay, start: event.start!, end: event.end! });
|
||||
},
|
||||
[updateEvent]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack spacing={4}>
|
||||
<Toolbar date={date} onAdd={handleAdd} onViewChange={handleViewChange} view={view} />
|
||||
<Card sx={{ overflowX: 'auto' }}>
|
||||
<Box sx={{ minWidth: '800px' }}>
|
||||
<Calendar
|
||||
allDayMaintainDuration
|
||||
dayMaxEventRows={3}
|
||||
droppable
|
||||
editable
|
||||
eventClick={handleEventSelect}
|
||||
eventContent={EventContent}
|
||||
eventDisplay="block"
|
||||
eventDrop={handleEventDrop}
|
||||
eventResizableFromStart
|
||||
eventResize={handleEventResize}
|
||||
events={Array.from(events.values())}
|
||||
headerToolbar={false}
|
||||
height={800}
|
||||
initialDate={date}
|
||||
initialView={view}
|
||||
plugins={plugins}
|
||||
ref={calendarRef}
|
||||
rerenderDelay={10}
|
||||
select={handleRangeSelect}
|
||||
selectable
|
||||
weekends
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<Stack direction="row" spacing={3} sx={{ justifyContent: 'center' }}>
|
||||
<IconButton onClick={handleDatePrev}>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Button color="secondary" onClick={handleDateToday} variant="outlined">
|
||||
Today
|
||||
</Button>
|
||||
<IconButton onClick={handleDateNext}>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{createDialog.open ? (
|
||||
<EventDialog
|
||||
action="create"
|
||||
onClose={createDialog.handleClose}
|
||||
onCreate={(params) => {
|
||||
createEvent(params);
|
||||
createDialog.handleClose();
|
||||
}}
|
||||
open
|
||||
range={createDialog.data?.range}
|
||||
/>
|
||||
) : null}
|
||||
{currentEvent ? (
|
||||
<EventDialog
|
||||
action="update"
|
||||
event={currentEvent}
|
||||
onClose={() => {
|
||||
setCurrentEventId(undefined);
|
||||
}}
|
||||
onCreate={createEvent}
|
||||
onDelete={(eventId) => {
|
||||
setCurrentEventId(undefined);
|
||||
deleteEvent(eventId);
|
||||
}}
|
||||
onUpdate={(eventId, params) => {
|
||||
updateEvent(eventId, params);
|
||||
setCurrentEventId(undefined);
|
||||
}}
|
||||
open
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import type { EventContentArg } from '@fullcalendar/core';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
export interface EventContentProps extends EventContentArg {
|
||||
event: EventContentArg['event'] & { extendedProps: { description?: string; priority?: 'high' | 'medium' | 'low' } };
|
||||
}
|
||||
|
||||
export function EventContent(arg: EventContentProps): React.JSX.Element {
|
||||
const { priority = 'low' } = arg.event.extendedProps;
|
||||
|
||||
const color =
|
||||
priority === 'high'
|
||||
? 'var(--mui-palette-error-main)'
|
||||
: priority === 'medium'
|
||||
? 'var(--mui-palette-warning-main)'
|
||||
: 'transparent';
|
||||
|
||||
const startTime = arg.event.start ? dayjs(arg.event.start).format('h:mm A') : null;
|
||||
const endTime = arg.event.end ? dayjs(arg.event.end).format('h:mm A') : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
boxShadow: 'var(--mui-shadows-1)',
|
||||
borderRadius: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Box sx={{ bgcolor: color, flex: '0 0 auto', width: '4px' }} />
|
||||
<div>
|
||||
{!arg.event.allDay ? (
|
||||
<Typography noWrap variant="body2">
|
||||
{startTime} - {endTime}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography noWrap variant="body2">
|
||||
{arg.event.title}
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
import { logger } from '@/lib/default-logger';
|
||||
import { toast } from '@/components/core/toaster';
|
||||
|
||||
import type { Event, EventPriority } from './types';
|
||||
|
||||
export interface EventCreateParams {
|
||||
title: string;
|
||||
description?: string;
|
||||
allDay: boolean;
|
||||
start: Date;
|
||||
end: Date;
|
||||
priority?: EventPriority;
|
||||
}
|
||||
|
||||
export interface EventUpdateParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
allDay?: boolean;
|
||||
priority?: EventPriority;
|
||||
}
|
||||
|
||||
const schema = zod
|
||||
.object({
|
||||
title: zod.string().min(1, 'Title is required').max(255),
|
||||
description: zod.string().max(5000).optional(),
|
||||
start: zod.date(),
|
||||
end: zod.date(),
|
||||
allDay: zod.boolean(),
|
||||
priority: zod.string().max(255).optional(),
|
||||
})
|
||||
.refine((data) => data.start < data.end, { message: 'End date should be greater than start date', path: ['end'] });
|
||||
|
||||
type Values = zod.infer<typeof schema>;
|
||||
|
||||
function getDefaultValues({ event, range }: { event?: Event; range?: { start: Date; end: Date } }): Values {
|
||||
if (event) {
|
||||
return {
|
||||
title: event.title ?? '',
|
||||
description: event.description ?? '',
|
||||
start: event.start ? event.start : new Date(),
|
||||
end: event.end ? event.end : dayjs().add(30, 'minute').toDate(),
|
||||
allDay: event.allDay ?? false,
|
||||
priority: event.priority ?? 'low',
|
||||
};
|
||||
}
|
||||
|
||||
if (range) {
|
||||
return { title: '', description: '', start: range.start, end: range.end, allDay: false, priority: 'low' };
|
||||
}
|
||||
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
start: new Date(),
|
||||
end: dayjs().add(30, 'minute').toDate(),
|
||||
allDay: false,
|
||||
priority: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
export interface EventDialogProps {
|
||||
action: 'create' | 'update';
|
||||
event?: Event;
|
||||
onClose?: () => void;
|
||||
onCreate?: (params: EventCreateParams) => void;
|
||||
onDelete?: (eventId: string) => void;
|
||||
onUpdate?: (eventId: string, params: EventUpdateParams) => void;
|
||||
open?: boolean;
|
||||
range?: { start: Date; end: Date };
|
||||
}
|
||||
|
||||
export function EventDialog({
|
||||
action = 'create',
|
||||
event,
|
||||
onClose,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
open = false,
|
||||
range,
|
||||
}: EventDialogProps): React.JSX.Element {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({ defaultValues: getDefaultValues({ event, range }), resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (values: Values): Promise<void> => {
|
||||
try {
|
||||
const params = {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
end: values.end,
|
||||
start: values.start,
|
||||
allDay: values.allDay,
|
||||
priority: values.priority as EventPriority,
|
||||
} satisfies EventCreateParams | EventUpdateParams;
|
||||
|
||||
if (action === 'update') {
|
||||
onUpdate?.(event!.id, params);
|
||||
} else {
|
||||
onCreate?.(params);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
toast.error('Something went wrong!');
|
||||
}
|
||||
},
|
||||
[action, event, onCreate, onUpdate]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Reset form when dialog data changes
|
||||
reset(getDefaultValues({ event, range }));
|
||||
}, [event, range, reset]);
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="h5">
|
||||
{event ? 'Edit event' : 'Add event'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={2} sx={{ p: 3 }}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.title)}>
|
||||
<InputLabel>Title</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.title ? <FormHelperText>{errors.title.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormControl error={Boolean(errors.description)}>
|
||||
<InputLabel>Description</InputLabel>
|
||||
<OutlinedInput {...field} />
|
||||
{errors.description ? <FormHelperText>{errors.description.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="allDay"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<FormControlLabel control={<Checkbox {...field} checked={field.value} />} label="All day" />
|
||||
{errors.allDay ? <FormHelperText error>{errors.allDay.message}</FormHelperText> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
format="MMM D, YYYY hh:mm A"
|
||||
label="Start date"
|
||||
onChange={(date) => {
|
||||
field.onChange(date ? date.toDate() : null);
|
||||
}}
|
||||
slotProps={{ textField: { error: Boolean(errors.start), helperText: errors.start?.message } }}
|
||||
value={dayjs(field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="end"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
format="MMM D, YYYY hh:mm A"
|
||||
label="End date"
|
||||
onChange={(date) => {
|
||||
field.onChange(date ? date.toDate() : null);
|
||||
}}
|
||||
slotProps={{ textField: { error: Boolean(errors.end), helperText: errors.end?.message } }}
|
||||
value={dayjs(field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', justifyContent: 'space-between', p: 2 }}>
|
||||
{action === 'update' ? (
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => {
|
||||
onDelete?.(event!.id);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Button color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained">
|
||||
Confirm
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
65
002_source/cms/src/components/dashboard/calendar/toolbar.tsx
Normal file
65
002_source/cms/src/components/dashboard/calendar/toolbar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Select from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
import { Option } from '@/components/core/option';
|
||||
|
||||
import type { ViewMode } from './types';
|
||||
|
||||
export interface ToolbarProps {
|
||||
date: Date;
|
||||
onAdd?: () => void;
|
||||
onViewChange?: (view: ViewMode) => void;
|
||||
view: ViewMode;
|
||||
}
|
||||
|
||||
export function Toolbar({ date, onAdd, onViewChange, view }: ToolbarProps): React.JSX.Element {
|
||||
const handleViewChange = React.useCallback(
|
||||
(event: SelectChangeEvent) => {
|
||||
onViewChange?.(event.target.value as ViewMode);
|
||||
},
|
||||
[onViewChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={3}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="h5">{dayjs(date).format('MMMM')}</Typography>
|
||||
<Typography sx={{ fontWeight: 400 }} variant="h5">
|
||||
{dayjs(date).format('YYYY')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}>
|
||||
<Select
|
||||
name="view"
|
||||
onChange={handleViewChange}
|
||||
sx={{ minWidth: '120px', order: { xs: -1, md: 0 } }}
|
||||
value={view}
|
||||
>
|
||||
<Option value="dayGridMonth">Month</Option>
|
||||
<Option value="timeGridWeek">Week</Option>
|
||||
<Option value="timeGridDay">Day</Option>
|
||||
<Option value="listWeek">Agenda</Option>
|
||||
</Select>
|
||||
<Button onClick={onAdd} startIcon={<PlusIcon />} sx={{ width: { xs: '100%', md: 'auto' } }} variant="contained">
|
||||
New event
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
13
002_source/cms/src/components/dashboard/calendar/types.d.ts
vendored
Normal file
13
002_source/cms/src/components/dashboard/calendar/types.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
priority?: EventPriority;
|
||||
}
|
||||
|
||||
export type EventPriority = 'low' | 'medium' | 'high';
|
||||
|
||||
export type ViewMode = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listWeek';
|
224
002_source/cms/src/components/dashboard/chat/chat-context.tsx
Normal file
224
002_source/cms/src/components/dashboard/chat/chat-context.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { Contact, Message, MessageType, Participant, Thread } from './types';
|
||||
|
||||
function noop(): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type CreateThreadParams = { type: 'direct'; recipientId: string } | { type: 'group'; recipientIds: string[] };
|
||||
|
||||
export interface CreateMessageParams {
|
||||
threadId: string;
|
||||
type: MessageType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatContextValue {
|
||||
contacts: Contact[];
|
||||
threads: Thread[];
|
||||
messages: Map<string, Message[]>;
|
||||
createThread: (params: CreateThreadParams) => string;
|
||||
markAsRead: (threadId: string) => void;
|
||||
createMessage: (params: CreateMessageParams) => void;
|
||||
openDesktopSidebar: boolean;
|
||||
setOpenDesktopSidebar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openMobileSidebar: boolean;
|
||||
setOpenMobileSidebar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const ChatContext = React.createContext<ChatContextValue>({
|
||||
contacts: [],
|
||||
threads: [],
|
||||
messages: new Map(),
|
||||
createThread: noop as () => string,
|
||||
markAsRead: noop,
|
||||
createMessage: noop,
|
||||
openDesktopSidebar: true,
|
||||
setOpenDesktopSidebar: noop,
|
||||
openMobileSidebar: true,
|
||||
setOpenMobileSidebar: noop,
|
||||
});
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: React.ReactNode;
|
||||
contacts: Contact[];
|
||||
threads: Thread[];
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
contacts: initialContacts = [],
|
||||
threads: initialLabels = [],
|
||||
messages: initialMessages = [],
|
||||
}: ChatProviderProps): React.JSX.Element {
|
||||
const [contacts, setContacts] = React.useState<Contact[]>([]);
|
||||
const [threads, setThreads] = React.useState<Thread[]>([]);
|
||||
const [messages, setMessages] = React.useState<Map<string, Message[]>>(new Map());
|
||||
const [openDesktopSidebar, setOpenDesktopSidebar] = React.useState<boolean>(true);
|
||||
const [openMobileSidebar, setOpenMobileSidebar] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setContacts(initialContacts);
|
||||
}, [initialContacts]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setThreads(initialLabels);
|
||||
}, [initialLabels]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
setMessages(
|
||||
initialMessages.reduce((acc, curr) => {
|
||||
const byThread = acc.get(curr.threadId) ?? [];
|
||||
// We unshift the message to ensure the messages are sorted by date
|
||||
byThread.unshift(curr);
|
||||
acc.set(curr.threadId, byThread);
|
||||
return acc;
|
||||
}, new Map<string, Message[]>())
|
||||
);
|
||||
}, [initialMessages]);
|
||||
|
||||
const handleCreateThread = React.useCallback(
|
||||
(params: CreateThreadParams): string => {
|
||||
// Authenticated user
|
||||
const userId = 'USR-000';
|
||||
|
||||
// Check if the thread already exists
|
||||
let thread = threads.find((thread) => {
|
||||
if (params.type === 'direct') {
|
||||
if (thread.type !== 'direct') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return thread.participants
|
||||
.filter((participant) => participant.id !== userId)
|
||||
.find((participant) => participant.id === params.recipientId);
|
||||
}
|
||||
|
||||
if (thread.type !== 'group') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recipientIds = thread.participants
|
||||
.filter((participant) => participant.id !== userId)
|
||||
.map((participant) => participant.id);
|
||||
|
||||
if (params.recipientIds.length !== recipientIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return params.recipientIds.every((recipientId) => recipientIds.includes(recipientId));
|
||||
});
|
||||
|
||||
if (thread) {
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
// Create a new thread
|
||||
|
||||
const participants: Participant[] = [{ id: 'USR-000', name: 'Sofia Rivers', avatar: '/assets/avatar.png' }];
|
||||
|
||||
if (params.type === 'direct') {
|
||||
const contact = contacts.find((contact) => contact.id === params.recipientId);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(`Contact with id "${params.recipientId}" not found`);
|
||||
}
|
||||
|
||||
participants.push({ id: contact.id, name: contact.name, avatar: contact.avatar });
|
||||
} else {
|
||||
params.recipientIds.forEach((recipientId) => {
|
||||
const contact = contacts.find((contact) => contact.id === recipientId);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(`Contact with id "${recipientId}" not found`);
|
||||
}
|
||||
|
||||
participants.push({ id: contact.id, name: contact.name, avatar: contact.avatar });
|
||||
});
|
||||
}
|
||||
|
||||
thread = { id: `TRD-${Date.now()}`, type: params.type, participants, unreadCount: 0 } satisfies Thread;
|
||||
|
||||
// Add it to the threads
|
||||
const updatedThreads = [thread, ...threads];
|
||||
|
||||
// Dispatch threads update
|
||||
setThreads(updatedThreads);
|
||||
|
||||
return thread.id;
|
||||
},
|
||||
[contacts, threads]
|
||||
);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(
|
||||
(threadId: string) => {
|
||||
const thread = threads.find((thread) => thread.id === threadId);
|
||||
|
||||
if (!thread) {
|
||||
// Thread might no longer exist
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedThreads = threads.map((threadToUpdate) => {
|
||||
if (threadToUpdate.id !== threadId) {
|
||||
return threadToUpdate;
|
||||
}
|
||||
|
||||
return { ...threadToUpdate, unreadCount: 0 };
|
||||
});
|
||||
|
||||
// Dispatch threads update
|
||||
setThreads(updatedThreads);
|
||||
},
|
||||
[threads]
|
||||
);
|
||||
|
||||
const handleCreateMessage = React.useCallback(
|
||||
(params: CreateMessageParams): void => {
|
||||
const message = {
|
||||
id: `MSG-${Date.now()}`,
|
||||
threadId: params.threadId,
|
||||
type: params.type,
|
||||
author: { id: 'USR-000', name: 'Sofia Rivers', avatar: '/assets/avatar.png' },
|
||||
content: params.content,
|
||||
createdAt: new Date(),
|
||||
} satisfies Message;
|
||||
|
||||
const updatedMessages = new Map<string, Message[]>(messages);
|
||||
|
||||
// Add it to the messages
|
||||
if (!updatedMessages.has(params.threadId)) {
|
||||
updatedMessages.set(params.threadId, [message]);
|
||||
} else {
|
||||
updatedMessages.set(params.threadId, [...updatedMessages.get(params.threadId)!, message]);
|
||||
}
|
||||
|
||||
// Dispatch messages update
|
||||
setMessages(updatedMessages);
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
contacts,
|
||||
threads,
|
||||
messages,
|
||||
createThread: handleCreateThread,
|
||||
markAsRead: handleMarkAsRead,
|
||||
createMessage: handleCreateMessage,
|
||||
openDesktopSidebar,
|
||||
setOpenDesktopSidebar,
|
||||
openMobileSidebar,
|
||||
setOpenMobileSidebar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
90
002_source/cms/src/components/dashboard/chat/chat-view.tsx
Normal file
90
002_source/cms/src/components/dashboard/chat/chat-view.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
import { ChatContext } from './chat-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
export interface ChatViewProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ChatView({ children }: ChatViewProps): React.JSX.Element {
|
||||
const {
|
||||
contacts,
|
||||
threads,
|
||||
messages,
|
||||
createThread,
|
||||
openDesktopSidebar,
|
||||
setOpenDesktopSidebar,
|
||||
openMobileSidebar,
|
||||
setOpenMobileSidebar,
|
||||
} = React.useContext(ChatContext);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
// The layout does not have a direct access to the current thread id param, we need to extract it from the pathname.
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const currentThreadId = segments.length === 4 ? segments[segments.length - 1] : undefined;
|
||||
|
||||
const mdDown = useMediaQuery('down', 'md');
|
||||
|
||||
const handleContactSelect = React.useCallback(
|
||||
(contactId: string) => {
|
||||
const threadId = createThread({ type: 'direct', recipientId: contactId });
|
||||
|
||||
router.push(paths.dashboard.chat.thread('direct', threadId));
|
||||
},
|
||||
[router, createThread]
|
||||
);
|
||||
|
||||
const handleThreadSelect = React.useCallback(
|
||||
(threadType: string, threadId: string) => {
|
||||
router.push(paths.dashboard.chat.thread(threadType, threadId));
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: '1 1 0', minHeight: 0 }}>
|
||||
<Sidebar
|
||||
contacts={contacts}
|
||||
currentThreadId={currentThreadId}
|
||||
messages={messages}
|
||||
onCloseMobile={() => {
|
||||
setOpenMobileSidebar(false);
|
||||
}}
|
||||
onSelectContact={handleContactSelect}
|
||||
onSelectThread={handleThreadSelect}
|
||||
openDesktop={openDesktopSidebar}
|
||||
openMobile={openMobileSidebar}
|
||||
threads={threads}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: '1px solid var(--mui-palette-divider)', display: 'flex', flex: '0 0 auto', p: 2 }}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (mdDown) {
|
||||
setOpenMobileSidebar((prev) => !prev);
|
||||
} else {
|
||||
setOpenDesktopSidebar((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
|
||||
import { ChatContext } from './chat-context';
|
||||
import { GroupRecipients } from './group-recipients';
|
||||
import { MessageAdd } from './message-add';
|
||||
import type { Contact, MessageType } from './types';
|
||||
|
||||
function useRecipients(): {
|
||||
handleRecipientAdd: (contact: Contact) => void;
|
||||
handleRecipientRemove: (contactId: string) => void;
|
||||
recipients: Contact[];
|
||||
} {
|
||||
const [recipients, setRecipients] = React.useState<Contact[]>([]);
|
||||
|
||||
const handleRecipientAdd = React.useCallback((recipient: Contact) => {
|
||||
setRecipients((prevState) => {
|
||||
const found = prevState.find((_recipient) => _recipient.id === recipient.id);
|
||||
|
||||
if (found) {
|
||||
return prevState;
|
||||
}
|
||||
|
||||
return [...prevState, recipient];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRecipientRemove = React.useCallback((recipientId: string) => {
|
||||
setRecipients((prevState) => {
|
||||
return prevState.filter((recipient) => recipient.id !== recipientId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { handleRecipientAdd, handleRecipientRemove, recipients };
|
||||
}
|
||||
|
||||
export function ComposeView(): React.JSX.Element {
|
||||
const router = useRouter();
|
||||
|
||||
const { contacts, createThread, createMessage } = React.useContext(ChatContext);
|
||||
|
||||
const { handleRecipientAdd, handleRecipientRemove, recipients } = useRecipients();
|
||||
|
||||
const handleSendMessage = React.useCallback(
|
||||
async (type: MessageType, content: string) => {
|
||||
const recipientIds = recipients.map((recipient) => recipient.id);
|
||||
|
||||
const threadId = createThread({ type: 'group', recipientIds });
|
||||
|
||||
createMessage({ threadId, type, content });
|
||||
|
||||
router.push(paths.dashboard.chat.thread('group', threadId));
|
||||
},
|
||||
[router, createThread, createMessage, recipients]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
|
||||
<GroupRecipients
|
||||
contacts={contacts}
|
||||
onRecipientAdd={handleRecipientAdd}
|
||||
onRecipientRemove={handleRecipientRemove}
|
||||
recipients={recipients}
|
||||
/>
|
||||
<Divider />
|
||||
<Box sx={{ flex: '1 1 auto' }} />
|
||||
<Divider />
|
||||
<MessageAdd disabled={recipients.length < 1} onSend={handleSendMessage} />
|
||||
</Box>
|
||||
);
|
||||
}
|
115
002_source/cms/src/components/dashboard/chat/direct-search.tsx
Normal file
115
002_source/cms/src/components/dashboard/chat/direct-search.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { Tip } from '@/components/core/tip';
|
||||
|
||||
import type { Contact } from './types';
|
||||
|
||||
export interface DirectSearchProps {
|
||||
isFocused?: boolean;
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClickAway?: () => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelect?: (result: Contact) => void;
|
||||
query?: string;
|
||||
results?: Contact[];
|
||||
}
|
||||
|
||||
export const DirectSearch = React.forwardRef<HTMLDivElement, DirectSearchProps>(function ChatSidebarSearch(
|
||||
{
|
||||
isFocused,
|
||||
onChange,
|
||||
onClickAway = () => {
|
||||
// noop
|
||||
},
|
||||
onFocus,
|
||||
onSelect,
|
||||
query = '',
|
||||
results = [],
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const handleSelect = React.useCallback(
|
||||
(result: Contact) => {
|
||||
onSelect?.(result);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
const showTip = isFocused && !query;
|
||||
const showResults = isFocused && query;
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={onClickAway}>
|
||||
<Stack ref={ref} spacing={2} tabIndex={-1}>
|
||||
<OutlinedInput
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
placeholder="Search contacts"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</InputAdornment>
|
||||
}
|
||||
value={query}
|
||||
/>
|
||||
{showTip ? <Tip message="Enter a contact name" /> : null}
|
||||
{showResults ? (
|
||||
<React.Fragment>
|
||||
{hasResults ? (
|
||||
<Stack spacing={1}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
Contacts
|
||||
</Typography>
|
||||
<List disablePadding sx={{ '& .MuiListItemButton-root': { borderRadius: 1 } }}>
|
||||
{results.map((contact) => (
|
||||
<ListItem disablePadding key={contact.id}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
handleSelect(contact);
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={contact.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
primary={
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{contact.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
) : (
|
||||
<div>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
We couldn't find any matches for "{query}". Try checking for typos or using complete
|
||||
words.
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</Stack>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
});
|
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import type { Contact } from './types';
|
||||
|
||||
export interface GroupRecipientsProps {
|
||||
contacts: Contact[];
|
||||
onRecipientAdd?: (contact: Contact) => void;
|
||||
onRecipientRemove?: (recipientId: string) => void;
|
||||
recipients?: Contact[];
|
||||
}
|
||||
|
||||
export function GroupRecipients({
|
||||
contacts,
|
||||
onRecipientAdd,
|
||||
onRecipientRemove,
|
||||
recipients = [],
|
||||
}: GroupRecipientsProps): React.JSX.Element {
|
||||
const searchRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [searchFocused, setSearchFocused] = React.useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
||||
const [searchResults, setSearchResults] = React.useState<Contact[]>([]);
|
||||
|
||||
const showSearchResults = searchFocused && Boolean(searchQuery);
|
||||
const hasSearchResults = searchResults.length > 0;
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const query = event.target.value;
|
||||
|
||||
setSearchQuery(query);
|
||||
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// This is where you would make an API request for a real search. For the sake of simplicity, we are just
|
||||
// filtering the data in the client.
|
||||
const results = contacts.filter((contact) => {
|
||||
// Filter already picked recipients
|
||||
if (recipients.find((recipient) => recipient.id === contact.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return contact.name.toLowerCase().includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
},
|
||||
[contacts, recipients]
|
||||
);
|
||||
|
||||
const handleSearchClickAway = React.useCallback(() => {
|
||||
if (showSearchResults) {
|
||||
setSearchFocused(false);
|
||||
}
|
||||
}, [showSearchResults]);
|
||||
|
||||
const handleSearchFocus = React.useCallback(() => {
|
||||
setSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchSelect = React.useCallback(
|
||||
(contact: Contact) => {
|
||||
setSearchQuery('');
|
||||
onRecipientAdd?.(contact);
|
||||
},
|
||||
[onRecipientAdd]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', overflowX: 'auto', p: 2 }}>
|
||||
<ClickAwayListener onClickAway={handleSearchClickAway}>
|
||||
<div>
|
||||
<OutlinedInput
|
||||
onChange={handleSearchChange}
|
||||
onFocus={handleSearchFocus}
|
||||
placeholder="Search contacts"
|
||||
ref={searchRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{ minWidth: '260px' }}
|
||||
value={searchQuery}
|
||||
/>
|
||||
{showSearchResults ? (
|
||||
<Popper anchorEl={searchRef.current} open={searchFocused} placement="bottom-start">
|
||||
<Paper
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
boxShadow: 'var(--mui-shadows-16)',
|
||||
maxWidth: '100%',
|
||||
mt: 1,
|
||||
width: '320px',
|
||||
}}
|
||||
>
|
||||
{hasSearchResults ? (
|
||||
<React.Fragment>
|
||||
<Box sx={{ px: 3, py: 2 }}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
Contacts
|
||||
</Typography>
|
||||
</Box>
|
||||
<List sx={{ p: 1, '& .MuiListItemButton-root': { borderRadius: 1 } }}>
|
||||
{searchResults.map((contact) => (
|
||||
<ListItem disablePadding key={contact.id}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
handleSearchSelect(contact);
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={contact.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
primary={
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{contact.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Stack spacing={1} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h6">Nothing found</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
We couldn't find any matches for "{searchQuery}". Try checking for typos or using
|
||||
complete words.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Popper>
|
||||
) : null}
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
To:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', overflowX: 'auto' }}>
|
||||
{recipients.map((recipient) => (
|
||||
<Chip
|
||||
avatar={<Avatar src={recipient.avatar} />}
|
||||
key={recipient.id}
|
||||
label={recipient.name}
|
||||
onDelete={() => {
|
||||
onRecipientRemove?.(recipient.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
107
002_source/cms/src/components/dashboard/chat/message-add.tsx
Normal file
107
002_source/cms/src/components/dashboard/chat/message-add.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
|
||||
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
|
||||
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
import type { MessageType } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface MessageAddProps {
|
||||
disabled?: boolean;
|
||||
onSend?: (type: MessageType, content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageAdd({ disabled = false, onSend }: MessageAddProps): React.JSX.Element {
|
||||
const [content, setContent] = React.useState<string>('');
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleAttach = React.useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContent(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSend = React.useCallback(() => {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSend?.('text', content);
|
||||
setContent('');
|
||||
}, [content, onSend]);
|
||||
|
||||
const handleKeyUp = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.code === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '0 0 auto', px: 3, py: 1 }}>
|
||||
<Avatar src={user.avatar} sx={{ display: { xs: 'none', sm: 'inline' } }} />
|
||||
<OutlinedInput
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder="Leave a message"
|
||||
sx={{ flex: '1 1 auto' }}
|
||||
value={content}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Tooltip title="Send">
|
||||
<span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={!content || disabled}
|
||||
onClick={handleSend}
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
'&:hover': { bgcolor: 'var(--mui-palette-primary-dark)' },
|
||||
}}
|
||||
>
|
||||
<PaperPlaneTiltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', sm: 'flex' } }}>
|
||||
<Tooltip title="Attach photo">
|
||||
<span>
|
||||
<IconButton disabled={disabled} edge="end" onClick={handleAttach}>
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Attach file">
|
||||
<span>
|
||||
<IconButton disabled={disabled} edge="end" onClick={handleAttach}>
|
||||
<PaperclipIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<input hidden ref={fileInputRef} type="file" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
84
002_source/cms/src/components/dashboard/chat/message-box.tsx
Normal file
84
002_source/cms/src/components/dashboard/chat/message-box.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Message } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface MessageBoxProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBox({ message }: MessageBoxProps): React.JSX.Element {
|
||||
const position = message.author.id === user.id ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<Box sx={{ alignItems: position === 'right' ? 'flex-end' : 'flex-start', flex: '0 0 auto', display: 'flex' }}>
|
||||
<Stack
|
||||
direction={position === 'right' ? 'row-reverse' : 'row'}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
maxWidth: '500px',
|
||||
ml: position === 'right' ? 'auto' : 0,
|
||||
mr: position === 'left' ? 'auto' : 0,
|
||||
}}
|
||||
>
|
||||
<Avatar src={message.author.avatar} sx={{ '--Avatar-size': '32px' }} />
|
||||
<Stack spacing={1} sx={{ flex: '1 1 auto' }}>
|
||||
<Card
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
...(position === 'right' && {
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<Link color="inherit" sx={{ cursor: 'pointer' }} variant="subtitle2">
|
||||
{message.author.name}
|
||||
</Link>
|
||||
</div>
|
||||
{message.type === 'image' ? (
|
||||
<CardMedia
|
||||
image={message.content}
|
||||
onClick={() => {
|
||||
// open modal
|
||||
}}
|
||||
sx={{ height: '200px', width: '200px' }}
|
||||
/>
|
||||
) : null}
|
||||
{message.type === 'text' ? (
|
||||
<Typography color="inherit" variant="body1">
|
||||
{message.content}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Box sx={{ display: 'flex', justifyContent: position === 'right' ? 'flex-end' : 'flex-start', px: 2 }}>
|
||||
<Typography color="text.secondary" noWrap variant="caption">
|
||||
{dayjs(message.createdAt).fromNow()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
218
002_source/cms/src/components/dashboard/chat/sidebar.tsx
Normal file
218
002_source/cms/src/components/dashboard/chat/sidebar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
|
||||
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
import { DirectSearch } from './direct-search';
|
||||
import { ThreadItem } from './thread-item';
|
||||
import type { Contact, Message, Thread } from './types';
|
||||
|
||||
export interface SidebarProps {
|
||||
contacts: Contact[];
|
||||
currentThreadId?: string;
|
||||
messages: Map<string, Message[]>;
|
||||
onCloseMobile?: () => void;
|
||||
onSelectContact?: (contactId: string) => void;
|
||||
onSelectThread?: (threadType: string, threadId: string) => void;
|
||||
openDesktop?: boolean;
|
||||
openMobile?: boolean;
|
||||
threads: Thread[];
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
contacts,
|
||||
currentThreadId,
|
||||
messages,
|
||||
onCloseMobile,
|
||||
onSelectContact,
|
||||
onSelectThread,
|
||||
openDesktop,
|
||||
openMobile,
|
||||
threads,
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const mdUp = useMediaQuery('up', 'md');
|
||||
|
||||
const content = (
|
||||
<SidebarContent
|
||||
closeOnGroupClick={!mdUp}
|
||||
closeOnThreadSelect={!mdUp}
|
||||
contacts={contacts}
|
||||
currentThreadId={currentThreadId}
|
||||
messages={messages}
|
||||
onClose={onCloseMobile}
|
||||
onSelectContact={onSelectContact}
|
||||
onSelectThread={onSelectThread}
|
||||
threads={threads}
|
||||
/>
|
||||
);
|
||||
|
||||
if (mdUp) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRight: '1px solid var(--mui-palette-divider)',
|
||||
flex: '0 0 auto',
|
||||
ml: openDesktop ? 0 : '-320px',
|
||||
position: 'relative',
|
||||
transition: 'margin 225ms cubic-bezier(0.0, 0, 0.2, 1) 0ms',
|
||||
width: '320px',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer PaperProps={{ sx: { maxWidth: '100%', width: '320px' } }} onClose={onCloseMobile} open={openMobile}>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
closeOnGroupClick?: boolean;
|
||||
closeOnThreadSelect?: boolean;
|
||||
contacts: Contact[];
|
||||
currentThreadId?: string;
|
||||
messages: Map<string, Message[]>;
|
||||
onClose?: () => void;
|
||||
onSelectContact?: (contactId: string) => void;
|
||||
onSelectThread?: (threadType: string, threadId: string) => void;
|
||||
threads: Thread[];
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
closeOnGroupClick,
|
||||
closeOnThreadSelect,
|
||||
contacts,
|
||||
currentThreadId,
|
||||
messages,
|
||||
onClose,
|
||||
onSelectContact,
|
||||
onSelectThread,
|
||||
threads,
|
||||
}: SidebarContentProps): React.JSX.Element {
|
||||
// If you want to persist the search states, you can move it to the Sidebar component or a context.
|
||||
// Otherwise, the search states will be reset when the window size changes between mobile and desktop.
|
||||
const [searchFocused, setSearchFocused] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
||||
const [searchResults, setSearchResults] = React.useState<Contact[]>([]);
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const { value } = event.target;
|
||||
|
||||
setSearchQuery(value);
|
||||
|
||||
if (!value) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = contacts.filter((contact) => {
|
||||
return contact.name.toLowerCase().includes(value.toLowerCase());
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
},
|
||||
[contacts]
|
||||
);
|
||||
|
||||
const handleSearchClickAway = React.useCallback(() => {
|
||||
if (searchFocused) {
|
||||
setSearchFocused(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [searchFocused]);
|
||||
|
||||
const handleSearchFocus = React.useCallback(() => {
|
||||
setSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchSelect = React.useCallback(
|
||||
(contact: Contact) => {
|
||||
onSelectContact?.(contact.id);
|
||||
|
||||
setSearchFocused(false);
|
||||
setSearchQuery('');
|
||||
},
|
||||
[onSelectContact]
|
||||
);
|
||||
|
||||
const handleThreadSelect = React.useCallback(
|
||||
(threadType: string, threadId: string) => {
|
||||
onSelectThread?.(threadType, threadId);
|
||||
|
||||
if (closeOnThreadSelect) {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onSelectThread, onClose, closeOnThreadSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '0 0 auto', p: 2 }}>
|
||||
<Typography sx={{ flex: '1 1 auto' }} variant="h5">
|
||||
Chats
|
||||
</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.chat.compose}
|
||||
onClick={() => {
|
||||
if (closeOnGroupClick) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
startIcon={<PlusIcon />}
|
||||
variant="contained"
|
||||
>
|
||||
Group
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ display: { md: 'none' } }}>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack spacing={2} sx={{ flex: '1 1 auto', overflowY: 'auto', p: 2 }}>
|
||||
<DirectSearch
|
||||
isFocused={searchFocused}
|
||||
onChange={handleSearchChange}
|
||||
onClickAway={handleSearchClickAway}
|
||||
onFocus={handleSearchFocus}
|
||||
onSelect={handleSearchSelect}
|
||||
query={searchQuery}
|
||||
results={searchResults}
|
||||
/>
|
||||
<Stack
|
||||
component="ul"
|
||||
spacing={1}
|
||||
sx={{ display: searchFocused ? 'none' : 'flex', listStyle: 'none', m: 0, p: 0 }}
|
||||
>
|
||||
{threads.map((thread) => (
|
||||
<ThreadItem
|
||||
active={currentThreadId === thread.id}
|
||||
key={thread.id}
|
||||
messages={messages.get(thread.id) ?? []}
|
||||
onSelect={() => {
|
||||
handleThreadSelect(thread.type, thread.id);
|
||||
}}
|
||||
thread={thread}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
110
002_source/cms/src/components/dashboard/chat/thread-item.tsx
Normal file
110
002_source/cms/src/components/dashboard/chat/thread-item.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Message, Thread } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
function getDisplayContent(lastMessage: Message, userId: string): string {
|
||||
const author = lastMessage.author.id === userId ? 'Me: ' : '';
|
||||
const message = lastMessage.type === 'image' ? 'Sent a photo' : lastMessage.content;
|
||||
|
||||
return `${author}${message}`;
|
||||
}
|
||||
|
||||
export interface ThreadItemProps {
|
||||
active?: boolean;
|
||||
onSelect?: () => void;
|
||||
thread: Thread;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function ThreadItem({ active = false, thread, messages, onSelect }: ThreadItemProps): React.JSX.Element {
|
||||
const recipients = (thread.participants ?? []).filter((participant) => participant.id !== user.id);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
return (
|
||||
<Box component="li" sx={{ userSelect: 'none' }}>
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onSelect?.();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flex: '0 0 auto',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
...(active && { bgcolor: 'var(--mui-palette-action-selected)' }),
|
||||
'&:hover': { ...(!active && { bgcolor: 'var(--mui-palette-action-hover)' }) },
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
<AvatarGroup
|
||||
max={2}
|
||||
sx={{
|
||||
'& .MuiAvatar-root': {
|
||||
fontSize: 'var(--fontSize-xs)',
|
||||
...(thread.type === 'group'
|
||||
? { height: '24px', ml: '-16px', width: '24px', '&:nth-of-type(2)': { mt: '12px' } }
|
||||
: { height: '36px', width: '36px' }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<Avatar key={recipient.id} src={recipient.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
<Box sx={{ flex: '1 1 auto', overflow: 'hidden' }}>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{recipients.map((recipient) => recipient.name).join(', ')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
{(thread.unreadCount ?? 0) > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
borderRadius: '50%',
|
||||
flex: '0 0 auto',
|
||||
height: '8px',
|
||||
width: '8px',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{lastMessage ? (
|
||||
<Typography color="text.secondary" noWrap sx={{ flex: '1 1 auto' }} variant="subtitle2">
|
||||
{getDisplayContent(lastMessage, user.id)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
{lastMessage ? (
|
||||
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="caption">
|
||||
{dayjs(lastMessage.createdAt).fromNow()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
125
002_source/cms/src/components/dashboard/chat/thread-toolbar.tsx
Normal file
125
002_source/cms/src/components/dashboard/chat/thread-toolbar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Archive as ArchiveIcon } from '@phosphor-icons/react/dist/ssr/Archive';
|
||||
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
|
||||
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { Phone as PhoneIcon } from '@phosphor-icons/react/dist/ssr/Phone';
|
||||
import { Prohibit as ProhibitIcon } from '@phosphor-icons/react/dist/ssr/Prohibit';
|
||||
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
import type { Thread } from './types';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export interface ThreadToolbarProps {
|
||||
thread: Thread;
|
||||
}
|
||||
|
||||
export function ThreadToolbar({ thread }: ThreadToolbarProps): React.JSX.Element {
|
||||
const popover = usePopover<HTMLButtonElement>();
|
||||
|
||||
const recipients = (thread.participants ?? []).filter((participant) => participant.id !== user.id);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--mui-palette-divider)',
|
||||
flex: '0 0 auto',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '64px',
|
||||
px: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', minWidth: 0 }}>
|
||||
<AvatarGroup
|
||||
max={2}
|
||||
sx={{
|
||||
'& .MuiAvatar-root': {
|
||||
fontSize: 'var(--fontSize-xs)',
|
||||
...(thread.type === 'group'
|
||||
? { height: '24px', ml: '-16px', width: '24px', '&:nth-of-type(2)': { mt: '12px' } }
|
||||
: { height: '36px', width: '36px' }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{recipients.map((recipient) => (
|
||||
<Avatar key={recipient.id} src={recipient.avatar} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{recipients.map((recipient) => recipient.name).join(', ')}
|
||||
</Typography>
|
||||
{thread.type === 'direct' ? (
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Recently active
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<IconButton>
|
||||
<PhoneIcon />
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
<Tooltip title="More options">
|
||||
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Menu anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open}>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<ProhibitIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Block</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<TrashIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Delete</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<ArchiveIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Archive</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<BellIcon />
|
||||
</ListItemIcon>
|
||||
<Typography>Mute</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user