Files
HKSingleParty/03_source/frontend/src/sections/blog/post-new-edit-form.tsx
2025-05-28 09:55:51 +08:00

290 lines
8.2 KiB
TypeScript

import type { IPostItem } from 'src/types/blog';
import { z as zod } from 'zod';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useBoolean } from 'minimal-shared/hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Card from '@mui/material/Card';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Switch from '@mui/material/Switch';
import Divider from '@mui/material/Divider';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import CardHeader from '@mui/material/CardHeader';
import Typography from '@mui/material/Typography';
import FormControlLabel from '@mui/material/FormControlLabel';
import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks';
import { _tags } from 'src/_mock';
import { toast } from 'src/components/snackbar';
import { Iconify } from 'src/components/iconify';
import { Form, Field, schemaHelper } from 'src/components/hook-form';
import { PostDetailsPreview } from './post-details-preview';
// ----------------------------------------------------------------------
export type NewPostSchemaType = zod.infer<typeof NewPostSchema>;
export const NewPostSchema = zod.object({
title: zod.string().min(1, { message: 'Title is required!' }),
description: zod.string().min(1, { message: 'Description is required!' }),
content: schemaHelper
.editor()
.min(100, { message: 'Content must be at least 100 characters' })
.max(500, { message: 'Content must be less than 500 characters' }),
coverUrl: schemaHelper.file({ message: 'Cover is required!' }),
tags: zod.string().array().min(2, { message: 'Must have at least 2 items!' }),
metaKeywords: zod.string().array().min(1, { message: 'Meta keywords is required!' }),
// Not required
metaTitle: zod.string(),
metaDescription: zod.string(),
});
// ----------------------------------------------------------------------
type Props = {
currentPost?: IPostItem;
};
export function PostNewEditForm({ currentPost }: Props) {
const router = useRouter();
const showPreview = useBoolean();
const openDetails = useBoolean(true);
const openProperties = useBoolean(true);
const defaultValues: NewPostSchemaType = {
title: '',
description: '',
content: '',
coverUrl: null,
tags: [],
metaKeywords: [],
metaTitle: '',
metaDescription: '',
};
const methods = useForm<NewPostSchemaType>({
mode: 'all',
resolver: zodResolver(NewPostSchema),
defaultValues,
values: currentPost,
});
const {
reset,
watch,
setValue,
handleSubmit,
formState: { isSubmitting, isValid },
} = methods;
const values = watch();
const onSubmit = handleSubmit(async (data) => {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
reset();
showPreview.onFalse();
toast.success(currentPost ? 'Update success!' : 'Create success!');
router.push(paths.dashboard.post.root);
console.info('DATA', data);
} catch (error) {
console.error(error);
}
});
const handleRemoveFile = useCallback(() => {
setValue('coverUrl', null);
}, [setValue]);
const renderCollapseButton = (value: boolean, onToggle: () => void) => (
<IconButton onClick={onToggle}>
<Iconify icon={value ? 'eva:arrow-ios-downward-fill' : 'eva:arrow-ios-forward-fill'} />
</IconButton>
);
const renderDetails = () => (
<Card>
<CardHeader
title="Details"
subheader="Title, short description, image..."
action={renderCollapseButton(openDetails.value, openDetails.onToggle)}
sx={{ mb: 3 }}
/>
<Collapse in={openDetails.value}>
<Divider />
<Stack spacing={3} sx={{ p: 3 }}>
<Field.Text name="title" label="Post title" />
<Field.Text name="description" label="Description" multiline rows={3} />
<Stack spacing={1.5}>
<Typography variant="subtitle2">Content</Typography>
<Field.Editor name="content" sx={{ maxHeight: 480 }} />
</Stack>
<Stack spacing={1.5}>
<Typography variant="subtitle2">Cover</Typography>
<Field.Upload name="coverUrl" maxSize={3145728} onDelete={handleRemoveFile} />
</Stack>
</Stack>
</Collapse>
</Card>
);
const renderProperties = () => (
<Card>
<CardHeader
title="Properties"
subheader="Additional functions and attributes..."
action={renderCollapseButton(openProperties.value, openProperties.onToggle)}
sx={{ mb: 3 }}
/>
<Collapse in={openProperties.value}>
<Divider />
<Stack spacing={3} sx={{ p: 3 }}>
<Field.Autocomplete
name="tags"
label="Tags"
placeholder="+ Tags"
multiple
freeSolo
disableCloseOnSelect
options={_tags.map((option) => option)}
getOptionLabel={(option) => option}
renderOption={(props, option) => (
<li {...props} key={option}>
{option}
</li>
)}
renderTags={(selected, getTagProps) =>
selected.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
color="info"
variant="soft"
/>
))
}
/>
<Field.Text name="metaTitle" label="Meta title" />
<Field.Text
name="metaDescription"
label="Meta description"
fullWidth
multiline
rows={3}
/>
<Field.Autocomplete
name="metaKeywords"
label="Meta keywords"
placeholder="+ Keywords"
multiple
freeSolo
disableCloseOnSelect
options={_tags.map((option) => option)}
getOptionLabel={(option) => option}
renderOption={(props, option) => (
<li {...props} key={option}>
{option}
</li>
)}
renderTags={(selected, getTagProps) =>
selected.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
color="info"
variant="soft"
/>
))
}
/>
<FormControlLabel
label="Enable comments"
control={<Switch defaultChecked slotProps={{ input: { id: 'comments-switch' } }} />}
/>
</Stack>
</Collapse>
</Card>
);
const renderActions = () => (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<FormControlLabel
label="Publish"
control={<Switch defaultChecked slotProps={{ input: { id: 'publish-switch' } }} />}
sx={{ pl: 3, flexGrow: 1 }}
/>
<div>
<Button color="inherit" variant="outlined" size="large" onClick={showPreview.onTrue}>
Preview
</Button>
<Button
type="submit"
variant="contained"
size="large"
loading={isSubmitting}
sx={{ ml: 2 }}
>
{!currentPost ? 'Create post' : 'Save changes'}
</Button>
</div>
</Box>
);
return (
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={5} sx={{ mx: 'auto', maxWidth: { xs: 720, xl: 880 } }}>
{renderDetails()}
{renderProperties()}
{renderActions()}
</Stack>
<PostDetailsPreview
isValid={isValid}
onSubmit={onSubmit}
title={values.title}
open={showPreview.value}
content={values.content}
onClose={showPreview.onFalse}
coverUrl={values.coverUrl}
isSubmitting={isSubmitting}
description={values.description}
/>
</Form>
);
}