mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
🚀 release v3.0.0
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.header {
|
||||
@apply mb-4 flex items-center justify-between;
|
||||
|
||||
.label {
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.inputGrid {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
|
||||
.delete {
|
||||
@apply opacity-25 hover:opacity-75;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Add, Delete } from '@mui/icons-material';
|
||||
import { IconButton, InputAdornment, TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
|
||||
import styles from './ArrayInput.module.scss';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
value: string[];
|
||||
className?: string;
|
||||
onChange: (event: any) => void;
|
||||
errors?: FieldError | FieldError[];
|
||||
};
|
||||
|
||||
const ArrayInput: React.FC<Props> = ({ value, label, onChange, errors, className }) => {
|
||||
const [items, setItems] = useState<string[]>(value);
|
||||
|
||||
const onAdd = () => setItems([...items, '']);
|
||||
|
||||
const onDelete = (index: number) => setItems(items.filter((_, idx) => idx !== index));
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
||||
const tempItems = [...items];
|
||||
tempItems[index] = event.target.value;
|
||||
setItems(tempItems);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onChange(items);
|
||||
}, [onChange, items]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.label}>
|
||||
{label} <small>({items.length})</small>
|
||||
</h3>
|
||||
|
||||
<IconButton onClick={onAdd}>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div className={styles.inputGrid}>
|
||||
{items.map((value, index) => (
|
||||
<TextField
|
||||
key={index}
|
||||
value={value}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => handleChange(event, index)}
|
||||
error={!!get(errors, index, false)}
|
||||
helperText={get(errors, `${index}.message`, '')}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton edge="end" onClick={() => onDelete(index)} className={styles.delete}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrayInput;
|
||||
@@ -0,0 +1,3 @@
|
||||
.avatar {
|
||||
@apply cursor-pointer rounded-full;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@mui/material';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { logout } from '@/store/auth/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import getGravatarUrl from '@/utils/getGravatarUrl';
|
||||
|
||||
import styles from './Avatar.module.scss';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const Avatar: React.FC<Props> = ({ size = 64 }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const email = user?.email || '';
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<Element>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
handleClose();
|
||||
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={handleOpen}>
|
||||
<Image
|
||||
width={size}
|
||||
height={size}
|
||||
alt={user?.name}
|
||||
className={styles.avatar}
|
||||
src={getGravatarUrl(email, size)}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
|
||||
<MenuItem>
|
||||
<div>
|
||||
<span className="text-xs opacity-50">{t('common.avatar.menu.greeting')}</span>
|
||||
<p>{user?.name}</p>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>{t('common.avatar.menu.logout')}</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,32 @@
|
||||
.content {
|
||||
@apply rounded p-6 text-sm shadow lg:w-1/2 xl:w-2/5;
|
||||
@apply absolute inset-4 sm:inset-x-4 sm:inset-y-auto lg:inset-auto;
|
||||
@apply overflow-scroll bg-neutral-50 dark:bg-neutral-900 lg:overflow-auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex items-center justify-between;
|
||||
@apply w-full border-b pb-5 dark:border-white/10;
|
||||
|
||||
> div {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply flex items-center justify-center;
|
||||
@apply rotate-0 transition-transform hover:rotate-90;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
@apply grid gap-4 pt-4 pb-6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply flex items-center justify-end gap-x-4;
|
||||
@apply w-full border-t pt-5 dark:border-white/10;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { Fade, IconButton, Modal } from '@mui/material';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import styles from './BaseModal.module.scss';
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
heading: string;
|
||||
handleClose: () => void;
|
||||
footerChildren?: React.ReactNode;
|
||||
};
|
||||
|
||||
const BaseModal: React.FC<Props> = ({ icon, isOpen, heading, children, handleClose, footerChildren }) => {
|
||||
const router = useRouter();
|
||||
const { pathname, query } = router;
|
||||
|
||||
const onClose = () => {
|
||||
router.push({ pathname, query }, '');
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
closeAfterTransition
|
||||
aria-labelledby={heading}
|
||||
classes={{ root: 'flex items-center justify-center' }}
|
||||
>
|
||||
<Fade in={isOpen}>
|
||||
<div className={styles.content}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{icon}
|
||||
{icon && <span className="mx-1 opacity-25">/</span>}
|
||||
<h1>{heading}</h1>
|
||||
</div>
|
||||
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div className={styles.body}>{children}</div>
|
||||
|
||||
{footerChildren ? <footer className={styles.footer}>{footerChildren}</footer> : null}
|
||||
</div>
|
||||
</Fade>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Avatar, IconButton } from '@mui/material';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
|
||||
type Props = {
|
||||
color: string;
|
||||
size?: number;
|
||||
onClick?: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorAvatar: React.FC<Props> = ({ color, size = 20, onClick }) => {
|
||||
const handleClick = () => isFunction(onClick) && onClick(color);
|
||||
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<Avatar sx={{ bgcolor: color, width: size, height: size }}> </Avatar>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorAvatar;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Popover, TextField } from '@mui/material';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { hexColorPattern } from '@/config/colors';
|
||||
|
||||
import ColorAvatar from './ColorAvatar';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
color: string;
|
||||
className?: string;
|
||||
onChange: (color: string) => void;
|
||||
};
|
||||
|
||||
const ColorPicker: React.FC<Props> = ({ label, color, onChange, className }) => {
|
||||
const isValid = useMemo(() => hexColorPattern.test(color), [color]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLInputElement | null>(null);
|
||||
const isOpen = Boolean(anchorEl);
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (hexColorPattern.test(event.target.value)) {
|
||||
onChange(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label={label}
|
||||
value={color}
|
||||
error={!isValid}
|
||||
onClick={handleOpen}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<div className="mr-2">
|
||||
<ColorAvatar color={color} />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<HexColorPicker color={color} onChange={onChange} className="overflow-hidden" />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,26 @@
|
||||
import clsx from 'clsx';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Footer: React.FC<Props> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<footer className={clsx('text-xs', className)}>
|
||||
<p>{t('common.footer.license')}</p>
|
||||
<p>
|
||||
<Trans t={t} i18nKey="common.footer.credit">
|
||||
A passion project by
|
||||
<a href="https://www.amruthpillai.com/" target="_blank" rel="noreferrer">
|
||||
Amruth Pillai
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,17 @@
|
||||
.container {
|
||||
@apply flex items-center justify-between;
|
||||
|
||||
h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex gap-2 opacity-75 transition-opacity lg:opacity-50 dark:lg:opacity-25;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.actions {
|
||||
@apply opacity-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Check, Delete, DriveFileRenameOutline, Grade, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { IconButton, TextField, Tooltip } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import sections from '@/config/sections';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { deleteSection, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './Heading.module.scss';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
name?: string;
|
||||
isEditable?: boolean;
|
||||
isHideable?: boolean;
|
||||
isDeletable?: boolean;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Heading: React.FC<Props> = ({
|
||||
path,
|
||||
name,
|
||||
isEditable = false,
|
||||
isHideable = false,
|
||||
isDeletable = false,
|
||||
action,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const heading = useAppSelector((state) => get(state.resume, `${path}.name`, name));
|
||||
const visibility = useAppSelector((state) => get(state.resume, `${path}.visible`, true));
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
const id = useMemo(() => path && path.split('.').at(-1), [path]);
|
||||
|
||||
const icon = sections.find((x) => x.id === id)?.icon || <Grade />;
|
||||
|
||||
const toggleVisibility = () => {
|
||||
dispatch(setResumeState({ path: `${path}.visible`, value: !visibility }));
|
||||
};
|
||||
|
||||
const toggleEditMode = () => setEditMode(!editMode);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setResumeState({ path: `${path}.name`, value: event.target.value }));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dispatch(deleteSection({ path }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="opacity-50">{icon}</div>
|
||||
{editMode ? (
|
||||
<TextField size="small" value={heading} className="w-3/4" onChange={handleChange} />
|
||||
) : (
|
||||
<h1>{heading}</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(styles.actions, {
|
||||
'!opacity-75': editMode,
|
||||
})}
|
||||
>
|
||||
{isEditable && (
|
||||
<Tooltip title={t<string>('builder.common.tooltip.rename-section')}>
|
||||
<IconButton onClick={toggleEditMode}>{editMode ? <Check /> : <DriveFileRenameOutline />}</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isHideable && (
|
||||
<Tooltip title={t<string>('builder.common.tooltip.toggle-visibility')}>
|
||||
<IconButton onClick={toggleVisibility}>{visibility ? <Visibility /> : <VisibilityOff />}</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDeletable && (
|
||||
<Tooltip title={t<string>('builder.common.tooltip.delete-section')}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
@@ -0,0 +1,7 @@
|
||||
.container {
|
||||
@apply rounded-lg border dark:border-neutral-50/10;
|
||||
|
||||
.empty {
|
||||
@apply py-8 text-center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ListItem as ListItemType } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useCallback } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { deleteItem, setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import styles from './List.module.scss';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
titleKey?: string;
|
||||
subtitleKey?: string;
|
||||
onEdit?: (item: ListItemType) => void;
|
||||
onDuplicate?: (item: ListItemType) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const List: React.FC<Props> = ({
|
||||
path,
|
||||
titleKey = 'title',
|
||||
subtitleKey = 'subtitle',
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const list: Array<ListItemType> = useAppSelector((state) => get(state.resume, path, []));
|
||||
|
||||
const handleEdit = (item: ListItemType) => {
|
||||
isFunction(onEdit) && onEdit(item);
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItemType) => {
|
||||
isFunction(onDuplicate) && onDuplicate(item);
|
||||
};
|
||||
|
||||
const handleDelete = (item: ListItemType) => {
|
||||
dispatch(deleteItem({ path, value: item }));
|
||||
};
|
||||
|
||||
const handleMove = useCallback(
|
||||
(dragIndex: number, hoverIndex: number) => {
|
||||
const dragItem = list[dragIndex];
|
||||
const newList = [...list];
|
||||
|
||||
newList.splice(dragIndex, 1);
|
||||
newList.splice(hoverIndex, 0, dragItem);
|
||||
|
||||
dispatch(setResumeState({ path, value: newList }));
|
||||
},
|
||||
[list, dispatch, path]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className={clsx(styles.container, className)}>
|
||||
{isEmpty(list) && <div className={styles.empty}>{t('builder.common.list.empty-text')}</div>}
|
||||
|
||||
{list.map((item, index) => {
|
||||
const title = get(item, titleKey, '');
|
||||
const subtitleObj = get(item, subtitleKey);
|
||||
const subtitle: string = isArray(subtitleObj) ? subtitleObj.join(', ') : subtitleObj;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onMove={handleMove}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default List;
|
||||
@@ -0,0 +1,18 @@
|
||||
.item {
|
||||
@apply flex items-center justify-between;
|
||||
@apply py-5 pl-5 pr-2;
|
||||
@apply border-b border-neutral-900/10 last:border-0 dark:border-neutral-50/10;
|
||||
@apply cursor-move transition-opacity;
|
||||
|
||||
.meta {
|
||||
@apply grid gap-1;
|
||||
|
||||
.title {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@apply text-xs opacity-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DeleteOutline, DriveFileRenameOutline, FileCopy, MoreVert } from '@mui/icons-material';
|
||||
import { Divider, IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from '@mui/material';
|
||||
import { ListItem as ListItemType } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { DropTargetMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
|
||||
import styles from './ListItem.module.scss';
|
||||
|
||||
interface DragItem {
|
||||
id: string;
|
||||
type: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: ListItemType;
|
||||
index: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onMove?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onEdit?: (item: ListItemType) => void;
|
||||
onDelete?: (item: ListItemType) => void;
|
||||
onDuplicate?: (item: ListItemType) => void;
|
||||
};
|
||||
|
||||
const ListItem: React.FC<Props> = ({ item, index, title, subtitle, onMove, onEdit, onDelete, onDuplicate }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
|
||||
|
||||
const [{ handlerId }, drop] = useDrop<DragItem, any, any>({
|
||||
accept: 'ListItem',
|
||||
collect(monitor) {
|
||||
return { handlerId: monitor.getHandlerId() };
|
||||
},
|
||||
hover(item: DragItem, monitor: DropTargetMonitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFunction(onMove) && onMove(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'ListItem',
|
||||
item: () => {
|
||||
return { id: item.id, index };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleOpen = (event: React.MouseEvent<Element>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleEdit = (item: ListItemType) => {
|
||||
isFunction(onEdit) && onEdit(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = (item: ListItemType) => {
|
||||
isFunction(onDelete) && onDelete(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDuplicate = (item: ListItemType) => {
|
||||
isFunction(onDuplicate) && onDuplicate(item);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-handler-id={handlerId}
|
||||
className={clsx(styles.item, {
|
||||
['opacity-25']: isDragging,
|
||||
})}
|
||||
>
|
||||
<div className={styles.meta}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IconButton onClick={handleOpen}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
|
||||
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
|
||||
<MenuItem onClick={() => handleEdit(item)}>
|
||||
<ListItemIcon>
|
||||
<DriveFileRenameOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Edit</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => handleDuplicate(item)}>
|
||||
<ListItemIcon>
|
||||
<FileCopy className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Duplicate</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="right"
|
||||
title="Are you sure you want to delete this item? This is an irreversible action."
|
||||
>
|
||||
<div>
|
||||
<MenuItem onClick={() => handleDelete(item)}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutline className="scale-90" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
@@ -0,0 +1,32 @@
|
||||
.loading {
|
||||
animation: progress 2s linear infinite;
|
||||
|
||||
@apply fixed top-0 z-50;
|
||||
@apply bg-primary-500 shadow-primary-500/50 h-0.5 w-screen shadow;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
left: 0%;
|
||||
right: 100%;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
left: 0%;
|
||||
right: 75%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
90% {
|
||||
left: 75%;
|
||||
right: 0%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
right: 0%;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useIsFetching, useIsMutating } from 'react-query';
|
||||
|
||||
import styles from './Loading.module.scss';
|
||||
|
||||
const Loading: React.FC = () => {
|
||||
const { isReady } = useRouter();
|
||||
const isFetching = useIsFetching();
|
||||
const isMutating = useIsMutating();
|
||||
|
||||
if (!isFetching && !isMutating && isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.loading} />;
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -0,0 +1,11 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
type Props = {
|
||||
size?: 256 | 64 | 48 | 40 | 32;
|
||||
};
|
||||
|
||||
const Logo: React.FC<Props> = ({ size = 64 }) => {
|
||||
return <Image alt="Reactive Resume" src="/images/logo.svg" className="rounded" width={size} height={size} />;
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import { isEmpty } from 'lodash';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
type Props = {
|
||||
children?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Markdown: React.FC<Props> = ({ className, children }) => {
|
||||
if (!children || isEmpty(children)) return null;
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} className={clsx('markdown', className)}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Link } from '@mui/material';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
|
||||
const MarkdownSupported: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className="inline-block pt-1 opacity-75">
|
||||
<Trans t={t} i18nKey="common.markdown.help-text">
|
||||
This section supports
|
||||
<Link href="https://www.markdownguide.org/cheat-sheet/" target="_blank" rel="noreferrer">
|
||||
markdown
|
||||
</Link>
|
||||
formatting.
|
||||
</Trans>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownSupported;
|
||||
@@ -0,0 +1,5 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const NoSSR: React.FC = ({ children }) => <>{children}</>;
|
||||
|
||||
export default dynamic(() => Promise.resolve(NoSSR), { ssr: false });
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TextField } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setResumeState } from '@/store/resume/resumeSlice';
|
||||
|
||||
import MarkdownSupported from './MarkdownSupported';
|
||||
|
||||
interface Props {
|
||||
type?: 'text' | 'textarea';
|
||||
label: string;
|
||||
path: string;
|
||||
className?: string;
|
||||
markdownSupported?: boolean;
|
||||
}
|
||||
|
||||
const ResumeInput: React.FC<Props> = ({ type = 'text', label, path, className, markdownSupported = false }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const stateValue = useAppSelector((state) => get(state.resume, path, ''));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(stateValue);
|
||||
}, [stateValue]);
|
||||
|
||||
const [value, setValue] = useState<string>(stateValue);
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setValue(event.target.value);
|
||||
dispatch(setResumeState({ path, value: event.target.value }));
|
||||
};
|
||||
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<TextField
|
||||
rows={5}
|
||||
multiline
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
helperText={markdownSupported && <MarkdownSupported />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TextField type={type} label={label} value={value} onChange={onChange} className={className} />;
|
||||
};
|
||||
|
||||
export default ResumeInput;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { styled, Switch } from '@mui/material';
|
||||
|
||||
const ThemeSwitch = styled(Switch)(({ theme }) => ({
|
||||
width: 62,
|
||||
height: 34,
|
||||
padding: 7,
|
||||
'& .MuiSwitch-switchBase': {
|
||||
margin: 1,
|
||||
padding: 0,
|
||||
transform: 'translateX(6px)',
|
||||
'&.Mui-checked': {
|
||||
color: '#fff',
|
||||
transform: 'translateX(22px)',
|
||||
'& .MuiSwitch-thumb:before': {
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff'
|
||||
)}" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>')`,
|
||||
},
|
||||
'& + .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
|
||||
width: 32,
|
||||
height: 32,
|
||||
'&:before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
left: 0,
|
||||
top: 0,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff'
|
||||
)}" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>')`,
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
borderRadius: 20 / 2,
|
||||
},
|
||||
}));
|
||||
|
||||
export default ThemeSwitch;
|
||||
Reference in New Issue
Block a user