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:
2025-07-03 16:13:45 +02:00
parent e9aa405464
commit e8a96dd6c4
5 changed files with 463 additions and 0 deletions

View 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>
);
}

View 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>
),
},
}}
/>
)}
/>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export * from './doc-search-view';