Add document management features and components
Introduces `DocItem`, `DocSearch`, and `DocSort` components for displaying, searching, and sorting documents. The `DocSearchView` component integrates these functionalities, providing a user interface for document management, including a button to create new posts. Updates `index.ts` to export the new `DocSearchView` component.
This commit is contained in:
211
src/client/dd-hub-react/src/sections/document/doc-item.tsx
Normal file
211
src/client/dd-hub-react/src/sections/document/doc-item.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import type { CardProps } from '@mui/material/Card';
|
||||||
|
import type { IconifyName } from 'src/components/iconify';
|
||||||
|
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
import { fDate } from 'src/utils/format-time';
|
||||||
|
import { fShortenNumber } from 'src/utils/format-number';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { SvgColor } from 'src/components/svg-color';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type IDocItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
coverUrl: string;
|
||||||
|
totalViews: number;
|
||||||
|
description: string;
|
||||||
|
totalShares: number;
|
||||||
|
totalComments: number;
|
||||||
|
totalFavorites: number;
|
||||||
|
postedAt: string | number | null;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocItem({
|
||||||
|
sx,
|
||||||
|
post,
|
||||||
|
latestDoc,
|
||||||
|
latestDocLarge,
|
||||||
|
...other
|
||||||
|
}: CardProps & {
|
||||||
|
post: IDocItem;
|
||||||
|
latestDoc: boolean;
|
||||||
|
latestDocLarge: boolean;
|
||||||
|
}) {
|
||||||
|
const renderAvatar = (
|
||||||
|
<Avatar
|
||||||
|
alt={post.author.name}
|
||||||
|
src={post.author.avatarUrl}
|
||||||
|
sx={{
|
||||||
|
left: 24,
|
||||||
|
zIndex: 9,
|
||||||
|
bottom: -24,
|
||||||
|
position: 'absolute',
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
top: 24,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTitle = (
|
||||||
|
<Link
|
||||||
|
color="inherit"
|
||||||
|
variant="subtitle2"
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
height: 44,
|
||||||
|
overflow: 'hidden',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
...(latestDocLarge && { typography: 'h5', height: 60 }),
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
color: 'common.white',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderInfo = (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 3,
|
||||||
|
gap: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
color: 'text.disabled',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ number: post.totalComments, icon: 'solar:chat-round-dots-bold' },
|
||||||
|
{ number: post.totalViews, icon: 'solar:eye-bold' },
|
||||||
|
{ number: post.totalShares, icon: 'solar:share-bold' },
|
||||||
|
].map((info, _index) => (
|
||||||
|
<Box
|
||||||
|
key={_index}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
opacity: 0.64,
|
||||||
|
color: 'common.white',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify width={16} icon={info.icon as IconifyName} sx={{ mr: 0.5 }} />
|
||||||
|
<Typography variant="caption">{fShortenNumber(info.number)}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCover = (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
alt={post.title}
|
||||||
|
src={post.coverUrl}
|
||||||
|
sx={{
|
||||||
|
top: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
objectFit: 'cover',
|
||||||
|
position: 'absolute',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDate = (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
mb: 1,
|
||||||
|
color: 'text.disabled',
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
opacity: 0.48,
|
||||||
|
color: 'common.white',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fDate(post.postedAt)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderShape = (
|
||||||
|
<SvgColor
|
||||||
|
src="/assets/icons/shape-avatar.svg"
|
||||||
|
sx={{
|
||||||
|
left: 0,
|
||||||
|
width: 88,
|
||||||
|
zIndex: 9,
|
||||||
|
height: 36,
|
||||||
|
bottom: -16,
|
||||||
|
position: 'absolute',
|
||||||
|
color: 'background.paper',
|
||||||
|
...((latestDocLarge || latestDoc) && { display: 'none' }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={sx} {...other}>
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
position: 'relative',
|
||||||
|
pt: 'calc(100% * 3 / 4)',
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
pt: 'calc(100% * 4 / 3)',
|
||||||
|
'&:after': {
|
||||||
|
top: 0,
|
||||||
|
content: "''",
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
bgcolor: varAlpha(theme.palette.grey['900Channel'], 0.72),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(latestDocLarge && {
|
||||||
|
pt: {
|
||||||
|
xs: 'calc(100% * 4 / 3)',
|
||||||
|
sm: 'calc(100% * 3 / 4.66)',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{renderShape}
|
||||||
|
{renderAvatar}
|
||||||
|
{renderCover}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
p: theme.spacing(6, 3, 3, 3),
|
||||||
|
...((latestDocLarge || latestDoc) && {
|
||||||
|
width: 1,
|
||||||
|
bottom: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{renderDate}
|
||||||
|
{renderTitle}
|
||||||
|
{renderInfo}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/client/dd-hub-react/src/sections/document/doc-search.tsx
Normal file
59
src/client/dd-hub-react/src/sections/document/doc-search.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Theme, SxProps } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
|
||||||
|
import type { IDocItem } from './doc-item';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DocSearchProps = {
|
||||||
|
posts: IDocItem[];
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocSearch({ posts, sx }: DocSearchProps) {
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
sx={{ width: 280 }}
|
||||||
|
autoHighlight
|
||||||
|
popupIcon={null}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
width: 320,
|
||||||
|
[`& .${autocompleteClasses.option}`]: {
|
||||||
|
typography: 'body2',
|
||||||
|
},
|
||||||
|
...sx,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
options={posts}
|
||||||
|
getOptionLabel={(post) => post.title}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder="Search post..."
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Iconify
|
||||||
|
icon="eva:search-fill"
|
||||||
|
sx={{ ml: 1, width: 20, height: 20, color: 'text.disabled' }}
|
||||||
|
/>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/client/dd-hub-react/src/sections/document/doc-sort.tsx
Normal file
96
src/client/dd-hub-react/src/sections/document/doc-sort.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ButtonProps } from '@mui/material/Button';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { varAlpha } from 'minimal-shared/utils';
|
||||||
|
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import MenuList from '@mui/material/MenuList';
|
||||||
|
import MenuItem, { menuItemClasses } from '@mui/material/MenuItem';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DocSortProps = ButtonProps & {
|
||||||
|
sortBy: string;
|
||||||
|
onSort: (newSort: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocSort({ options, sortBy, onSort, sx, ...other }: DocSortProps) {
|
||||||
|
const [openPopover, setOpenPopover] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const handleOpenPopover = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setOpenPopover(event.currentTarget);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClosePopover = useCallback(() => {
|
||||||
|
setOpenPopover(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disableRipple
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleOpenPopover}
|
||||||
|
endIcon={
|
||||||
|
<Iconify
|
||||||
|
icon={openPopover ? 'eva:arrow-ios-upward-fill' : 'eva:arrow-ios-downward-fill'}
|
||||||
|
sx={{
|
||||||
|
ml: -0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08),
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{options.find((option) => option.value === sortBy)?.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={!!openPopover}
|
||||||
|
anchorEl={openPopover}
|
||||||
|
onClose={handleClosePopover}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<MenuList
|
||||||
|
disablePadding
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
gap: 0.5,
|
||||||
|
width: 160,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
[`& .${menuItemClasses.root}`]: {
|
||||||
|
px: 1,
|
||||||
|
gap: 2,
|
||||||
|
borderRadius: 0.75,
|
||||||
|
[`&.${menuItemClasses.selected}`]: { bgcolor: 'action.selected' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
selected={option.value === sortBy}
|
||||||
|
onClick={() => {
|
||||||
|
onSort(option.value);
|
||||||
|
handleClosePopover();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Pagination from '@mui/material/Pagination';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
|
||||||
|
import { DocItem } from '../doc-item';
|
||||||
|
import { DocSort } from '../doc-sort';
|
||||||
|
import { DocSearch } from '../doc-search';
|
||||||
|
|
||||||
|
import type { IDocItem } from '../doc-item';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
posts: IDocItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocSearchView({ posts }: Props) {
|
||||||
|
const [sortBy, setSortBy] = useState('latest');
|
||||||
|
|
||||||
|
const handleSort = useCallback((newSort: string) => {
|
||||||
|
setSortBy(newSort);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||||
|
Document Search
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||||
|
>
|
||||||
|
New post
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocSearch posts={posts} />
|
||||||
|
<DocSort
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSort={handleSort}
|
||||||
|
options={[
|
||||||
|
{ value: 'latest', label: 'Latest' },
|
||||||
|
{ value: 'popular', label: 'Popular' },
|
||||||
|
{ value: 'oldest', label: 'Oldest' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{posts.map((post, index) => {
|
||||||
|
const latestDocLarge = index === 0;
|
||||||
|
const latestDoc = index === 1 || index === 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
key={post.id}
|
||||||
|
size={{
|
||||||
|
xs: 12,
|
||||||
|
sm: latestDocLarge ? 12 : 6,
|
||||||
|
md: latestDocLarge ? 6 : 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocItem post={post} latestDoc={latestDoc} latestDocLarge={latestDocLarge} />
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Pagination count={10} color="primary" sx={{ mt: 8, mx: 'auto' }} />
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './doc-search-view';
|
||||||
Reference in New Issue
Block a user