This commit is contained in:
louiscklaw
2025-04-26 07:14:53 +08:00
parent 6e8fea3bdd
commit 9be92b41d1
5 changed files with 234 additions and 166 deletions

View File

@@ -2,9 +2,9 @@
import * as React from 'react';
import { useRouter } from 'next/navigation';
import GetAllCount from '@/db/LessonCategories/GetAllCount';
import GetHiddenCount from '@/db/LessonCategories/GetHiddenCount';
import GetVisibleCount from '@/db/LessonCategories/GetVisibleCount';
import GetAllCount from '@/db/Vocabularies/GetAllCount';
import GetHiddenCount from '@/db/Vocabularies/GetHiddenCount';
import GetVisibleCount from '@/db/Vocabularies/GetVisibleCount';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';

View File

@@ -5,8 +5,7 @@ import * as React from 'react';
import { useSelection } from '@/hooks/use-selection';
import type { Selection } from '@/hooks/use-selection';
import { Vocabulary } from './type';
import type { Vocabulary } from './type';
function noop(): void {
return undefined;

View File

@@ -5,21 +5,14 @@ import RouterLink from 'next/link';
import { LoadingButton } from '@mui/lab';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
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 { CheckCircle as CheckCircleIcon } from '@phosphor-icons/react/dist/ssr/CheckCircle';
import { Clock as ClockIcon } from '@phosphor-icons/react/dist/ssr/Clock';
import { Images as ImagesIcon } from '@phosphor-icons/react/dist/ssr/Images';
import { Minus as MinusIcon } from '@phosphor-icons/react/dist/ssr/Minus';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { TrashSimple as TrashSimpleIcon } from '@phosphor-icons/react/dist/ssr/TrashSimple';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
@@ -29,16 +22,8 @@ import type { ColumnDef } from '@/components/core/data-table';
import ConfirmDeleteModal from './confirm-delete-modal';
import { useVocabulariesSelection } from './vocabularies-selection-context';
import type { Vocabulary } from './type';
import { listLessonCategories } from '@/db/LessonCategories/listLessonCategories';
import { LessonCategory } from '@/db/LessonCategories/type';
import { Logger } from '@/lib/logger';
import { logger } from '@/lib/default-logger';
import getImageUrlFromFile from '@/lib/get-image-url-from-file.ts';
function columns(
handleDeleteClick: (testId: string) => void,
lessonCategories: { id: string; label: string }[]
): ColumnDef<Vocabulary>[] {
function columns(handleDeleteClick: (testId: string) => void): ColumnDef<Vocabulary>[] {
return [
{
formatter: (row): React.JSX.Element => (
@@ -173,20 +158,6 @@ export function VocabulariesTable({ rows, reloadRows }: VocabulariesTableProps):
setIdToDelete(testId);
}
let [lessonCategories, setLessonCategories] = React.useState<{}>({});
async function tempFunc(): Promise<void> {
try {
const tempCategories = await listLessonCategories();
console.log(tempCategories);
} catch (error) {
logger.error(error);
}
}
React.useEffect(() => {
void tempFunc;
}, []);
return (
<React.Fragment>
<ConfirmDeleteModal

View File

@@ -2,75 +2,85 @@
import * as React from 'react';
import RouterLink from 'next/link';
import { useRouter } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { COL_VOCABULARIES } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import { Avatar, Divider } from '@mui/material';
// import Avatar from '@mui/material/Avatar';
import { Avatar, Divider, MenuItem } from '@mui/material';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
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 Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
// import axios from 'axios';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { logger } from '@/lib/default-logger';
import { fileToBase64 } from '@/lib/file-to-base64';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { Option } from '@/components/core/option';
import { TextEditor } from '@/components/core/text-editor/text-editor';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
import ErrorDisplay from '../error';
import type { EditFormProps } from './type';
import { listLessonCategories } from '@/db/LessonCategories/listLessonCategories';
import isDevelopment from '@/lib/check-is-development';
const schema = zod.object({
image: zod.union([zod.array(zod.any()), zod.string()]).optional(),
sound: zod.union([zod.array(zod.any()), zod.string()]).optional(),
word: zod.string().min(1, 'Word is required').max(255),
word_c: zod.string().min(1, 'Chinese word is required').max(255),
sample_e: zod.string().optional(),
sample_c: zod.string().optional(),
cat_id: zod.string().min(1, 'Category ID is required'),
category: zod.string().optional(),
lesson_type_id: zod.string().optional(),
// NOTE: for image handling
avatar: zod.string().optional(),
name: zod.string().min(1, 'Name is required').max(255),
email: zod.string().email('Must be a valid email').min(1, 'Email is required').max(255),
phone: zod.string().min(1, 'Phone is required').max(15),
company: zod.string().max(255),
billingAddress: zod.object({
country: zod.string().min(1, 'Country is required').max(255),
state: zod.string().min(1, 'State is required').max(255),
city: zod.string().min(1, 'City is required').max(255),
zipCode: zod.string().min(1, 'Zip code is required').max(255),
line1: zod.string().min(1, 'Street line 1 is required').max(255),
line2: zod.string().max(255).optional(),
}),
taxId: zod.string().max(255).optional(),
timezone: zod.string().min(1, 'Timezone is required').max(255),
language: zod.string().min(1, 'Language is required').max(255),
currency: zod.string().min(1, 'Currency is required').max(255),
visible: zod.string(),
});
type Values = zod.infer<typeof schema>;
const defaultValues = {
avatar: '',
name: '',
email: '',
phone: '',
company: '',
billingAddress: { country: '', state: '', city: '', zipCode: '', line1: '', line2: '' },
taxId: '',
timezone: 'new_york',
language: 'en',
currency: 'USD',
image: undefined,
sound: undefined,
word: '',
word_c: '',
sample_e: '',
sample_c: '',
cat_id: '',
category: '',
lesson_type_id: '',
visible: 'visible',
} satisfies Values;
export function VocabularyCreateForm(): React.JSX.Element {
const router = useRouter();
const { t } = useTranslation(['common', 'lesson_category']);
const { t } = useTranslation();
const { cat_id: catId } = useParams<{ cat_id: string }>();
const [isCreating, setIsCreating] = React.useState<boolean>(false);
const [showLoading, setShowLoading] = React.useState<boolean>(false);
//
const [showError, setShowError] = React.useState({ show: false, detail: '' });
const {
control,
@@ -83,15 +93,33 @@ export function VocabularyCreateForm(): React.JSX.Element {
const onSubmit = React.useCallback(
async (values: Values): Promise<void> => {
setIsCreating(true);
const tempUpdate: EditFormProps = {
image: values.avatar ? [await base64ToFile(values.avatar)] : null,
sound: '',
word: values.word,
word_c: values.word_c,
sample_e: values.sample_e || '',
sample_c: values.sample_c || '',
cat_id: values.cat_id,
category: '',
lesson_type_id: '',
};
try {
// Make API request
toast.success('Customer updated');
router.push(paths.dashboard.lesson_categories.details('1'));
const result = await pb.collection(COL_VOCABULARIES).create(tempUpdate);
logger.debug(result);
toast.success(t('create.success'));
// router.push(paths.dashboard.lesson_categories.details('1'));
} catch (err) {
logger.error(err);
toast.error('Something went wrong!');
toast.error(t('create.failed'));
} finally {
setIsCreating(false);
}
},
// t is not necessary here
// eslint-disable-next-line react-hooks/exhaustive-deps
[router]
);
@@ -110,6 +138,43 @@ export function VocabularyCreateForm(): React.JSX.Element {
[setValue]
);
const [categoriesOption, setCategoriesOption] = React.useState<{ value: string; label: string }[]>([]);
const loadCategories = React.useCallback(async () => {
try {
const categories = await listLessonCategories();
logger.debug(categories);
setCategoriesOption(
categories.map((c) => {
return { label: c.cat_name || '??', value: c.id };
})
);
} catch (error) {
logger.error(error);
toast.error(t('list.error'));
}
}, [catId]);
React.useEffect(() => {
setShowLoading(true);
void loadCategories();
//
setShowLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [catId]);
if (showLoading) return <FormLoading />;
if (showError.show)
return (
<ErrorDisplay
message={t('error.unable-to-process-request')}
code="500"
details={showError.detail}
/>
);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
@@ -183,84 +248,125 @@ export function VocabularyCreateForm(): React.JSX.Element {
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="name"
name="word"
render={({ field }) => (
<FormControl
error={Boolean(errors.name)}
disabled={isCreating}
error={Boolean(errors.word)}
fullWidth
>
<InputLabel required>{t('create.name')}</InputLabel>
<InputLabel required>{t('create.word')}</InputLabel>
<OutlinedInput {...field} />
{errors.name ? <FormHelperText>{errors.name.message}</FormHelperText> : null}
{errors.word ? <FormHelperText>{errors.word.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="word_c"
render={({ field }) => (
<FormControl
error={Boolean(errors.word_c)}
fullWidth
>
<InputLabel required>{t('create.word_c')}</InputLabel>
<OutlinedInput {...field} />
{errors.word_c ? <FormHelperText>{errors.word_c.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="cat_id"
render={({ field }) => (
<FormControl
error={Boolean(errors.cat_id)}
fullWidth
>
<InputLabel>{t('create.cat_id')}</InputLabel>
<Select {...field}>
{categoriesOption.map((co, i) => (
<Option
key={i}
value={co.value}
>
{co.label}
</Option>
))}
</Select>
</FormControl>
)}
/>
</Grid>
{/* */}
<Grid
md={6}
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="sound"
render={({ field }) => (
<FormControl
error={Boolean(errors.sound)}
fullWidth
>
{/* TODO: sound file selection is not implemented */}
<InputLabel required>{t('create.sound_file')} - (Not implemented)</InputLabel>
<OutlinedInput {...field} />
{errors.sound ? <FormHelperText>{errors.sound.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="email"
name="visible"
render={({ field }) => (
<FormControl
error={Boolean(errors.email)}
error={Boolean(errors.visible)}
fullWidth
>
<InputLabel required>Email address</InputLabel>
<OutlinedInput
{...field}
type="email"
/>
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="phone"
render={({ field }) => (
<FormControl
error={Boolean(errors.phone)}
fullWidth
>
<InputLabel required>Phone number</InputLabel>
<OutlinedInput {...field} />
{errors.phone ? <FormHelperText>{errors.phone.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
<Grid
md={6}
xs={12}
>
<Controller
control={control}
name="company"
render={({ field }) => (
<FormControl
error={Boolean(errors.company)}
fullWidth
>
<InputLabel>Company</InputLabel>
<OutlinedInput {...field} />
{errors.company ? <FormHelperText>{errors.company.message}</FormHelperText> : null}
<InputLabel>{t('create.visible')}</InputLabel>
<Select {...field}>
<MenuItem value="visible">visible</MenuItem>
<MenuItem value="hidden">hidden</MenuItem>
</Select>
{errors.visible ? <FormHelperText>{errors.visible.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
<Stack spacing={3}>
<Typography variant="h6">{t('create.detail-information')}</Typography>
<Typography variant="h6">{t('create.sample-sentence')}</Typography>
<Grid
container
spacing={3}
@@ -270,23 +376,22 @@ export function VocabularyCreateForm(): React.JSX.Element {
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="billingAddress.country"
name="sample_e"
render={({ field }) => (
<Box>
<Typography
variant="subtitle1"
color="text-secondary"
>
{t('create.description')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '400px' } }}>
<TextEditor
content=""
placeholder="Write something"
/>
</Box>
</Box>
<FormControl
error={Boolean(errors.sample_e)}
fullWidth
>
<InputLabel>{t('create.sample_e')}</InputLabel>
<OutlinedInput
{...field}
multiline
rows={4}
/>
{errors.sample_e ? <FormHelperText>{errors.sample_e.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
@@ -295,35 +400,35 @@ export function VocabularyCreateForm(): React.JSX.Element {
xs={12}
>
<Controller
disabled={isCreating}
control={control}
name="billingAddress.state"
name="sample_c"
render={({ field }) => (
<Box>
<Typography
variant="subtitle1"
color="text.secondary"
>
{t('create.remarks')}
</Typography>
<Box sx={{ mt: '8px', '& .tiptap-container': { height: '400px' } }}>
<TextEditor
content=""
placeholder="Write something"
/>
</Box>
</Box>
<FormControl
error={Boolean(errors.sample_c)}
fullWidth
>
<InputLabel>{t('create.sample_c')}</InputLabel>
<OutlinedInput
{...field}
multiline
rows={4}
/>
{errors.sample_c ? <FormHelperText>{errors.sample_c.message}</FormHelperText> : null}
</FormControl>
)}
/>
</Grid>
</Grid>
</Stack>
{/* */}
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
component={RouterLink}
href={paths.dashboard.lesson_categories.list}
href={paths.dashboard.vocabularies.list}
>
{t('create.cancelButton')}
</Button>
@@ -337,6 +442,9 @@ export function VocabularyCreateForm(): React.JSX.Element {
{t('create.createButton')}
</LoadingButton>
</CardActions>
<Box sx={{ display: isDevelopment ? 'block' : 'none' }}>
<pre>{JSON.stringify(errors, null, 2)}</pre>
</Box>
</Card>
</form>
);

View File

@@ -6,8 +6,7 @@ import { useParams, useRouter } from 'next/navigation';
import { COL_VOCABULARIES } from '@/constants';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoadingButton } from '@mui/lab';
import { Avatar, Divider, MenuItem } from '@mui/material';
// import Avatar from '@mui/material/Avatar';
import { Avatar, Divider } from '@mui/material';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
@@ -23,26 +22,20 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Unstable_Grid2';
import { Camera as CameraIcon } from '@phosphor-icons/react/dist/ssr/Camera';
import type { RecordModel } from 'pocketbase';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z as zod } from 'zod';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { logger } from '@/lib/default-logger';
import { base64ToFile, fileToBase64 } from '@/lib/file-to-base64';
import { pb } from '@/lib/pb';
import { Option } from '@/components/core/option';
import { TextEditor } from '@/components/core/text-editor/text-editor';
import { toast } from '@/components/core/toaster';
import FormLoading from '@/components/loading';
import ErrorDisplay from '../error';
import { defaultLessonCategory } from './_constants';
import type { EditFormProps } from './type';
import getVocabularyById from '@/db/Vocabularies/GetById';
import getAllLessonCategories from '@/db/LessonCategories/GetAll';
import { listLessonCategories } from '@/db/LessonCategories/listLessonCategories';
import isDevelopment from '@/lib/check-is-development';
@@ -55,7 +48,7 @@ const schema = zod.object({
sample_c: zod.string().optional(),
cat_id: zod.string().min(1, 'Category ID is required'),
category: zod.string().optional(),
lesson_type_id: zod.string().min(1, 'Lesson type ID is required'),
lesson_type_id: zod.string().optional(),
// NOTE: for image handling
avatar: zod.string().optional(),
});
@@ -111,7 +104,6 @@ export function VocabularyEditForm(): React.JSX.Element {
};
//
try {
console.log({ tempUpdate });
const result = await pb.collection(COL_VOCABULARIES).update(catId, tempUpdate);
logger.debug(result);
toast.success(t('edit.success'));
@@ -173,7 +165,7 @@ export function VocabularyEditForm(): React.JSX.Element {
[catId]
);
let [categoriesOption, setCategoriesOption] = React.useState<{ value: string; label: string }[]>([]);
const [categoriesOption, setCategoriesOption] = React.useState<{ value: string; label: string }[]>([]);
const loadCategories = React.useCallback(async () => {
try {
const categories = await listLessonCategories();
@@ -193,10 +185,8 @@ export function VocabularyEditForm(): React.JSX.Element {
React.useEffect(() => {
setShowLoading(true);
void loadCategories();
void loadExistingData(catId);
setShowLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps