build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import { ItemCard } from './item-card';
import { StorageContext } from './storage-context';
export function GridView(): React.JSX.Element {
const {
items,
deleteItem: onItemDelete,
favoriteItem: onItemFavorite,
setCurrentItemId,
} = React.useContext(StorageContext);
return (
<Box
sx={{ display: 'grid', gap: 4, gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' } }}
>
{Array.from(items.values()).map((item) => (
<ItemCard
item={item}
key={item.id}
onDelete={onItemDelete}
onFavorite={onItemFavorite}
onOpen={setCurrentItemId}
/>
))}
</Box>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { Globe as GlobeIcon } from '@phosphor-icons/react/dist/ssr/Globe';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { dayjs } from '@/lib/dayjs';
import { usePopover } from '@/hooks/use-popover';
import { ItemIcon } from './item-icon';
import { ItemMenu } from './item-menu';
import type { Item } from './types';
export interface ItemCardProps {
item: Item;
onDelete?: (itemId: string) => void;
onFavorite?: (itemId: string, value: boolean) => void;
onOpen?: (itemId: string) => void;
}
export function ItemCard({ item, onDelete, onFavorite, onOpen }: ItemCardProps): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
const handleDelete = React.useCallback(() => {
popover.handleClose();
onDelete?.(item.id);
}, [item, popover, onDelete]);
const createdAt = item.createdAt ? dayjs(item.createdAt).format('MMM D, YYYY') : undefined;
const sharedWith = item.shared ?? [];
const showShared = !item.isPublic && sharedWith.length > 0;
return (
<React.Fragment>
<Card
key={item.id}
sx={{
transition: 'box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
'&:hover': { boxShadow: 'var(--mui-shadows-16)' },
}}
>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', pt: 2, px: 2 }}>
<IconButton
onClick={() => {
onFavorite?.(item.id, !item.isFavorite);
}}
>
<StarIcon color="var(--mui-palette-warning-main)" weight={item.isFavorite ? 'fill' : undefined} />
</IconButton>
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<DotsThreeIcon weight="bold" />
</IconButton>
</Stack>
<Stack divider={<Divider />} spacing={1} sx={{ p: 2 }}>
<Box
onClick={() => {
onOpen?.(item.id);
}}
sx={{ display: 'inline-flex', cursor: 'pointer' }}
>
<ItemIcon extension={item.extension} type={item.type} />
</Box>
<div>
<Typography
onClick={() => {
onOpen?.(item.id);
}}
sx={{ cursor: 'pointer' }}
variant="subtitle2"
>
{item.name}
</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography color="text.secondary" variant="body2">
{item.size}
{item.type === 'folder' ? <span> {item.itemsCount} items</span> : null}
</Typography>
<div>
{item.isPublic ? (
<Tooltip title="Public">
<Avatar sx={{ '--Avatar-size': '32px' }}>
<GlobeIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Tooltip>
) : null}
{showShared ? (
<AvatarGroup max={3}>
{sharedWith.map((person) => (
<Avatar key={person.name} src={person.avatar} sx={{ '--Avatar-size': '32px' }} />
))}
</AvatarGroup>
) : null}
</div>
</Stack>
<Typography color="text.secondary" variant="caption">
Created at {createdAt}
</Typography>
</div>
</Stack>
</Card>
<ItemMenu
anchorEl={popover.anchorRef.current}
onClose={popover.handleClose}
onDelete={handleDelete}
open={popover.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import { FileIcon } from '@/components/core/file-icon';
import type { ItemType } from './types';
export interface ItemIconProps {
extension?: string;
type: ItemType;
}
export function ItemIcon({ type, extension }: ItemIconProps): React.JSX.Element {
return type === 'folder' ? <FolderIcon /> : <FileIcon extension={extension} />;
}
function FolderIcon(): React.JSX.Element {
return (
<Box
sx={{
alignItems: 'center',
display: 'inline-flex',
flex: '0 0 auto',
justifyContent: 'center',
width: '48px',
height: '48px',
}}
>
<Box alt="Folder" component="img" src="/assets/icon-folder.svg" sx={{ height: '100%', width: 'auto' }} />
</Box>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import ListItemIcon from '@mui/material/ListItemIcon';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
export interface ItemMenuProps {
anchorEl?: HTMLElement | null;
onClose?: () => void;
onDelete?: () => void;
open?: boolean;
}
export function ItemMenu({ anchorEl, onClose, onDelete, open = false }: ItemMenuProps): React.JSX.Element {
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={open}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
<MenuItem onClick={onClose}>
<ListItemIcon>
<LinkIcon />
</ListItemIcon>
Copy Link
</MenuItem>
<MenuItem onClick={onDelete} sx={{ color: 'var(--mui-palette-error-main)' }}>
<ListItemIcon>
<TrashIcon />
</ListItemIcon>
Delete
</MenuItem>
</Menu>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
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 Grid from '@mui/material/Unstable_Grid2';
import { Globe as GlobeIcon } from '@phosphor-icons/react/dist/ssr/Globe';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { dayjs } from '@/lib/dayjs';
import { usePopover } from '@/hooks/use-popover';
import { ItemIcon } from './item-icon';
import type { Item } from './types';
const tagOptions = ['Personal', 'Work', 'Business', 'Accounting', 'Security', 'Design'] satisfies string[];
export interface ItemModalProps {
item: Item;
onClose?: () => void;
onDelete?: (itemId: string) => void;
onFavorite?: (itemId: string, value: boolean) => void;
open?: boolean;
}
export function ItemModal({ item, onClose, onDelete, onFavorite, open = false }: ItemModalProps): React.JSX.Element {
const tagsPopover = usePopover<HTMLButtonElement>();
const tags = item.tags ?? [];
const sharedWith = item.shared ?? [];
const showShared = !item.isPublic && sharedWith.length > 0;
return (
<React.Fragment>
<Dialog
maxWidth="sm"
onClose={onClose}
open={open}
sx={{
'& .MuiDialog-container': { justifyContent: 'flex-end' },
'& .MuiDialog-paper': { height: '100%', width: '100%' },
}}
>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, p: 0 }}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
justifyContent: 'space-between',
p: 3,
}}
>
<IconButton
onClick={() => {
onFavorite?.(item.id, !item.isFavorite);
}}
>
<StarIcon color="var(--mui-palette-warning-main)" weight={item.isFavorite ? 'fill' : undefined} />
</IconButton>
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<Stack spacing={2} sx={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', px: 3, py: 2 }}>
<Box
sx={{
border: '1px dashed var(--mui-palette-divider)',
borderRadius: 1,
display: 'flex',
flex: '0 0 auto',
justifyContent: 'center',
p: 3,
}}
>
<ItemIcon extension={item.extension} type={item.type} />
</Box>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6">{item.name}</Typography>
<IconButton>
<PencilSimpleIcon />
</IconButton>
</Stack>
<div>
<Grid alignItems="center" container spacing={3}>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Created by
</Typography>
</Grid>
<Grid sm={8} xs={12}>
{item.author ? <Avatar src={item.author.avatar} /> : null}
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Size
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<Typography variant="body2">{item.size}</Typography>
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Created At
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<Typography variant="body2">
{item.createdAt ? dayjs(item.createdAt).format('MMM D, YYYY hh:mm A') : undefined}
</Typography>
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Modified At
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<Typography variant="body2">
{item.updatedAt ? dayjs(item.updatedAt).format('MMM D, YYYY hh:mm A') : undefined}
</Typography>
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Tags
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
{tags.map((tag) => (
<Chip
key={tag}
label={tag}
onDelete={() => {
// noop
}}
size="small"
variant="soft"
/>
))}
<IconButton onClick={tagsPopover.handleOpen} ref={tagsPopover.anchorRef}>
<PlusIcon />
</IconButton>
</Stack>
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Shared with
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{item.isPublic ? (
<Tooltip title="Public">
<Avatar sx={{ '--Avatar-size': '32px' }}>
<GlobeIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Tooltip>
) : null}
{showShared ? (
<AvatarGroup max={3}>
{sharedWith.map((person) => (
<Avatar key={person.name} src={person.avatar} sx={{ '--Avatar-size': '32px' }} />
))}
</AvatarGroup>
) : null}
<IconButton>
<PlusIcon />
</IconButton>
</Stack>
</Grid>
<Grid sm={4} xs={12}>
<Typography color="text.secondary" variant="body2">
Actions
</Typography>
</Grid>
<Grid sm={8} xs={12}>
<IconButton
color="error"
onClick={() => {
onDelete?.(item.id);
}}
>
<TrashIcon />
</IconButton>
</Grid>
</Grid>
</div>
</Stack>
</DialogContent>
</Dialog>
<Menu
anchorEl={tagsPopover.anchorRef.current}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={tagsPopover.handleClose}
open={tagsPopover.open}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
{tagOptions.map((option) => (
<MenuItem key={option}>{option}</MenuItem>
))}
</Menu>
</React.Fragment>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
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 Stack from '@mui/material/Stack';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { Globe as GlobeIcon } from '@phosphor-icons/react/dist/ssr/Globe';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { dayjs } from '@/lib/dayjs';
import { usePopover } from '@/hooks/use-popover';
import { ItemIcon } from './item-icon';
import { ItemMenu } from './item-menu';
import type { Item } from './types';
export interface ItemRowProps {
item: Item;
onDelete?: (itemId: string) => void;
onFavorite?: (itemId: string, value: boolean) => void;
onOpen?: (itemId: string) => void;
}
export function ItemRow({ item, onDelete, onFavorite, onOpen }: ItemRowProps): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
const handleDelete = React.useCallback(() => {
popover.handleClose();
onDelete?.(item.id);
}, [item, popover, onDelete]);
const sharedWith = item.shared ?? [];
const showShared = !item.isPublic && sharedWith.length > 0;
return (
<React.Fragment>
<TableRow
key={item.id}
sx={{
bgcolor: 'var(--mui-palette-background-paper)',
borderRadius: 1.5,
boxShadow: 0,
transition: 'box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
'&:hover': { boxShadow: 'var(--mui-shadows-16)' },
'& .MuiTableCell-root': {
borderBottom: '1px solid var(--mui-palette-divider)',
borderTop: '1px solid var(--mui-palette-divider)',
'&:first-of-type': {
borderTopLeftRadius: '12px',
borderBottomLeftRadius: '12px',
borderLeft: '1px solid var(--mui-palette-divider)',
},
'&:last-of-type': {
borderTopRightRadius: '12px',
borderBottomRightRadius: '12px',
borderRight: '1px solid var(--mui-palette-divider)',
},
},
}}
>
<TableCell sx={{ maxWidth: '250px' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Box
onClick={() => {
onOpen?.(item.id);
}}
sx={{ cursor: 'pointer' }}
>
<ItemIcon extension={item.extension} type={item.type} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography
noWrap
onClick={() => {
onOpen?.(item.id);
}}
sx={{ cursor: 'pointer' }}
variant="subtitle2"
>
{item.name}
</Typography>
<Typography color="text.secondary" variant="body2">
{item.size}
{item.type === 'folder' ? <span> {item.itemsCount} items</span> : null}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>
<Typography noWrap variant="subtitle2">
Created at
</Typography>
{item.createdAt ? (
<Typography color="text.secondary" noWrap variant="body2">
{dayjs(item.createdAt).format('MMM D, YYYY')}
</Typography>
) : undefined}
</TableCell>
<TableCell>
<Box sx={{ display: 'flex' }}>
{item.isPublic ? (
<Tooltip title="Public">
<Avatar sx={{ '--Avatar-size': '32px' }}>
<GlobeIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
</Tooltip>
) : null}
{showShared ? (
<AvatarGroup max={3}>
{sharedWith.map((person) => (
<Avatar key={person.name} src={person.avatar} sx={{ '--Avatar-size': '32px' }} />
))}
</AvatarGroup>
) : null}
</Box>
</TableCell>
<TableCell align="right">
<IconButton
onClick={() => {
onFavorite?.(item.id, !item.isFavorite);
}}
>
<StarIcon color="var(--mui-palette-warning-main)" weight={item.isFavorite ? 'fill' : undefined} />
</IconButton>
</TableCell>
<TableCell align="right">
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<DotsThreeIcon weight="bold" />
</IconButton>
</TableCell>
</TableRow>
<ItemMenu
anchorEl={popover.anchorRef.current || undefined}
onClose={popover.handleClose}
onDelete={handleDelete}
open={popover.open}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Card from '@mui/material/Card';
import InputAdornment from '@mui/material/InputAdornment';
import OutlinedInput from '@mui/material/OutlinedInput';
import type { SelectChangeEvent } from '@mui/material/Select';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Rows as RowsIcon } from '@phosphor-icons/react/dist/ssr/Rows';
import { SquaresFour as SquaresFourIcon } from '@phosphor-icons/react/dist/ssr/SquaresFour';
import { paths } from '@/paths';
import { Option } from '@/components/core/option';
export interface Filters {
query?: string;
}
export type ViewMode = 'grid' | 'list';
export type SortDir = 'asc' | 'desc';
export interface ItemsFiltersProps {
filters?: Filters;
sortDir?: SortDir;
view?: ViewMode;
}
export function ItemsFilters({ filters = {}, sortDir = 'desc', view = 'grid' }: ItemsFiltersProps): React.JSX.Element {
const router = useRouter();
const [query, setQuery] = React.useState(filters.query ?? '');
React.useEffect(() => {
setQuery(filters.query ?? '');
}, [filters]);
const updateSearchParams = React.useCallback(
(newFilters: Filters, newSortDir: SortDir): void => {
const searchParams = new URLSearchParams();
// Keep the view mode
if (view) {
searchParams.set('view', view);
}
if (newSortDir === 'asc') {
searchParams.set('sortDir', newSortDir);
}
if (newFilters.query) {
searchParams.set('query', newFilters.query);
}
router.push(`${paths.dashboard.fileStorage}?${searchParams.toString()}`);
},
[router, view]
);
const handleQueryChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
}, []);
const handleQueryApply = React.useCallback(() => {
updateSearchParams({ ...filters, query }, sortDir);
}, [updateSearchParams, filters, query, sortDir]);
const handleSortChange = React.useCallback(
(event: SelectChangeEvent) => {
updateSearchParams(filters, event.target.value as SortDir);
},
[updateSearchParams, filters]
);
const handleViewChange = React.useCallback(
(value: ViewMode) => {
if (value) {
router.push(`${paths.dashboard.fileStorage}?view=${value}`);
}
},
[router]
);
return (
<Card>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap', p: 2 }}>
<OutlinedInput
name="name"
onChange={handleQueryChange}
onKeyUp={(event) => {
if (event.key === 'Enter') {
handleQueryApply();
}
}}
placeholder="Search"
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
</InputAdornment>
}
sx={{ flex: '1 1 auto' }}
/>
<Select name="sort" onChange={handleSortChange} sx={{ maxWidth: '100%', width: '120px' }} value={sortDir}>
<Option value="desc">Newest</Option>
<Option value="asc">Oldest</Option>
</Select>
<ToggleButtonGroup
color="primary"
exclusive
onChange={(_, value: ViewMode) => {
handleViewChange(value);
}}
onKeyUp={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleViewChange(view === 'grid' ? 'list' : 'grid');
}
}}
tabIndex={0}
value={view}
>
<ToggleButton value="grid">
<SquaresFourIcon />
</ToggleButton>
<ToggleButton value="list">
<RowsIcon />
</ToggleButton>
</ToggleButtonGroup>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import TablePagination from '@mui/material/TablePagination';
function noop(): void {
return undefined;
}
interface ItemsPaginationProps {
count: number;
page: number;
}
export function ItemsPagination({ count, page }: ItemsPaginationProps): React.JSX.Element {
return (
<TablePagination
component="div"
count={count}
onPageChange={noop}
onRowsPerPageChange={noop}
page={page}
rowsPerPage={10}
rowsPerPageOptions={[5, 10, 25]}
/>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Table 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';
import { ItemRow } from './item-row';
import { StorageContext } from './storage-context';
export function ListView(): React.JSX.Element {
const {
items,
deleteItem: onItemDelete,
favoriteItem: onItemFavorite,
setCurrentItemId,
} = React.useContext(StorageContext);
return (
<Box sx={{ mx: -3, my: -6 }}>
<Box sx={{ overflowX: 'auto', px: 3 }}>
<Table sx={{ borderCollapse: 'separate', borderSpacing: '0 24px' }}>
<TableHead sx={{ visibility: 'collapse' }}>
<TableRow>
<TableCell sx={{ width: '250px', minWidth: '250px', maxWidth: '250px' }} />
<TableCell sx={{ width: '150px', minWidth: '150px', maxWidth: '150px' }} />
<TableCell sx={{ width: '150px', minWidth: '150px', maxWidth: '150px' }} />
<TableCell sx={{ width: '75px', minWidth: '75px', maxWidth: '75px' }} />
<TableCell sx={{ width: '75px', minWidth: '75px', maxWidth: '75px' }} />
</TableRow>
</TableHead>
<TableBody>
{Array.from(items.values()).map((item) => (
<ItemRow
item={item}
key={item.id}
onDelete={onItemDelete}
onFavorite={onItemFavorite}
onOpen={setCurrentItemId}
/>
))}
</TableBody>
</Table>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,129 @@
'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 CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Lightning as LightningIcon } from '@phosphor-icons/react/dist/ssr/Lightning';
import { RadialBar, RadialBarChart } from 'recharts';
import { FileIcon } from '@/components/core/file-icon';
import { NoSsr } from '@/components/core/no-ssr';
const totals = [
{ extension: 'mp4', itemsCount: 25, label: 'MP4', size: '22.75 GB' },
{ extension: 'png', itemsCount: 591, label: 'PNG', size: '54.69 GB' },
{ extension: 'pdf', itemsCount: 95, label: 'PDF', size: '412.39 MB' },
{ itemsCount: 210, label: 'Other', size: '261.43 MB' },
] satisfies { extension?: string; itemsCount: number; label: string; size: string }[];
export function Stats(): React.JSX.Element {
const chartSize = 300;
const data = [
{ name: 'Empty', value: 100 },
{ name: 'Usage', value: 75 },
] satisfies { name: string; value: number }[];
return (
<Card>
<CardHeader subheader="Upgrade before reaching it" title="Stats" />
<CardContent>
<Stack spacing={2}>
<Stack sx={{ alignItems: 'center' }}>
<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={32}
data={data}
endAngle={-10}
height={chartSize}
innerRadius={166}
startAngle={190}
width={chartSize}
>
<RadialBar
animationDuration={300}
background
cornerRadius={16}
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="h4">75 GB</Typography>
</Box>
</Box>
</Box>
</NoSsr>
<Box sx={{ mt: '-100px' }}>
<Typography variant="h6">You&apos;ve almost reached your limit</Typography>
<Typography color="text.secondary" variant="body2">
You have used 75% of your available storage.
</Typography>
</Box>
</Stack>
<List disablePadding>
{totals.map((total) => (
<ListItem disableGutters key={total.label}>
<ListItemIcon>
<FileIcon extension={total.extension} />
</ListItemIcon>
<ListItemText
disableTypography
primary={<Typography variant="body2">{total.label}</Typography>}
secondary={
<Typography color="text.secondary" variant="caption">
{total.size} {total.itemsCount} items
</Typography>
}
/>
</ListItem>
))}
</List>
</Stack>
</CardContent>
<Divider />
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" startIcon={<LightningIcon />} variant="contained">
Upgrade plan
</Button>
</CardActions>
</Card>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import * as React from 'react';
import type { Item } from './types';
function noop(): void {
return undefined;
}
export interface StorageContextValue {
items: Map<string, Item>;
currentItemId?: string;
setCurrentItemId: (itemId?: string) => void;
deleteItem: (itemId: string) => void;
favoriteItem: (itemId: string, value: boolean) => void;
}
export const StorageContext = React.createContext<StorageContextValue>({
items: new Map(),
setCurrentItemId: noop,
deleteItem: noop,
favoriteItem: noop,
});
export interface StorageProviderProps {
children: React.ReactNode;
items: Item[];
}
export function StorageProvider({ children, items: initialItems = [] }: StorageProviderProps): React.JSX.Element {
const [items, setItems] = React.useState(new Map<string, Item>());
const [currentItemId, setCurrentItemId] = React.useState<string>();
React.useEffect((): void => {
setItems(new Map(initialItems.map((item) => [item.id, item])));
}, [initialItems]);
const handleDeleteItem = React.useCallback(
(itemId: string) => {
const item = items.get(itemId);
// Item might no longer exist
if (!item) {
return;
}
const updatedItems = new Map<string, Item>(items);
// Delete item
updatedItems.delete(itemId);
// Dispatch update
setItems(updatedItems);
},
[items]
);
const handleFavoriteItem = React.useCallback(
(itemId: string, value: boolean) => {
const item = items.get(itemId);
// Item might no longer exist
if (!item) {
return;
}
const updatedItems = new Map<string, Item>(items);
// Update item
updatedItems.set(itemId, { ...item, isFavorite: value });
// Dispatch update
setItems(updatedItems);
},
[items]
);
return (
<StorageContext.Provider
value={{ items, currentItemId, setCurrentItemId, deleteItem: handleDeleteItem, favoriteItem: handleFavoriteItem }}
>
{children}
</StorageContext.Provider>
);
}
export const StorageConsumer = StorageContext.Consumer;

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { GridView } from './grid-view';
import { ItemModal } from './item-modal';
import { ListView } from './list-view';
import { StorageContext } from './storage-context';
export interface StorageViewProps {
view: 'grid' | 'list';
}
export function StorageView({ view }: StorageViewProps): React.JSX.Element {
const { currentItemId, items, deleteItem, favoriteItem, setCurrentItemId } = React.useContext(StorageContext);
const currentItem = currentItemId ? items.get(currentItemId) : undefined;
return (
<React.Fragment>
{items.size ? (
<React.Fragment>{view === 'grid' ? <GridView /> : <ListView />}</React.Fragment>
) : (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="body2">
No items found
</Typography>
</Box>
)}
{currentItem ? (
<ItemModal
item={currentItem}
onClose={() => {
setCurrentItemId(undefined);
}}
onDelete={(itemId) => {
setCurrentItemId(undefined);
deleteItem(itemId);
}}
onFavorite={favoriteItem}
open
/>
) : null}
</React.Fragment>
);
}

View File

@@ -0,0 +1,17 @@
export type ItemType = 'file' | 'folder';
export interface Item {
id: string;
type: ItemType;
name: string;
extension?: string;
author?: { name: string; avatar?: string };
isFavorite?: boolean;
isPublic?: boolean;
tags: string[];
shared: { name: string; avatar?: string }[];
itemsCount?: number;
size: string;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import Button from '@mui/material/Button';
import { UploadSimple as UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr/UploadSimple';
import { useDialog } from '@/hooks/use-dialog';
import { Uploader } from './uploader';
export function UplaodButton(): React.JSX.Element {
const uploadDialog = useDialog();
return (
<React.Fragment>
<Button onClick={uploadDialog.handleOpen} startIcon={<UploadSimpleIcon />} variant="contained">
Upload
</Button>
<Uploader onClose={uploadDialog.handleClose} open={uploadDialog.open} />
</React.Fragment>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import type { File } from '@/components/core/file-dropzone';
import { FileDropzone } from '@/components/core/file-dropzone';
import { FileIcon } from '@/components/core/file-icon';
function bytesToSize(bytes: number, decimals = 2): string {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export interface UploaderProps {
onClose?: () => void;
open?: boolean;
}
export function Uploader({ onClose, open = false }: UploaderProps): React.JSX.Element {
const [files, setFiles] = React.useState<File[]>([]);
React.useEffect(() => {
setFiles([]);
}, [open]);
const handleDrop = React.useCallback((newFiles: File[]) => {
setFiles((prevFiles) => {
return [...prevFiles, ...newFiles];
});
}, []);
const handleRemove = React.useCallback((file: File) => {
setFiles((prevFiles) => {
return prevFiles.filter((_file) => _file.path !== file.path);
});
}, []);
const handleRemoveAll = React.useCallback(() => {
setFiles([]);
}, []);
return (
<Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}>
<Typography variant="h6">Upload files</Typography>
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<DialogContent>
<Stack spacing={3}>
<FileDropzone accept={{ '*/*': [] }} caption="Max file size is 3 MB" files={files} onDrop={handleDrop} />
{files.length ? (
<Stack spacing={2}>
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{files.map((file) => {
const extension = file.name.split('.').pop();
return (
<Stack
component="li"
direction="row"
key={file.path}
spacing={2}
sx={{
alignItems: 'center',
border: '1px solid var(--mui-palette-divider)',
borderRadius: 1,
flex: '1 1 auto',
p: 1,
}}
>
<FileIcon extension={extension} />
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="subtitle2">{file.name}</Typography>
<Typography color="text.secondary" variant="body2">
{bytesToSize(file.size)}
</Typography>
</Box>
<Tooltip title="Remove">
<IconButton
onClick={() => {
handleRemove(file);
}}
>
<XIcon />
</IconButton>
</Tooltip>
</Stack>
);
})}
</Stack>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'flex-end' }}>
<Button color="secondary" onClick={handleRemoveAll} size="small" type="button">
Remove all
</Button>
<Button onClick={onClose} size="small" type="button" variant="contained">
Upload
</Button>
</Stack>
</Stack>
) : null}
</Stack>
</DialogContent>
</Dialog>
);
}