refactor: use tabs instead of spaces

This commit is contained in:
Sonny
2024-10-07 01:33:59 +02:00
parent f425decf2c
commit eea9732100
197 changed files with 5206 additions and 5209 deletions

View File

@@ -10,18 +10,18 @@ import '../i18n/index';
const appName = import.meta.env.VITE_APP_NAME || 'MyLinks';
createInertiaApp({
progress: { color: primaryColor },
progress: { color: primaryColor },
title: (title) => `${appName}${title && ` - ${title}`}`,
title: (title) => `${appName}${title && ` - ${title}`}`,
resolve: (name) => {
return resolvePageComponent(
`../pages/${name}.tsx`,
import.meta.glob('../pages/**/*.tsx')
);
},
resolve: (name) => {
return resolvePageComponent(
`../pages/${name}.tsx`,
import.meta.glob('../pages/**/*.tsx')
);
},
setup({ el, App, props }) {
hydrateRoot(el, <App {...props} />);
},
setup({ el, App, props }) {
hydrateRoot(el, <App {...props} />);
},
});

View File

@@ -2,13 +2,13 @@ import { createInertiaApp } from '@inertiajs/react';
import ReactDOMServer from 'react-dom/server';
export default function render(page: any) {
return createInertiaApp({
page,
render: ReactDOMServer.renderToString,
resolve: (name) => {
const pages = import.meta.glob('../pages/**/*.tsx', { eager: true });
return pages[`../pages/${name}.tsx`];
},
setup: ({ App, props }) => <App {...props} />,
});
return createInertiaApp({
page,
render: ReactDOMServer.renderToString,
resolve: (name) => {
const pages = import.meta.glob('../pages/**/*.tsx', { eager: true });
return pages[`../pages/${name}.tsx`];
},
setup: ({ App, props }) => <App {...props} />,
});
}

View File

@@ -7,58 +7,58 @@ import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
const DropdownStyle = styled.div<{ opened: boolean; svgSize?: number }>(
({ opened, theme, svgSize = 24 }) => ({
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
minWidth: 'fit-content',
width: 'fit-content',
maxWidth: '250px',
backgroundColor: opened ? theme.colors.secondary : theme.colors.background,
padding: '4px',
borderRadius: theme.border.radius,
({ opened, theme, svgSize = 24 }) => ({
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
minWidth: 'fit-content',
width: 'fit-content',
maxWidth: '250px',
backgroundColor: opened ? theme.colors.secondary : theme.colors.background,
padding: '4px',
borderRadius: theme.border.radius,
'&:hover': {
backgroundColor: theme.colors.secondary,
},
'&:hover': {
backgroundColor: theme.colors.secondary,
},
'& svg': {
height: `${svgSize}px`,
width: `${svgSize}px`,
},
})
'& svg': {
height: `${svgSize}px`,
width: `${svgSize}px`,
},
})
);
export default function Dropdown({
children,
label,
className,
svgSize,
onClick,
children,
label,
className,
svgSize,
onClick,
}: HtmlHTMLAttributes<HTMLDivElement> & {
label: ReactNode | string;
className?: string;
svgSize?: number;
label: ReactNode | string;
className?: string;
svgSize?: number;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
const { isShowing, toggle, close } = useToggle();
const dropdownRef = useRef<HTMLDivElement>(null);
const { isShowing, toggle, close } = useToggle();
useClickOutside(dropdownRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
useClickOutside(dropdownRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
return (
<DropdownStyle
opened={isShowing}
onClick={(event) => {
onClick?.(event);
toggle();
}}
ref={dropdownRef}
className={className}
svgSize={svgSize}
>
<DropdownLabel>{label}</DropdownLabel>
<DropdownContainer show={isShowing}>{children}</DropdownContainer>
</DropdownStyle>
);
return (
<DropdownStyle
opened={isShowing}
onClick={(event) => {
onClick?.(event);
toggle();
}}
ref={dropdownRef}
className={className}
svgSize={svgSize}
>
<DropdownLabel>{label}</DropdownLabel>
<DropdownContainer show={isShowing}>{children}</DropdownContainer>
</DropdownStyle>
);
}

View File

@@ -2,20 +2,20 @@ import styled from '@emotion/styled';
import TransitionLayout from '~/components/layouts/_transition_layout';
const DropdownContainer = styled(TransitionLayout)<{ show: boolean }>(
({ show, theme }) => ({
zIndex: 99,
position: 'absolute',
top: 'calc(100% + 0.5em)',
right: 0,
minWidth: '175px',
backgroundColor: show ? theme.colors.secondary : theme.colors.background,
border: `2px solid ${theme.colors.secondary}`,
borderRadius: theme.border.radius,
boxShadow: theme.colors.boxShadow,
display: show ? 'flex' : 'none',
flexDirection: 'column',
overflow: 'hidden',
})
({ show, theme }) => ({
zIndex: 99,
position: 'absolute',
top: 'calc(100% + 0.5em)',
right: 0,
minWidth: '175px',
backgroundColor: show ? theme.colors.secondary : theme.colors.background,
border: `2px solid ${theme.colors.secondary}`,
borderRadius: theme.border.radius,
boxShadow: theme.colors.boxShadow,
display: show ? 'flex' : 'none',
flexDirection: 'column',
overflow: 'hidden',
})
);
export default DropdownContainer;

View File

@@ -2,30 +2,30 @@ import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
const DropdownItemBase = styled('div', {
shouldForwardProp: (propName) => propName !== 'danger',
shouldForwardProp: (propName) => propName !== 'danger',
})<{ danger?: boolean }>(({ theme, danger }) => ({
fontSize: '14px',
whiteSpace: 'nowrap',
color: danger ? theme.colors.lightRed : theme.colors.primary,
padding: '8px 12px',
borderRadius: theme.border.radius,
fontSize: '14px',
whiteSpace: 'nowrap',
color: danger ? theme.colors.lightRed : theme.colors.primary,
padding: '8px 12px',
borderRadius: theme.border.radius,
'&:hover': {
backgroundColor: theme.colors.background,
},
'&:hover': {
backgroundColor: theme.colors.background,
},
}));
const DropdownItemButton = styled(DropdownItemBase)({
display: 'flex',
gap: '0.75em',
alignItems: 'center',
display: 'flex',
gap: '0.75em',
alignItems: 'center',
});
const DropdownItemLink = styled(DropdownItemBase.withComponent(Link))({
width: '100%',
display: 'flex',
gap: '0.75em',
alignItems: 'center',
width: '100%',
display: 'flex',
gap: '0.75em',
alignItems: 'center',
});
export { DropdownItemButton, DropdownItemLink };

View File

@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
const DropdownLabel = styled.div(({ theme }) => ({
height: 'auto',
width: 'auto',
color: theme.colors.font,
display: 'flex',
gap: '0.35em',
height: 'auto',
width: 'auto',
color: theme.colors.font,
display: 'flex',
gap: '0.35em',
}));
export default DropdownLabel;

View File

@@ -1,18 +1,18 @@
import { AnchorHTMLAttributes, CSSProperties, ReactNode } from 'react';
export default function ExternalLink({
children,
title,
...props
children,
title,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
children: ReactNode;
style?: CSSProperties;
title?: string;
className?: string;
children: ReactNode;
style?: CSSProperties;
title?: string;
className?: string;
}) {
return (
<a target="_blank" rel="noreferrer" title={title} {...props}>
{children}
</a>
);
return (
<a target="_blank" rel="noreferrer" title={title} {...props}>
{children}
</a>
);
}

View File

@@ -1,31 +1,31 @@
import styled from '@emotion/styled';
const Button = styled.button<{ danger?: boolean }>(({ theme, danger }) => {
const btnColor = !danger ? theme.colors.primary : theme.colors.lightRed;
const btnDarkColor = !danger ? theme.colors.darkBlue : theme.colors.lightRed;
return {
cursor: 'pointer',
width: '100%',
textTransform: 'uppercase',
fontSize: '14px',
color: theme.colors.white,
background: btnColor,
padding: '0.75em',
border: `1px solid ${btnColor}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
const btnColor = !danger ? theme.colors.primary : theme.colors.lightRed;
const btnDarkColor = !danger ? theme.colors.darkBlue : theme.colors.lightRed;
return {
cursor: 'pointer',
width: '100%',
textTransform: 'uppercase',
fontSize: '14px',
color: theme.colors.white,
background: btnColor,
padding: '0.75em',
border: `1px solid ${btnColor}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
'&:disabled': {
cursor: 'not-allowed',
opacity: '0.5',
},
'&:disabled': {
cursor: 'not-allowed',
opacity: '0.5',
},
'&:not(:disabled):hover': {
boxShadow: `${btnDarkColor} 0 0 3px 1px`,
background: btnDarkColor,
color: theme.colors.white,
},
};
'&:not(:disabled):hover': {
boxShadow: `${btnDarkColor} 0 0 3px 1px`,
background: btnDarkColor,
color: theme.colors.white,
},
};
});
export default Button;

View File

@@ -1,10 +1,10 @@
import styled from '@emotion/styled';
const Form = styled.form({
width: '100%',
display: 'flex',
gap: '1em',
flexDirection: 'column',
width: '100%',
display: 'flex',
gap: '1em',
flexDirection: 'column',
});
export default Form;

View File

@@ -1,25 +1,25 @@
import styled from '@emotion/styled';
const FormField = styled('div', {
shouldForwardProp: (propName) => propName !== 'required',
shouldForwardProp: (propName) => propName !== 'required',
})<{ required?: boolean }>(({ required, theme }) => ({
display: 'flex',
gap: '0.25em',
flexDirection: 'column',
display: 'flex',
gap: '0.25em',
flexDirection: 'column',
'& label': {
position: 'relative',
userSelect: 'none',
width: 'fit-content',
},
'& label': {
position: 'relative',
userSelect: 'none',
width: 'fit-content',
},
'& label::after': {
position: 'absolute',
top: 0,
right: '-0.75em',
color: theme.colors.lightRed,
content: (required ? '"*"' : '""') as any,
},
'& label::after': {
position: 'absolute',
top: 0,
right: '-0.75em',
color: theme.colors.lightRed,
content: (required ? '"*"' : '""') as any,
},
}));
export default FormField;

View File

@@ -2,8 +2,8 @@ import styled from '@emotion/styled';
// TODO: create a global style variable (fontSize)
const FormFieldError = styled.p(({ theme }) => ({
fontSize: '12px',
color: theme.colors.lightRed,
fontSize: '12px',
color: theme.colors.lightRed,
}));
export default FormFieldError;

View File

@@ -1,27 +1,27 @@
import styled from '@emotion/styled';
const Input = styled.input(({ theme }) => ({
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
padding: '0.75em',
border: `1px solid ${theme.colors.lightGrey}`,
borderBottom: `2px solid ${theme.colors.lightGrey}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
padding: '0.75em',
border: `1px solid ${theme.colors.lightGrey}`,
borderBottom: `2px solid ${theme.colors.lightGrey}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
'&:focus': {
borderBottom: `2px solid ${theme.colors.primary}`,
},
'&:focus': {
borderBottom: `2px solid ${theme.colors.primary}`,
},
'&:disabled': {
opacity: 0.85,
},
'&:disabled': {
opacity: 0.85,
},
'&::placeholder': {
fontStyle: 'italic',
color: theme.colors.grey,
},
'&::placeholder': {
fontStyle: 'italic',
color: theme.colors.grey,
},
}));
export default Input;

View File

@@ -4,52 +4,52 @@ import FormField from '~/components/common/form/_form_field';
import FormFieldError from '~/components/common/form/_form_field_error';
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
checked: boolean;
errors?: string[];
onChange?: (name: string, checked: boolean) => void;
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
checked: boolean;
errors?: string[];
onChange?: (name: string, checked: boolean) => void;
}
export default function Checkbox({
name,
label,
checked = false,
errors = [],
onChange,
required = false,
...props
name,
label,
checked = false,
errors = [],
onChange,
required = false,
...props
}: InputProps): JSX.Element {
const [checkboxChecked, setCheckboxChecked] = useState<boolean>(checked);
const [checkboxChecked, setCheckboxChecked] = useState<boolean>(checked);
if (typeof window === 'undefined') return <Fragment />;
if (typeof window === 'undefined') return <Fragment />;
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setCheckboxChecked(target.checked);
if (onChange) {
onChange(target.name, target.checked);
}
}
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setCheckboxChecked(target.checked);
if (onChange) {
onChange(target.name, target.checked);
}
}
return (
<FormField
css={{ alignItems: 'center', gap: '1em', flexDirection: 'row' }}
required={required}
>
<label htmlFor={name} title={label}>
{label}
</label>
<Toggle
{...props}
onChange={_onChange}
checked={checkboxChecked}
placeholder={props.placeholder ?? 'Type something...'}
name={name}
id={name}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
return (
<FormField
css={{ alignItems: 'center', gap: '1em', flexDirection: 'row' }}
required={required}
>
<label htmlFor={name} title={label}>
{label}
</label>
<Toggle
{...props}
onChange={_onChange}
checked={checkboxChecked}
placeholder={props.placeholder ?? 'Type something...'}
name={name}
id={name}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
}

View File

@@ -1,79 +1,79 @@
import { useTheme } from '@emotion/react';
import { InputHTMLAttributes, ReactNode, useEffect, useState } from 'react';
import Select, {
FormatOptionLabelMeta,
GroupBase,
OptionsOrGroups,
FormatOptionLabelMeta,
GroupBase,
OptionsOrGroups,
} from 'react-select';
import FormField from '~/components/common/form/_form_field';
type Option = { label: string | number; value: string | number };
interface SelectorProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
errors?: string[];
options: OptionsOrGroups<Option, GroupBase<Option>>;
value: number | string;
onChangeCallback?: (value: number | string) => void;
formatOptionLabel?: (
data: Option,
formatOptionLabelMeta: FormatOptionLabelMeta<Option>
) => ReactNode;
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
errors?: string[];
options: OptionsOrGroups<Option, GroupBase<Option>>;
value: number | string;
onChangeCallback?: (value: number | string) => void;
formatOptionLabel?: (
data: Option,
formatOptionLabelMeta: FormatOptionLabelMeta<Option>
) => ReactNode;
}
export default function Selector({
name,
label,
value,
errors = [],
options,
onChangeCallback,
formatOptionLabel,
required = false,
...props
name,
label,
value,
errors = [],
options,
onChangeCallback,
formatOptionLabel,
required = false,
...props
}: SelectorProps): JSX.Element {
const theme = useTheme();
const [selectorValue, setSelectorValue] = useState<Option>();
const theme = useTheme();
const [selectorValue, setSelectorValue] = useState<Option>();
useEffect(() => {
if (options.length === 0) return;
useEffect(() => {
if (options.length === 0) return;
const option = options.find((o: any) => o.value === value);
if (option) {
setSelectorValue(option as Option);
}
}, [options, value]);
const option = options.find((o: any) => o.value === value);
if (option) {
setSelectorValue(option as Option);
}
}, [options, value]);
const handleChange = (selectedOption: Option) => {
setSelectorValue(selectedOption);
if (onChangeCallback) {
onChangeCallback(selectedOption.value);
}
};
const handleChange = (selectedOption: Option) => {
setSelectorValue(selectedOption);
if (onChangeCallback) {
onChangeCallback(selectedOption.value);
}
};
return (
<FormField required={required}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
<Select
value={selectorValue}
onChange={(newValue) => handleChange(newValue as Option)}
options={options}
isDisabled={props.disabled}
menuPlacement="auto"
formatOptionLabel={
formatOptionLabel
? (val, formatOptionLabelMeta) =>
formatOptionLabel(val, formatOptionLabelMeta)
: undefined
}
css={{ color: theme.colors.black }}
/>
</FormField>
);
return (
<FormField required={required}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
<Select
value={selectorValue}
onChange={(newValue) => handleChange(newValue as Option)}
options={options}
isDisabled={props.disabled}
menuPlacement="auto"
formatOptionLabel={
formatOptionLabel
? (val, formatOptionLabelMeta) =>
formatOptionLabel(val, formatOptionLabelMeta)
: undefined
}
css={{ color: theme.colors.black }}
/>
</FormField>
);
}

View File

@@ -4,46 +4,46 @@ import FormFieldError from '~/components/common/form/_form_field_error';
import Input from '~/components/common/form/_input';
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
value?: string;
errors?: string[];
onChange?: (name: string, value: string) => void;
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
value?: string;
errors?: string[];
onChange?: (name: string, value: string) => void;
}
export default function TextBox({
name,
label,
value = '',
errors = [],
onChange,
required = false,
...props
name,
label,
value = '',
errors = [],
onChange,
required = false,
...props
}: InputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(value);
const [inputValue, setInputValue] = useState<string>(value);
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setInputValue(target.value);
if (onChange) {
onChange(target.name, target.value);
}
}
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setInputValue(target.value);
if (onChange) {
onChange(target.name, target.value);
}
}
return (
<FormField required={required}>
<label htmlFor={name} title={label}>
{label}
</label>
<Input
{...props}
name={name}
onChange={_onChange}
value={inputValue}
placeholder={props.placeholder ?? 'Type something...'}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
return (
<FormField required={required}>
<label htmlFor={name} title={label}>
{label}
</label>
<Input
{...props}
name={name}
onChange={_onChange}
value={inputValue}
placeholder={props.placeholder ?? 'Type something...'}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
}

View File

@@ -1,22 +1,22 @@
import styled from '@emotion/styled';
const IconButton = styled.button(({ theme }) => ({
cursor: 'pointer',
height: '2rem',
width: '2rem',
fontSize: '1rem',
color: theme.colors.font,
backgroundColor: theme.colors.grey,
borderRadius: '50%',
border: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
height: '2rem',
width: '2rem',
fontSize: '1rem',
color: theme.colors.font,
backgroundColor: theme.colors.grey,
borderRadius: '50%',
border: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:disabled': {
cursor: 'not-allowed',
opacity: 0.15,
},
'&:disabled': {
cursor: 'not-allowed',
opacity: 0.15,
},
}));
export default IconButton;

View File

@@ -1,8 +1,8 @@
import styled from '@emotion/styled';
const Legend = styled.span(({ theme }) => ({
fontSize: '13px',
color: theme.colors.grey,
fontSize: '13px',
color: theme.colors.grey,
}));
export default Legend;

View File

@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
const ModalBody = styled.div({
width: '100%',
display: 'flex',
flex: 1,
alignItems: 'center',
flexDirection: 'column',
width: '100%',
display: 'flex',
flex: 1,
alignItems: 'center',
flexDirection: 'column',
});
export default ModalBody;

View File

@@ -2,23 +2,23 @@ import styled from '@emotion/styled';
import TransitionLayout from '~/components/layouts/_transition_layout';
const ModalContainer = styled(TransitionLayout)(({ theme }) => ({
minWidth: '500px',
background: theme.colors.background,
padding: '1em',
borderRadius: theme.border.radius,
marginTop: '6em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
boxShadow: theme.colors.boxShadow,
minWidth: '500px',
background: theme.colors.background,
padding: '1em',
borderRadius: theme.border.radius,
marginTop: '6em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
boxShadow: theme.colors.boxShadow,
[`@media (max-width: ${theme.media.mobile})`]: {
maxHeight: 'calc(100% - 2em)',
width: 'calc(100% - 2em)',
minWidth: 'unset',
marginTop: '1em',
},
[`@media (max-width: ${theme.media.mobile})`]: {
maxHeight: 'calc(100% - 2em)',
width: 'calc(100% - 2em)',
minWidth: 'unset',
marginTop: '1em',
},
}));
export default ModalContainer;

View File

@@ -1,20 +1,20 @@
import styled from '@emotion/styled';
const ModalHeader = styled.h3({
width: '100%',
marginBottom: '0.75em',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
marginBottom: '0.75em',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
const ModalCloseBtn = styled.button(({ theme }) => ({
cursor: 'pointer',
color: theme.colors.primary,
backgroundColor: 'transparent',
border: 0,
padding: 0,
margin: 0,
cursor: 'pointer',
color: theme.colors.primary,
backgroundColor: 'transparent',
border: 0,
padding: 0,
margin: 0,
}));
export { ModalHeader, ModalCloseBtn };

View File

@@ -2,18 +2,18 @@ import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const ModalWrapper = styled.div(({ theme }) => ({
zIndex: 9999,
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
background: rgba(theme.colors.black, 0.35),
backdropFilter: 'blur(0.1em)',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
transition: theme.transition.delay,
zIndex: 9999,
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
background: rgba(theme.colors.black, 0.35),
backdropFilter: 'blur(0.1em)',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
transition: theme.transition.delay,
}));
export default ModalWrapper;

View File

@@ -4,8 +4,8 @@ import { IoClose } from 'react-icons/io5';
import ModalBody from '~/components/common/modal/_modal_body';
import ModalContainer from '~/components/common/modal/_modal_container';
import {
ModalCloseBtn,
ModalHeader,
ModalCloseBtn,
ModalHeader,
} from '~/components/common/modal/_modal_header';
import ModalWrapper from '~/components/common/modal/_modal_wrapper';
import TextEllipsis from '~/components/common/text_ellipsis';
@@ -14,54 +14,54 @@ import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
import useShortcut from '~/hooks/use_shortcut';
interface ModalProps {
title?: string;
children: ReactNode;
opened: boolean;
hideCloseBtn?: boolean;
className?: string;
title?: string;
children: ReactNode;
opened: boolean;
hideCloseBtn?: boolean;
className?: string;
close: () => void;
close: () => void;
}
export default function Modal({
title,
children,
opened = true,
hideCloseBtn = false,
className,
close,
title,
children,
opened = true,
hideCloseBtn = false,
className,
close,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
const modalRef = useRef<HTMLDivElement>(null);
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
useClickOutside(modalRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
useClickOutside(modalRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
useEffect(() => setGlobalHotkeysEnabled(!opened), [opened]);
useEffect(() => setGlobalHotkeysEnabled(!opened), [opened]);
if (typeof window === 'undefined') {
return <Fragment />;
}
if (typeof window === 'undefined') {
return <Fragment />;
}
return (
opened &&
createPortal(
<ModalWrapper>
<ModalContainer className={className} ref={modalRef}>
{(!hideCloseBtn || title) && (
<ModalHeader>
{title && <TextEllipsis>{title}</TextEllipsis>}
{!hideCloseBtn && (
<ModalCloseBtn onClick={close}>
<IoClose size={20} />
</ModalCloseBtn>
)}
</ModalHeader>
)}
<ModalBody>{children}</ModalBody>
</ModalContainer>
</ModalWrapper>,
document.body
)
);
return (
opened &&
createPortal(
<ModalWrapper>
<ModalContainer className={className} ref={modalRef}>
{(!hideCloseBtn || title) && (
<ModalHeader>
{title && <TextEllipsis>{title}</TextEllipsis>}
{!hideCloseBtn && (
<ModalCloseBtn onClick={close}>
<IoClose size={20} />
</ModalCloseBtn>
)}
</ModalHeader>
)}
<ModalBody>{children}</ModalBody>
</ModalContainer>
</ModalWrapper>,
document.body
)
);
}

View File

@@ -6,12 +6,12 @@ import useShortcut from '~/hooks/use_shortcut';
import { appendCollectionId } from '~/lib/navigation';
export default function BackToDashboard({ children }: { children: ReactNode }) {
const collectionId = Number(useSearchParam('collectionId'));
useShortcut(
'ESCAPE_KEY',
() =>
router.visit(appendCollectionId(route('dashboard').url, collectionId)),
{ disableGlobalCheck: true }
);
return <>{children}</>;
const collectionId = Number(useSearchParam('collectionId'));
useShortcut(
'ESCAPE_KEY',
() =>
router.visit(appendCollectionId(route('dashboard').url, collectionId)),
{ disableGlobalCheck: true }
);
return <>{children}</>;
}

View File

@@ -2,14 +2,14 @@ import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const RoundedImage = styled.img(({ theme }) => {
const transparentBlack = rgba(theme.colors.black, 0.1);
return {
borderRadius: '50%',
const transparentBlack = rgba(theme.colors.black, 0.1);
return {
borderRadius: '50%',
'&:hover': {
boxShadow: `0 1px 3px 0 ${transparentBlack}, 0 1px 2px -1px ${transparentBlack}`,
},
};
'&:hover': {
boxShadow: `0 1px 3px 0 ${transparentBlack}, 0 1px 2px -1px ${transparentBlack}`,
},
};
});
export default RoundedImage;

View File

@@ -1,218 +1,218 @@
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
import IconButton from '~/components/common/icon_button';
import {
MdKeyboardArrowLeft,
MdKeyboardArrowRight,
MdKeyboardDoubleArrowLeft,
MdKeyboardDoubleArrowRight,
MdKeyboardArrowLeft,
MdKeyboardArrowRight,
MdKeyboardDoubleArrowLeft,
MdKeyboardDoubleArrowRight,
} from 'react-icons/md';
import Input from '~/components/common/form/_input';
const TablePageFooter = styled.div({
display: 'flex',
gap: '1em',
alignItems: 'center',
display: 'flex',
gap: '1em',
alignItems: 'center',
});
const Box = styled(TablePageFooter)({
gap: '0.35em',
gap: '0.35em',
});
const Resizer = styled.div<{ isResizing: boolean }>(
({ theme, isResizing }) => ({
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '5px',
opacity: !isResizing ? 0 : 1,
background: !isResizing ? theme.colors.white : theme.colors.primary,
'&:hover': {
opacity: 0.5,
},
})
({ theme, isResizing }) => ({
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '5px',
opacity: !isResizing ? 0 : 1,
background: !isResizing ? theme.colors.white : theme.colors.primary,
'&:hover': {
opacity: 0.5,
},
})
);
type TableProps<T> = {
columns: ColumnDef<T>[];
data: T[];
columns: ColumnDef<T>[];
data: T[];
};
export default function Table<T>({ columns, data }: TableProps<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
data,
columns,
enableColumnResizing: true,
columnResizeMode: 'onChange',
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: true,
});
const table = useReactTable({
data,
columns,
enableColumnResizing: true,
columnResizeMode: 'onChange',
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
debugTable: true,
});
return (
<div css={{ fontSize: '0.9rem', paddingBlock: '1em' }}>
<div
css={{
maxWidth: '100%',
marginBottom: '1em',
display: 'block',
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
}}
css={{
position: 'relative',
userSelect: 'none',
// resizer
'&:hover > div > div': {
opacity: 0.5,
},
}}
colSpan={header.colSpan}
>
{header.isPlaceholder ? null : (
<div
css={{
cursor: header.column.getCanSort()
? 'pointer'
: 'default',
}}
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getCanSort()
? header.column.getNextSortingOrder() === 'asc'
? 'Sort ascending'
: header.column.getNextSortingOrder() === 'desc'
? 'Sort descending'
: 'Clear sort'
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
{header.column.getCanResize() && (
<Resizer
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
isResizing={header.column.getIsResizing()}
/>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table
.getRowModel()
.rows.slice(0, 10)
.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{table.getPageCount() > 1 && (
<TablePageFooter>
<Box>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardDoubleArrowLeft />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardArrowLeft />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<MdKeyboardArrowRight />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<MdKeyboardDoubleArrowRight />
</IconButton>
</Box>
<Box>
<span>Page</span>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</Box>
<Box>
Go to page
<Input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</Box>
</TablePageFooter>
)}
</div>
);
return (
<div css={{ fontSize: '0.9rem', paddingBlock: '1em' }}>
<div
css={{
maxWidth: '100%',
marginBottom: '1em',
display: 'block',
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
}}
css={{
position: 'relative',
userSelect: 'none',
// resizer
'&:hover > div > div': {
opacity: 0.5,
},
}}
colSpan={header.colSpan}
>
{header.isPlaceholder ? null : (
<div
css={{
cursor: header.column.getCanSort()
? 'pointer'
: 'default',
}}
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getCanSort()
? header.column.getNextSortingOrder() === 'asc'
? 'Sort ascending'
: header.column.getNextSortingOrder() === 'desc'
? 'Sort descending'
: 'Clear sort'
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
{header.column.getCanResize() && (
<Resizer
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
isResizing={header.column.getIsResizing()}
/>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table
.getRowModel()
.rows.slice(0, 10)
.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{table.getPageCount() > 1 && (
<TablePageFooter>
<Box>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardDoubleArrowLeft />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardArrowLeft />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<MdKeyboardArrowRight />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<MdKeyboardDoubleArrowRight />
</IconButton>
</Box>
<Box>
<span>Page</span>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</Box>
<Box>
Go to page
<Input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</Box>
</TablePageFooter>
)}
</div>
);
}

View File

@@ -2,23 +2,23 @@ import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabItem = styled.li<{ active?: boolean; danger?: boolean }>(
({ theme, active, danger }) => {
const activeColor = !danger ? theme.colors.primary : theme.colors.lightRed;
return {
userSelect: 'none',
cursor: 'pointer',
backgroundColor: active
? rgba(activeColor, 0.15)
: theme.colors.secondary,
padding: '10px 20px',
border: `1px solid ${active ? rgba(activeColor, 0.1) : theme.colors.secondary}`,
borderBottom: `1px solid ${active ? rgba(activeColor, 0.25) : theme.colors.secondary}`,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
transition: '.075s',
};
}
({ theme, active, danger }) => {
const activeColor = !danger ? theme.colors.primary : theme.colors.lightRed;
return {
userSelect: 'none',
cursor: 'pointer',
backgroundColor: active
? rgba(activeColor, 0.15)
: theme.colors.secondary,
padding: '10px 20px',
border: `1px solid ${active ? rgba(activeColor, 0.1) : theme.colors.secondary}`,
borderBottom: `1px solid ${active ? rgba(activeColor, 0.25) : theme.colors.secondary}`,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
transition: '.075s',
};
}
);
export default TabItem;

View File

@@ -1,10 +1,10 @@
import styled from '@emotion/styled';
const TabList = styled.ul({
padding: 0,
margin: 0,
display: 'flex',
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
listStyle: 'none',
});
export default TabList;

View File

@@ -2,11 +2,11 @@ import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabPanel = styled.div(({ theme }) => ({
zIndex: 1,
position: 'relative',
border: `1px solid ${rgba(theme.colors.primary, 0.25)}`,
padding: '20px',
marginTop: '-1px',
zIndex: 1,
position: 'relative',
border: `1px solid ${rgba(theme.colors.primary, 0.25)}`,
padding: '20px',
marginTop: '-1px',
}));
export default TabPanel;

View File

@@ -6,37 +6,37 @@ import TabPanel from '~/components/common/tabs/tab_panel';
import TransitionLayout from '~/components/layouts/_transition_layout';
export interface Tab {
title: string;
content: ReactNode;
icon?: IconType;
danger?: boolean;
title: string;
content: ReactNode;
icon?: IconType;
danger?: boolean;
}
export default function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const handleTabClick = (index: number) => {
setActiveTabIndex(index);
};
const handleTabClick = (index: number) => {
setActiveTabIndex(index);
};
return (
<div css={{ width: '100%' }}>
<TabList>
{tabs.map(({ title, icon: Icon, danger }, index) => (
<TabItem
key={index}
active={index === activeTabIndex}
onClick={() => handleTabClick(index)}
danger={danger ?? false}
>
{!!Icon && <Icon size={20} />}
{title}
</TabItem>
))}
</TabList>
<TabPanel key={tabs[activeTabIndex].title}>
<TransitionLayout>{tabs[activeTabIndex].content}</TransitionLayout>
</TabPanel>
</div>
);
return (
<div css={{ width: '100%' }}>
<TabList>
{tabs.map(({ title, icon: Icon, danger }, index) => (
<TabItem
key={index}
active={index === activeTabIndex}
onClick={() => handleTabClick(index)}
danger={danger ?? false}
>
{!!Icon && <Icon size={20} />}
{title}
</TabItem>
))}
</TabList>
<TabPanel key={tabs[activeTabIndex].title}>
<TransitionLayout>{tabs[activeTabIndex].content}</TransitionLayout>
</TabPanel>
</div>
);
}

View File

@@ -1,20 +1,20 @@
import styled from '@emotion/styled';
const TextEllipsis = styled.p<{ lines?: number }>(({ lines = 1 }) => {
if (lines > 1) {
return {
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: lines,
WebkitBoxOrient: 'vertical',
};
}
if (lines > 1) {
return {
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: lines,
WebkitBoxOrient: 'vertical',
};
}
return {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
};
return {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
};
});
export default TextEllipsis;

View File

@@ -1,12 +1,12 @@
import styled from '@emotion/styled';
const UnstyledList = styled.ul({
'&, & li': {
listStyleType: 'none',
margin: 0,
padding: 0,
border: 0,
},
'&, & li': {
listStyleType: 'none',
margin: 0,
padding: 0,
border: 0,
},
});
export default UnstyledList;

View File

@@ -7,33 +7,33 @@ import Footer from '~/components/footer/footer';
import useActiveCollection from '~/hooks/use_active_collection';
export interface CollectionHeaderProps {
showButtons: boolean;
showControls?: boolean;
openNavigationItem?: ReactNode;
openCollectionItem?: ReactNode;
showButtons: boolean;
showControls?: boolean;
openNavigationItem?: ReactNode;
openCollectionItem?: ReactNode;
}
const CollectionContainerStyle = styled.div({
height: '100%',
minWidth: 0,
padding: '0.5em 0.5em 0',
display: 'flex',
flex: 1,
flexDirection: 'column',
height: '100%',
minWidth: 0,
padding: '0.5em 0.5em 0',
display: 'flex',
flex: 1,
flexDirection: 'column',
});
export default function CollectionContainer(props: CollectionHeaderProps) {
const { activeCollection } = useActiveCollection();
const { activeCollection } = useActiveCollection();
if (activeCollection === null) {
return <NoCollection />;
}
if (activeCollection === null) {
return <NoCollection />;
}
return (
<CollectionContainerStyle>
<CollectionHeader {...props} />
<LinkList links={activeCollection.links} />
<Footer css={{ paddingBottom: 0 }} />
</CollectionContainerStyle>
);
return (
<CollectionContainerStyle>
<CollectionHeader {...props} />
<LinkList links={activeCollection.links} />
<Footer css={{ paddingBottom: 0 }} />
</CollectionContainerStyle>
);
}

View File

@@ -10,33 +10,33 @@ import { appendCollectionId } from '~/lib/navigation';
import { Collection } from '~/types/app';
export default function CollectionControls({
collectionId,
collectionId,
}: {
collectionId: Collection['id'];
collectionId: Collection['id'];
}) {
const { t } = useTranslation('common');
return (
<Dropdown label={<BsThreeDotsVertical />} svgSize={18}>
<DropdownItemLink href={route('link.create-form').url}>
<IoIosAddCircleOutline /> {t('link.create')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.edit-form').url,
collectionId
)}
>
<GoPencil /> {t('collection.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.delete-form').url,
collectionId
)}
danger
>
<IoTrashOutline /> {t('collection.delete')}
</DropdownItemLink>
</Dropdown>
);
const { t } = useTranslation('common');
return (
<Dropdown label={<BsThreeDotsVertical />} svgSize={18}>
<DropdownItemLink href={route('link.create-form').url}>
<IoIosAddCircleOutline /> {t('link.create')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.edit-form').url,
collectionId
)}
>
<GoPencil /> {t('collection.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.delete-form').url,
collectionId
)}
danger
>
<IoTrashOutline /> {t('collection.delete')}
</DropdownItemLink>
</Dropdown>
);
}

View File

@@ -3,16 +3,16 @@ import TextEllipsis from '~/components/common/text_ellipsis';
import useActiveCollection from '~/hooks/use_active_collection';
const CollectionDescriptionStyle = styled.div({
width: '100%',
fontSize: '0.85rem',
marginBottom: '0.5rem',
width: '100%',
fontSize: '0.85rem',
marginBottom: '0.5rem',
});
export default function CollectionDescription() {
const { activeCollection } = useActiveCollection();
return (
<CollectionDescriptionStyle>
<TextEllipsis lines={3}>{activeCollection!.description}</TextEllipsis>
</CollectionDescriptionStyle>
);
const { activeCollection } = useActiveCollection();
return (
<CollectionDescriptionStyle>
<TextEllipsis lines={3}>{activeCollection!.description}</TextEllipsis>
</CollectionDescriptionStyle>
);
}

View File

@@ -12,76 +12,76 @@ const paddingLeft = '1.25em';
const paddingRight = '1.65em';
const CollectionHeaderWrapper = styled.div(({ theme }) => ({
minWidth: 0,
width: '100%',
paddingInline: `${paddingLeft} ${paddingRight}`,
marginBottom: '0.5em',
minWidth: 0,
width: '100%',
paddingInline: `${paddingLeft} ${paddingRight}`,
marginBottom: '0.5em',
[`@media (max-width: ${theme.media.tablet})`]: {
paddingInline: 0,
marginBottom: '1rem',
},
[`@media (max-width: ${theme.media.tablet})`]: {
paddingInline: 0,
marginBottom: '1rem',
},
}));
const CollectionHeaderStyle = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
'& > svg': {
display: 'flex',
},
'& > svg': {
display: 'flex',
},
});
const CollectionName = styled.h2(({ theme }) => ({
width: `calc(100% - (${paddingLeft} + ${paddingRight}))`,
color: theme.colors.primary,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
width: `calc(100% - (${paddingLeft} + ${paddingRight}))`,
color: theme.colors.primary,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
[`@media (max-width: ${theme.media.tablet})`]: {
width: `calc(100% - (${paddingLeft} + ${paddingRight} + 1.75em))`,
},
[`@media (max-width: ${theme.media.tablet})`]: {
width: `calc(100% - (${paddingLeft} + ${paddingRight} + 1.75em))`,
},
}));
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.8em',
color: theme.colors.grey,
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.8em',
color: theme.colors.grey,
}));
export default function CollectionHeader({
showButtons,
showControls = true,
openNavigationItem,
openCollectionItem,
showButtons,
showControls = true,
openNavigationItem,
openCollectionItem,
}: CollectionHeaderProps) {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
if (!activeCollection) return <Fragment />;
if (!activeCollection) return <Fragment />;
const { name, links, visibility } = activeCollection;
return (
<CollectionHeaderWrapper>
<CollectionHeaderStyle>
{showButtons && openNavigationItem && openNavigationItem}
<CollectionName title={name}>
<TextEllipsis>{name}</TextEllipsis>
{links.length > 0 && <LinksCount> {links.length}</LinksCount>}
<VisibilityBadge
label={t('collection.visibility')}
visibility={visibility}
/>
</CollectionName>
{showControls && (
<CollectionControls collectionId={activeCollection.id} />
)}
{showButtons && openCollectionItem && openCollectionItem}
</CollectionHeaderStyle>
{activeCollection.description && <CollectionDescription />}
</CollectionHeaderWrapper>
);
const { name, links, visibility } = activeCollection;
return (
<CollectionHeaderWrapper>
<CollectionHeaderStyle>
{showButtons && openNavigationItem && openNavigationItem}
<CollectionName title={name}>
<TextEllipsis>{name}</TextEllipsis>
{links.length > 0 && <LinksCount> {links.length}</LinksCount>}
<VisibilityBadge
label={t('collection.visibility')}
visibility={visibility}
/>
</CollectionName>
{showControls && (
<CollectionControls collectionId={activeCollection.id} />
)}
{showButtons && openCollectionItem && openCollectionItem}
</CollectionHeaderStyle>
{activeCollection.description && <CollectionDescription />}
</CollectionHeaderWrapper>
);
}

View File

@@ -10,48 +10,48 @@ import { appendCollectionId } from '~/lib/navigation';
import { CollectionWithLinks } from '~/types/app';
const CollectionItemStyle = styled(Item, {
shouldForwardProp: (propName) => propName !== 'isActive',
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
cursor: 'pointer',
color: isActive ? theme.colors.primary : theme.colors.font,
backgroundColor: theme.colors.secondary,
cursor: 'pointer',
color: isActive ? theme.colors.primary : theme.colors.font,
backgroundColor: theme.colors.secondary,
}));
const CollectionItemLink = CollectionItemStyle.withComponent(Link);
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.9rem',
color: theme.colors.grey,
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.9rem',
color: theme.colors.grey,
}));
export default function CollectionItem({
collection,
collection,
}: {
collection: CollectionWithLinks;
collection: CollectionWithLinks;
}) {
const itemRef = useRef<HTMLDivElement>(null);
const { activeCollection } = useActiveCollection();
const isActiveCollection = collection.id === activeCollection?.id;
const FolderIcon = isActiveCollection ? AiFillFolderOpen : AiOutlineFolder;
const itemRef = useRef<HTMLDivElement>(null);
const { activeCollection } = useActiveCollection();
const isActiveCollection = collection.id === activeCollection?.id;
const FolderIcon = isActiveCollection ? AiFillFolderOpen : AiOutlineFolder;
useEffect(() => {
if (collection.id === activeCollection?.id) {
itemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [collection.id, activeCollection?.id]);
useEffect(() => {
if (collection.id === activeCollection?.id) {
itemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [collection.id, activeCollection?.id]);
return (
<CollectionItemLink
href={appendCollectionId(route('dashboard').url, collection.id)}
isActive={isActiveCollection}
ref={itemRef}
>
<FolderIcon css={{ minWidth: '24px' }} size={24} />
<TextEllipsis>{collection.name}</TextEllipsis>
{collection.links.length > 0 && (
<LinksCount> {collection.links.length}</LinksCount>
)}
</CollectionItemLink>
);
return (
<CollectionItemLink
href={appendCollectionId(route('dashboard').url, collection.id)}
isActive={isActiveCollection}
ref={itemRef}
>
<FolderIcon css={{ minWidth: '24px' }} size={24} />
<TextEllipsis>{collection.name}</TextEllipsis>
{collection.links.length > 0 && (
<LinksCount> {collection.links.length}</LinksCount>
)}
</CollectionItemLink>
);
}

View File

@@ -7,76 +7,76 @@ import useCollections from '~/hooks/use_collections';
import useShortcut from '~/hooks/use_shortcut';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
paddingLeft: '10px',
marginLeft: '5px',
borderLeft: `1px solid ${theme.colors.lightGrey}`,
display: 'flex',
gap: '.35em',
flexDirection: 'column',
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
paddingLeft: '10px',
marginLeft: '5px',
borderLeft: `1px solid ${theme.colors.lightGrey}`,
display: 'flex',
gap: '.35em',
flexDirection: 'column',
}));
const CollectionLabel = styled.p(({ theme }) => ({
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
}));
const CollectionListStyle = styled.div({
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
});
export default function CollectionList() {
const { t } = useTranslation('common');
const { collections } = useCollections();
const { activeCollection, setActiveCollection } = useActiveCollection();
const { t } = useTranslation('common');
const { collections } = useCollections();
const { activeCollection, setActiveCollection } = useActiveCollection();
const goToPreviousCollection = () => {
const currentCategoryIndex = collections.findIndex(
({ id }) => id === activeCollection?.id
);
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
const goToPreviousCollection = () => {
const currentCategoryIndex = collections.findIndex(
({ id }) => id === activeCollection?.id
);
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
setActiveCollection(collections[currentCategoryIndex - 1]);
};
setActiveCollection(collections[currentCategoryIndex - 1]);
};
const goToNextCollection = () => {
const currentCategoryIndex = collections.findIndex(
({ id }) => id === activeCollection?.id
);
if (
currentCategoryIndex === -1 ||
currentCategoryIndex === collections.length - 1
)
return;
const goToNextCollection = () => {
const currentCategoryIndex = collections.findIndex(
({ id }) => id === activeCollection?.id
);
if (
currentCategoryIndex === -1 ||
currentCategoryIndex === collections.length - 1
)
return;
setActiveCollection(collections[currentCategoryIndex + 1]);
};
setActiveCollection(collections[currentCategoryIndex + 1]);
};
useShortcut('ARROW_UP', goToPreviousCollection);
useShortcut('ARROW_DOWN', goToNextCollection);
useShortcut('ARROW_UP', goToPreviousCollection);
useShortcut('ARROW_DOWN', goToNextCollection);
return (
<SideMenu>
<CollectionListContainer>
<CollectionLabel>
{t('collection.collections', { count: collections.length })} {' '}
{collections.length}
</CollectionLabel>
<CollectionListStyle>
{collections.map((collection) => (
<CollectionItem collection={collection} key={collection.id} />
))}
</CollectionListStyle>
</CollectionListContainer>
</SideMenu>
);
return (
<SideMenu>
<CollectionListContainer>
<CollectionLabel>
{t('collection.collections', { count: collections.length })} {' '}
{collections.length}
</CollectionLabel>
<CollectionListStyle>
{collections.map((collection) => (
<CollectionItem collection={collection} key={collection.id} />
))}
</CollectionListStyle>
</CollectionListContainer>
</SideMenu>
);
}

View File

@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
const CollectionListContainer = styled.div({
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
});
export default CollectionListContainer;

View File

@@ -10,86 +10,86 @@ import { appendCollectionId } from '~/lib/navigation';
import { CollectionWithLinks, LinkWithCollection } from '~/types/app';
export default function DashboardProviders(
props: Readonly<{
children: ReactNode;
collections: CollectionWithLinks[];
activeCollection: CollectionWithLinks;
}>
props: Readonly<{
children: ReactNode;
collections: CollectionWithLinks[];
activeCollection: CollectionWithLinks;
}>
) {
const [globalHotkeysEnabled, setGlobalHotkeysEnabled] =
useState<boolean>(true);
const [collections, setCollections] = useState<CollectionWithLinks[]>(
props.collections
);
const [activeCollection, setActiveCollection] =
useState<CollectionWithLinks | null>(
props.activeCollection || collections?.[0]
);
const [globalHotkeysEnabled, setGlobalHotkeysEnabled] =
useState<boolean>(true);
const [collections, setCollections] = useState<CollectionWithLinks[]>(
props.collections
);
const [activeCollection, setActiveCollection] =
useState<CollectionWithLinks | null>(
props.activeCollection || collections?.[0]
);
const handleChangeCollection = (collection: CollectionWithLinks) => {
setActiveCollection(collection);
router.visit(appendCollectionId(route('dashboard').url, collection.id));
};
const handleChangeCollection = (collection: CollectionWithLinks) => {
setActiveCollection(collection);
router.visit(appendCollectionId(route('dashboard').url, collection.id));
};
// TODO: compute this in controller
const favorites = useMemo<LinkWithCollection[]>(
() =>
collections.reduce((acc, collection) => {
collection.links.forEach((link) => {
if (link.favorite) {
const newLink: LinkWithCollection = { ...link, collection };
acc.push(newLink);
}
});
return acc;
}, [] as LinkWithCollection[]),
[collections]
);
// TODO: compute this in controller
const favorites = useMemo<LinkWithCollection[]>(
() =>
collections.reduce((acc, collection) => {
collection.links.forEach((link) => {
if (link.favorite) {
const newLink: LinkWithCollection = { ...link, collection };
acc.push(newLink);
}
});
return acc;
}, [] as LinkWithCollection[]),
[collections]
);
const collectionsContextValue = useMemo(
() => ({ collections, setCollections }),
[collections]
);
const activeCollectionContextValue = useMemo(
() => ({ activeCollection, setActiveCollection: handleChangeCollection }),
[activeCollection, handleChangeCollection]
);
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled]
);
const collectionsContextValue = useMemo(
() => ({ collections, setCollections }),
[collections]
);
const activeCollectionContextValue = useMemo(
() => ({ activeCollection, setActiveCollection: handleChangeCollection }),
[activeCollection, handleChangeCollection]
);
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled]
);
useShortcut(
'OPEN_CREATE_LINK_KEY',
() =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut(
'OPEN_CREATE_COLLECTION_KEY',
() => router.visit(route('collection.create-form').url),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut(
'OPEN_CREATE_LINK_KEY',
() =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut(
'OPEN_CREATE_COLLECTION_KEY',
() => router.visit(route('collection.create-form').url),
{
enabled: globalHotkeysEnabled,
}
);
return (
<CollectionsContext.Provider value={collectionsContextValue}>
<ActiveCollectionContext.Provider value={activeCollectionContextValue}>
<FavoritesContext.Provider value={favoritesContextValue}>
<GlobalHotkeysContext.Provider value={globalHotkeysContextValue}>
{props.children}
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCollectionContext.Provider>
</CollectionsContext.Provider>
);
return (
<CollectionsContext.Provider value={collectionsContextValue}>
<ActiveCollectionContext.Provider value={activeCollectionContextValue}>
<FavoritesContext.Provider value={favoritesContextValue}>
<GlobalHotkeysContext.Provider value={globalHotkeysContextValue}>
{props.children}
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCollectionContext.Provider>
</CollectionsContext.Provider>
);
}

View File

@@ -11,27 +11,27 @@ import { appendLinkId } from '~/lib/navigation';
import { Link } from '~/types/app';
export default function LinkControls({ link }: { link: Link }) {
const theme = useTheme();
const { t } = useTranslation('common');
const theme = useTheme();
const { t } = useTranslation('common');
return (
<Dropdown
label={<BsThreeDotsVertical css={{ color: theme.colors.grey }} />}
css={{ backgroundColor: theme.colors.secondary }}
svgSize={18}
>
<FavoriteDropdownItem link={link} />
<DropdownItemLink
href={appendLinkId(route('link.edit-form').url, link.id)}
>
<GoPencil /> {t('link.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendLinkId(route('link.delete-form').url, link.id)}
danger
>
<IoTrashOutline /> {t('link.delete')}
</DropdownItemLink>
</Dropdown>
);
return (
<Dropdown
label={<BsThreeDotsVertical css={{ color: theme.colors.grey }} />}
css={{ backgroundColor: theme.colors.secondary }}
svgSize={18}
>
<FavoriteDropdownItem link={link} />
<DropdownItemLink
href={appendLinkId(route('link.edit-form').url, link.id)}
>
<GoPencil /> {t('link.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendLinkId(route('link.delete-form').url, link.id)}
danger
>
<IoTrashOutline /> {t('link.delete')}
</DropdownItemLink>
</Dropdown>
);
}

View File

@@ -7,76 +7,76 @@ import { rotate } from '~/styles/keyframes';
const IMG_LOAD_TIMEOUT = 7_500;
interface LinkFaviconProps {
url: string;
size?: number;
url: string;
size?: number;
}
const Favicon = styled.div({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
const FaviconLoader = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
position: 'absolute',
top: 0,
left: 0,
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
'& > *': {
animation: `${rotate} 1s both reverse infinite linear`,
},
'& > *': {
animation: `${rotate} 1s both reverse infinite linear`,
},
}));
// The Favicon API should always return an image, so it's not really useful to keep the loader nor placeholder icon,
// but for slow connections and other random stuff, I'll keep this
export default function LinkFavicon({ url, size = 32 }: LinkFaviconProps) {
const imgRef = useRef<HTMLImageElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [isFailed, setFailed] = useState<boolean>(false);
const [isLoading, setLoading] = useState<boolean>(true);
const [isFailed, setFailed] = useState<boolean>(false);
const [isLoading, setLoading] = useState<boolean>(true);
const setFallbackFavicon = () => setFailed(true);
const handleStopLoading = () => setLoading(false);
const setFallbackFavicon = () => setFailed(true);
const handleStopLoading = () => setLoading(false);
const handleErrorLoading = () => {
setFallbackFavicon();
handleStopLoading();
};
const handleErrorLoading = () => {
setFallbackFavicon();
handleStopLoading();
};
useEffect(() => {
// Ugly hack, onLoad cb not triggered on first load when SSR
if (imgRef.current?.complete) {
handleStopLoading();
return;
}
const id = setTimeout(() => handleErrorLoading(), IMG_LOAD_TIMEOUT);
return () => clearTimeout(id);
}, [isLoading]);
useEffect(() => {
// Ugly hack, onLoad cb not triggered on first load when SSR
if (imgRef.current?.complete) {
handleStopLoading();
return;
}
const id = setTimeout(() => handleErrorLoading(), IMG_LOAD_TIMEOUT);
return () => clearTimeout(id);
}, [isLoading]);
return (
<Favicon>
{!isFailed ? (
<img
src={`/favicon?url=${url}`}
onError={handleErrorLoading}
onLoad={handleStopLoading}
height={size}
width={size}
alt="icon"
ref={imgRef}
decoding="async"
/>
) : (
<TfiWorld size={size} />
)}
{isLoading && (
<FaviconLoader style={{ height: `${size}px`, width: `${size}px` }}>
<TbLoader3 size={size} />
</FaviconLoader>
)}
</Favicon>
);
return (
<Favicon>
{!isFailed ? (
<img
src={`/favicon?url=${url}`}
onError={handleErrorLoading}
onLoad={handleStopLoading}
height={size}
width={size}
alt="icon"
ref={imgRef}
decoding="async"
/>
) : (
<TfiWorld size={size} />
)}
{isLoading && (
<FaviconLoader style={{ height: `${size}px`, width: `${size}px` }}>
<TbLoader3 size={size} />
</FaviconLoader>
)}
</Favicon>
);
}

View File

@@ -6,121 +6,121 @@ import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { Link } from '~/types/app';
const LinkWrapper = styled.li(({ theme }) => ({
userSelect: 'none',
cursor: 'pointer',
height: 'fit-content',
width: '100%',
color: theme.colors.primary,
backgroundColor: theme.colors.secondary,
padding: '0.75em 1em',
borderRadius: theme.border.radius,
userSelect: 'none',
cursor: 'pointer',
height: 'fit-content',
width: '100%',
color: theme.colors.primary,
backgroundColor: theme.colors.secondary,
padding: '0.75em 1em',
borderRadius: theme.border.radius,
'&:hover': {
outlineWidth: '1px',
outlineStyle: 'solid',
},
'&:hover': {
outlineWidth: '1px',
outlineStyle: 'solid',
},
}));
const LinkHeader = styled.div(({ theme }) => ({
display: 'flex',
gap: '1em',
alignItems: 'center',
display: 'flex',
gap: '1em',
alignItems: 'center',
'& > a': {
height: '100%',
maxWidth: 'calc(100% - 75px)', // TODO: fix this, it is ugly af :(
textDecoration: 'none',
display: 'flex',
flex: 1,
flexDirection: 'column',
transition: theme.transition.delay,
'& > a': {
height: '100%',
maxWidth: 'calc(100% - 75px)', // TODO: fix this, it is ugly af :(
textDecoration: 'none',
display: 'flex',
flex: 1,
flexDirection: 'column',
transition: theme.transition.delay,
'&, &:hover': {
border: 0,
},
},
'&, &:hover': {
border: 0,
},
},
}));
const LinkName = styled.div({
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
const LinkDescription = styled.div(({ theme }) => ({
marginTop: '0.5em',
color: theme.colors.font,
fontSize: '0.8em',
wordWrap: 'break-word',
marginTop: '0.5em',
color: theme.colors.font,
fontSize: '0.8em',
wordWrap: 'break-word',
}));
const LinkUrl = styled.span(({ theme }) => ({
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
color: theme.colors.grey,
fontSize: '0.8em',
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
color: theme.colors.grey,
fontSize: '0.8em',
}));
const StarIcon = styled(AiFillStar)(({ theme }) => ({
color: theme.colors.yellow,
color: theme.colors.yellow,
}));
const LinkUrlPathname = styled.span({
opacity: 0,
opacity: 0,
});
export default function LinkItem({
link,
showUserControls = false,
link,
showUserControls = false,
}: {
link: Link;
showUserControls: boolean;
link: Link;
showUserControls: boolean;
}) {
const { id, name, url, description, favorite } = link;
return (
<LinkWrapper key={id} title={name}>
<LinkHeader>
<LinkFavicon url={url} />
<ExternalLink href={url} className="reset">
<LinkName>
{name} {showUserControls && favorite && <StarIcon />}
</LinkName>
<LinkItemURL url={url} />
</ExternalLink>
{showUserControls && <LinkControls link={link} />}
</LinkHeader>
{description && <LinkDescription>{description}</LinkDescription>}
</LinkWrapper>
);
const { id, name, url, description, favorite } = link;
return (
<LinkWrapper key={id} title={name}>
<LinkHeader>
<LinkFavicon url={url} />
<ExternalLink href={url} className="reset">
<LinkName>
{name} {showUserControls && favorite && <StarIcon />}
</LinkName>
<LinkItemURL url={url} />
</ExternalLink>
{showUserControls && <LinkControls link={link} />}
</LinkHeader>
{description && <LinkDescription>{description}</LinkDescription>}
</LinkWrapper>
);
}
function LinkItemURL({ url }: { url: Link['url'] }) {
try {
const { origin, pathname, search } = new URL(url);
let text = '';
try {
const { origin, pathname, search } = new URL(url);
let text = '';
if (pathname !== '/') {
text += pathname;
}
if (pathname !== '/') {
text += pathname;
}
if (search !== '') {
if (text === '') {
text += '/';
}
text += search;
}
if (search !== '') {
if (text === '') {
text += '/';
}
text += search;
}
return (
<LinkUrl>
{origin}
<LinkUrlPathname>{text}</LinkUrlPathname>
</LinkUrl>
);
} catch (error) {
console.error('error', error);
return <LinkUrl>{url}</LinkUrl>;
}
return (
<LinkUrl>
{origin}
<LinkUrlPathname>{text}</LinkUrlPathname>
</LinkUrl>
);
} catch (error) {
console.error('error', error);
return <LinkUrl>{url}</LinkUrl>;
}
}

View File

@@ -5,34 +5,34 @@ import { sortByCreationDate } from '~/lib/array';
import { Link } from '~/types/app';
const LinkListStyle = styled.ul({
height: '100%',
width: '100%',
minWidth: 0,
display: 'flex',
flex: 1,
gap: '0.5em',
padding: '3px',
flexDirection: 'column',
overflowX: 'hidden',
overflowY: 'scroll',
height: '100%',
width: '100%',
minWidth: 0,
display: 'flex',
flex: 1,
gap: '0.5em',
padding: '3px',
flexDirection: 'column',
overflowX: 'hidden',
overflowY: 'scroll',
});
export default function LinkList({
links,
showControls = true,
links,
showControls = true,
}: {
links: Link[];
showControls?: boolean;
links: Link[];
showControls?: boolean;
}) {
if (links.length === 0) {
return <NoLink />;
}
if (links.length === 0) {
return <NoLink />;
}
return (
<LinkListStyle>
{sortByCreationDate(links).map((link) => (
<LinkItem link={link} key={link.id} showUserControls={showControls} />
))}
</LinkListStyle>
);
return (
<LinkListStyle>
{sortByCreationDate(links).map((link) => (
<LinkItem link={link} key={link.id} showUserControls={showControls} />
))}
</LinkListStyle>
);
}

View File

@@ -7,60 +7,60 @@ import { appendCollectionId } from '~/lib/navigation';
import { fadeIn } from '~/styles/keyframes';
const NoCollectionStyle = styled.div({
minWidth: 0,
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
animation: `${fadeIn} 0.3s both`,
minWidth: 0,
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
animation: `${fadeIn} 0.3s both`,
});
const Text = styled.p({
minWidth: 0,
width: '100%',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
minWidth: 0,
width: '100%',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export function NoCollection() {
const { t } = useTranslation('home');
return (
<NoCollectionStyle>
<Text>{t('select-collection')}</Text>
<Link href={route('collection.create-form').url}>
{t('or-create-one')}
</Link>
</NoCollectionStyle>
);
const { t } = useTranslation('home');
return (
<NoCollectionStyle>
<Text>{t('select-collection')}</Text>
<Link href={route('collection.create-form').url}>
{t('or-create-one')}
</Link>
</NoCollectionStyle>
);
}
export function NoLink() {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<NoCollectionStyle>
<Text
dangerouslySetInnerHTML={{
__html: t(
'home:no-link',
{ name: activeCollection?.name ?? '' } as any,
{
interpolation: { escapeValue: false },
}
),
}}
/>
<Link
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
{t('link.create')}
</Link>
</NoCollectionStyle>
);
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<NoCollectionStyle>
<Text
dangerouslySetInnerHTML={{
__html: t(
'home:no-link',
{ name: activeCollection?.name ?? '' } as any,
{
interpolation: { escapeValue: false },
}
),
}}
/>
<Link
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
{t('link.create')}
</Link>
</NoCollectionStyle>
);
}

View File

@@ -3,27 +3,27 @@ import { useTranslation } from 'react-i18next';
import { FcGoogle } from 'react-icons/fc';
const NoSearchResultStyle = styled.i({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25em',
'& > span': {
display: 'flex',
alignItems: 'center',
},
'& > span': {
display: 'flex',
alignItems: 'center',
},
});
export default function NoSearchResult() {
const { t } = useTranslation('common');
return (
<NoSearchResultStyle>
{t('search-with')}
{''}
<span>
<FcGoogle size={20} />
oogle
</span>
</NoSearchResultStyle>
);
const { t } = useTranslation('common');
return (
<NoSearchResultStyle>
{t('search-with')}
{''}
<span>
<FcGoogle size={20} />
oogle
</span>
</NoSearchResultStyle>
);
}

View File

@@ -15,142 +15,142 @@ import { makeRequest } from '~/lib/request';
import { SearchResult } from '~/types/search';
const SearchInput = styled.input(({ theme }) => ({
width: '100%',
fontSize: '20px',
color: theme.colors.font,
backgroundColor: 'transparent',
paddingLeft: 0,
border: '1px solid transparent',
width: '100%',
fontSize: '20px',
color: theme.colors.font,
backgroundColor: 'transparent',
paddingLeft: 0,
border: '1px solid transparent',
}));
interface SearchModalProps {
openItem: any;
openItem: any;
}
function SearchModal({ openItem: OpenItem }: SearchModalProps) {
const { t } = useTranslation();
const { collections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const { t } = useTranslation();
const { collections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const [searchTerm, setSearchTerm] = useState<string>('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const [searchTerm, setSearchTerm] = useState<string>('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const searchModal = useToggle(!!searchTerm);
const searchModal = useToggle(!!searchTerm);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearchTerm('');
}, [searchModal]);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearchTerm('');
}, [searchModal]);
const handleSearchInputChange = (value: string) => setSearchTerm(value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleCloseModal();
const handleSearchInputChange = (value: string) => setSearchTerm(value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleCloseModal();
if (results.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
}
if (results.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
}
if (!selectedItem) return;
if (!selectedItem) return;
if (selectedItem.type === 'collection') {
const collection = collections.find((c) => c.id === selectedItem.id);
if (collection) {
setActiveCollection(collection);
}
return;
}
if (selectedItem.type === 'collection') {
const collection = collections.find((c) => c.id === selectedItem.id);
if (collection) {
setActiveCollection(collection);
}
return;
}
window.open(selectedItem.url);
};
window.open(selectedItem.url);
};
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
disableGlobalCheck: true,
});
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
disableGlobalCheck: true,
});
useEffect(() => {
if (searchTerm.trim() === '') {
return setResults([]);
}
useEffect(() => {
if (searchTerm.trim() === '') {
return setResults([]);
}
const controller = new AbortController();
const { url, method } = route('search', { qs: { term: searchTerm } });
makeRequest({
method,
url,
controller,
}).then(({ results: _results }) => {
setResults(_results);
setSelectedItem(_results?.[0]);
});
const controller = new AbortController();
const { url, method } = route('search', { qs: { term: searchTerm } });
makeRequest({
method,
url,
controller,
}).then(({ results: _results }) => {
setResults(_results);
setSelectedItem(_results?.[0]);
});
return () => controller.abort();
}, [searchTerm]);
return () => controller.abort();
}, [searchTerm]);
return (
<>
<OpenItem onClick={searchModal.open}>
<IoIosSearch /> {t('common:search')}
</OpenItem>
<Modal
close={handleCloseModal}
opened={searchModal.isShowing}
hideCloseBtn
css={{ width: '650px' }}
>
<form
onSubmit={handleSubmit}
css={{
width: '100%',
display: 'flex',
gap: '0.5em',
flexDirection: 'column',
}}
>
<div
css={{
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
}}
>
<label htmlFor="search" css={{ display: 'flex' }}>
<IoIosSearch size={24} />
</label>
<SearchInput
name="search"
id="search"
onChange={({ target }) => handleSearchInputChange(target.value)}
value={searchTerm}
placeholder={t('common:search')}
autoFocus
/>
</div>
{results.length > 0 && selectedItem && (
<SearchResultList
results={results}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)}
{results.length === 0 && !!searchTerm.trim() && <NoSearchResult />}
<button
type="submit"
disabled={searchTerm.length === 0}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
</>
);
return (
<>
<OpenItem onClick={searchModal.open}>
<IoIosSearch /> {t('common:search')}
</OpenItem>
<Modal
close={handleCloseModal}
opened={searchModal.isShowing}
hideCloseBtn
css={{ width: '650px' }}
>
<form
onSubmit={handleSubmit}
css={{
width: '100%',
display: 'flex',
gap: '0.5em',
flexDirection: 'column',
}}
>
<div
css={{
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
}}
>
<label htmlFor="search" css={{ display: 'flex' }}>
<IoIosSearch size={24} />
</label>
<SearchInput
name="search"
id="search"
onChange={({ target }) => handleSearchInputChange(target.value)}
value={searchTerm}
placeholder={t('common:search')}
autoFocus
/>
</div>
{results.length > 0 && selectedItem && (
<SearchResultList
results={results}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)}
{results.length === 0 && !!searchTerm.trim() && <NoSearchResult />}
<button
type="submit"
disabled={searchTerm.length === 0}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
</>
);
}
export default SearchModal;

View File

@@ -6,118 +6,118 @@ import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import useCollections from '~/hooks/use_collections';
import {
SearchResult,
SearchResultCollection,
SearchResultLink,
SearchResult,
SearchResultCollection,
SearchResultLink,
} from '~/types/search';
const SearchItemStyle = styled('li', {
shouldForwardProp: (propName) => propName !== 'isActive',
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
fontSize: '16px',
backgroundColor: isActive ? theme.colors.secondary : 'transparent',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
borderRadius: theme.border.radius,
padding: '0.25em 0.35em !important',
fontSize: '16px',
backgroundColor: isActive ? theme.colors.secondary : 'transparent',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
borderRadius: theme.border.radius,
padding: '0.25em 0.35em !important',
}));
interface CommonResultProps {
innerRef: RefObject<HTMLLIElement>;
isActive: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
innerRef: RefObject<HTMLLIElement>;
isActive: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export default function SearchResultItem({
result,
isActive,
onHover,
result,
isActive,
onHover,
}: {
result: SearchResult;
isActive: boolean;
onHover: (result: SearchResult) => void;
result: SearchResult;
isActive: boolean;
onHover: (result: SearchResult) => void;
}) {
const itemRef = useRef<HTMLLIElement>(null);
const [isHovering, setHovering] = useState<boolean>(false);
const itemRef = useRef<HTMLLIElement>(null);
const [isHovering, setHovering] = useState<boolean>(false);
const onMouseEnter = () => {
if (!isHovering) {
onHover(result);
setHovering(true);
}
};
const onMouseLeave = () => setHovering(false);
const onMouseEnter = () => {
if (!isHovering) {
onHover(result);
setHovering(true);
}
};
const onMouseLeave = () => setHovering(false);
useEffect(() => {
if (isActive && !isHovering) {
itemRef.current?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
});
}
}, [itemRef, isActive]);
useEffect(() => {
if (isActive && !isHovering) {
itemRef.current?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
});
}
}, [itemRef, isActive]);
const commonProps = {
onMouseEnter,
onMouseLeave,
isActive,
};
return result.type === 'collection' ? (
<ResultCollection result={result} innerRef={itemRef} {...commonProps} />
) : (
<ResultLink result={result} innerRef={itemRef} {...commonProps} />
);
const commonProps = {
onMouseEnter,
onMouseLeave,
isActive,
};
return result.type === 'collection' ? (
<ResultCollection result={result} innerRef={itemRef} {...commonProps} />
) : (
<ResultLink result={result} innerRef={itemRef} {...commonProps} />
);
}
function ResultLink({
result,
innerRef,
...props
result,
innerRef,
...props
}: {
result: SearchResultLink;
result: SearchResultLink;
} & CommonResultProps) {
const { collections } = useCollections();
const collection = collections.find((c) => c.id === result.collection_id);
const link = collection?.links.find((l) => l.id === result.id);
const { collections } = useCollections();
const collection = collections.find((c) => c.id === result.collection_id);
const link = collection?.links.find((l) => l.id === result.id);
if (!collection || !link) return <></>;
if (!collection || !link) return <></>;
return (
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<LinkFavicon url={link.url} size={20} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
<Legend>({collection.name})</Legend>
</SearchItemStyle>
);
return (
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<LinkFavicon url={link.url} size={20} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
<Legend>({collection.name})</Legend>
</SearchItemStyle>
);
}
const ResultCollection = ({
result,
innerRef,
...props
result,
innerRef,
...props
}: {
result: SearchResultCollection;
result: SearchResultCollection;
} & CommonResultProps) => (
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<AiOutlineFolder size={24} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
</SearchItemStyle>
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<AiOutlineFolder size={24} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
</SearchItemStyle>
);

View File

@@ -4,49 +4,49 @@ import useShortcut from '~/hooks/use_shortcut';
import { SearchResult } from '~/types/search';
export default function SearchResultList({
results,
selectedItem,
setSelectedItem,
results,
selectedItem,
setSelectedItem,
}: {
results: SearchResult[];
selectedItem: SearchResult;
setSelectedItem: (result: SearchResult) => void;
results: SearchResult[];
selectedItem: SearchResult;
setSelectedItem: (result: SearchResult) => void;
}) {
const selectedItemIndex = results.findIndex(
(item) => item.id === selectedItem.id && item.type === selectedItem.type
);
const selectedItemIndex = results.findIndex(
(item) => item.id === selectedItem.id && item.type === selectedItem.type
);
useShortcut(
'ARROW_UP',
() => setSelectedItem(results[selectedItemIndex - 1]),
{
enabled: results.length > 1 && selectedItemIndex !== 0,
disableGlobalCheck: true,
}
);
useShortcut(
'ARROW_DOWN',
() => setSelectedItem(results[selectedItemIndex + 1]),
{
enabled: results.length > 1 && selectedItemIndex !== results.length - 1,
disableGlobalCheck: true,
}
);
useShortcut(
'ARROW_UP',
() => setSelectedItem(results[selectedItemIndex - 1]),
{
enabled: results.length > 1 && selectedItemIndex !== 0,
disableGlobalCheck: true,
}
);
useShortcut(
'ARROW_DOWN',
() => setSelectedItem(results[selectedItemIndex + 1]),
{
enabled: results.length > 1 && selectedItemIndex !== results.length - 1,
disableGlobalCheck: true,
}
);
return (
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
{results.map((result) => (
<SearchResultItem
result={result}
onHover={setSelectedItem}
isActive={
selectedItem &&
selectedItem.id === result.id &&
selectedItem.type === result.type
}
key={result.type + result.id.toString()}
/>
))}
</UnstyledList>
);
return (
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
{results.map((result) => (
<SearchResultItem
result={result}
onHover={setSelectedItem}
isActive={
selectedItem &&
selectedItem.id === result.id &&
selectedItem.type === result.type
}
key={result.type + result.id.toString()}
/>
))}
</UnstyledList>
);
}

View File

@@ -1,11 +1,11 @@
import styled from '@emotion/styled';
const FavoriteListContainer = styled.div({
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
});
export default FavoriteListContainer;

View File

@@ -9,52 +9,52 @@ import { onFavorite } from '~/lib/favorite';
import { Link } from '~/types/app';
const StarItem = styled(DropdownItemButton)(({ theme }) => ({
color: theme.colors.yellow,
color: theme.colors.yellow,
}));
export default function FavoriteDropdownItem({ link }: { link: Link }) {
const { collections, setCollections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const { t } = useTranslation();
const { collections, setCollections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const { t } = useTranslation();
const toggleFavorite = useCallback(
(linkId: Link['id']) => {
let linkIndex = 0;
const collectionIndex = collections.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const toggleFavorite = useCallback(
(linkId: Link['id']) => {
let linkIndex = 0;
const collectionIndex = collections.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const collectionLink = collections[collectionIndex].links[linkIndex];
const collectionsCopy = [...collections];
collectionsCopy[collectionIndex].links[linkIndex] = {
...collectionLink,
favorite: !collectionLink.favorite,
};
const collectionLink = collections[collectionIndex].links[linkIndex];
const collectionsCopy = [...collections];
collectionsCopy[collectionIndex].links[linkIndex] = {
...collectionLink,
favorite: !collectionLink.favorite,
};
setCollections(collectionsCopy);
setActiveCollection(collectionsCopy[collectionIndex]);
},
[collections, setCollections]
);
setCollections(collectionsCopy);
setActiveCollection(collectionsCopy[collectionIndex]);
},
[collections, setCollections]
);
const onFavoriteCallback = () => toggleFavorite(link.id);
return (
<StarItem
onClick={() => onFavorite(link.id, !link.favorite, onFavoriteCallback)}
>
{!link.favorite ? (
<>
<AiFillStar /> {t('add-favorite')}
</>
) : (
<>
<AiOutlineStar /> {t('remove-favorite')}
</>
)}
</StarItem>
);
const onFavoriteCallback = () => toggleFavorite(link.id);
return (
<StarItem
onClick={() => onFavorite(link.id, !link.favorite, onFavoriteCallback)}
>
{!link.favorite ? (
<>
<AiFillStar /> {t('add-favorite')}
</>
) : (
<>
<AiOutlineStar /> {t('remove-favorite')}
</>
)}
</StarItem>
);
}

View File

@@ -16,54 +16,54 @@ import { appendCollectionId, appendLinkId } from '~/lib/navigation';
import { LinkWithCollection } from '~/types/app';
const FavoriteItemStyle = styled(ItemExternalLink)(({ theme }) => ({
height: 'auto',
backgroundColor: theme.colors.secondary,
height: 'auto',
backgroundColor: theme.colors.secondary,
}));
const FavoriteDropdown = styled(Dropdown)(({ theme }) => ({
backgroundColor: theme.colors.secondary,
backgroundColor: theme.colors.secondary,
}));
const FavoriteContainer = styled.div({
flex: 1,
lineHeight: '1.1rem',
flex: 1,
lineHeight: '1.1rem',
});
export default function FavoriteItem({ link }: { link: LinkWithCollection }) {
const { t } = useTranslation();
return (
<FavoriteItemStyle href={link.url}>
<LinkFavicon url={link.url} size={24} />
<FavoriteContainer>
<TextEllipsis>{link.name}</TextEllipsis>
<Legend>{link.collection.name}</Legend>
</FavoriteContainer>
<FavoriteDropdown
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
label={<BsThreeDotsVertical />}
svgSize={18}
>
<DropdownItemLink
href={appendCollectionId(route('dashboard').url, link.collection.id)}
>
<FaRegEye /> {t('go-to-collection')}
</DropdownItemLink>
<FavoriteDropdownItem link={link} />
<DropdownItemLink
href={appendLinkId(route('link.edit-form').url, link.id)}
>
<GoPencil /> {t('link.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendLinkId(route('link.delete-form').url, link.id)}
danger
>
<IoTrashOutline /> {t('link.delete')}
</DropdownItemLink>
</FavoriteDropdown>
</FavoriteItemStyle>
);
const { t } = useTranslation();
return (
<FavoriteItemStyle href={link.url}>
<LinkFavicon url={link.url} size={24} />
<FavoriteContainer>
<TextEllipsis>{link.name}</TextEllipsis>
<Legend>{link.collection.name}</Legend>
</FavoriteContainer>
<FavoriteDropdown
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
label={<BsThreeDotsVertical />}
svgSize={18}
>
<DropdownItemLink
href={appendCollectionId(route('dashboard').url, link.collection.id)}
>
<FaRegEye /> {t('go-to-collection')}
</DropdownItemLink>
<FavoriteDropdownItem link={link} />
<DropdownItemLink
href={appendLinkId(route('link.edit-form').url, link.id)}
>
<GoPencil /> {t('link.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendLinkId(route('link.delete-form').url, link.id)}
danger
>
<IoTrashOutline /> {t('link.delete')}
</DropdownItemLink>
</FavoriteDropdown>
</FavoriteItemStyle>
);
}

View File

@@ -5,47 +5,47 @@ import FavoriteItem from '~/components/dashboard/side_nav/favorite/favorite_item
import useFavorites from '~/hooks/use_favorites';
const FavoriteLabel = styled.p(({ theme }) => ({
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
}));
const NoFavorite = () => {
const { t } = useTranslation('common');
return (
<FavoriteLabel css={{ textAlign: 'center' }}>
{t('favorites-appears-here')}
</FavoriteLabel>
);
const { t } = useTranslation('common');
return (
<FavoriteLabel css={{ textAlign: 'center' }}>
{t('favorites-appears-here')}
</FavoriteLabel>
);
};
const FavoriteListStyle = styled.div({
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
});
export default function FavoriteList() {
const { t } = useTranslation('common');
const { favorites } = useFavorites();
if (favorites.length === 0) {
return <NoFavorite key="no-favorite" />;
}
const { t } = useTranslation('common');
const { favorites } = useFavorites();
if (favorites.length === 0) {
return <NoFavorite key="no-favorite" />;
}
return (
<FavoriteListContainer>
<FavoriteLabel>
{t('favorite')} {favorites.length}
</FavoriteLabel>
<FavoriteListStyle>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />
))}
</FavoriteListStyle>
</FavoriteListContainer>
);
return (
<FavoriteListContainer>
<FavoriteLabel>
{t('favorite')} {favorites.length}
</FavoriteLabel>
<FavoriteListStyle>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />
))}
</FavoriteListStyle>
</FavoriteListContainer>
);
}

View File

@@ -4,27 +4,27 @@ import ExternalLink from '~/components/common/external_link';
import { rgba } from '~/lib/color';
export const Item = styled.div(({ theme }) => ({
userSelect: 'none',
height: '40px',
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.background,
padding: '8px 12px',
borderRadius: theme.border.radius,
display: 'flex',
gap: '.75em',
alignItems: 'center',
userSelect: 'none',
height: '40px',
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.background,
padding: '8px 12px',
borderRadius: theme.border.radius,
display: 'flex',
gap: '.75em',
alignItems: 'center',
'& > svg': {
height: '24px',
width: '24px',
},
'& > svg': {
height: '24px',
width: '24px',
},
// Disable hover effect for UserCard
'&:hover:not(.disable-hover)': {
cursor: 'pointer',
backgroundColor: rgba(theme.colors.font, 0.1),
},
// Disable hover effect for UserCard
'&:hover:not(.disable-hover)': {
cursor: 'pointer',
backgroundColor: rgba(theme.colors.font, 0.1),
},
}));
export const ItemLink = Item.withComponent(Link);

View File

@@ -14,67 +14,67 @@ import { rgba } from '~/lib/color';
import { appendCollectionId } from '~/lib/navigation';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
borderRight: `1px solid ${theme.colors.lightGrey}`,
marginRight: '5px',
display: 'flex',
gap: '.35em',
flexDirection: 'column',
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
borderRight: `1px solid ${theme.colors.lightGrey}`,
marginRight: '5px',
display: 'flex',
gap: '.35em',
flexDirection: 'column',
}));
const AdminButton = styled(ItemLink)(({ theme }) => ({
color: theme.colors.lightRed,
'&:hover': {
backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`,
},
color: theme.colors.lightRed,
'&:hover': {
backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`,
},
}));
const SettingsButton = styled(Item)(({ theme }) => ({
color: theme.colors.grey,
'&:hover': {
backgroundColor: `${rgba(theme.colors.grey, 0.1)}!important`,
},
color: theme.colors.grey,
'&:hover': {
backgroundColor: `${rgba(theme.colors.grey, 0.1)}!important`,
},
}));
const AddButton = styled(ItemLink)(({ theme }) => ({
color: theme.colors.primary,
'&:hover': {
backgroundColor: `${rgba(theme.colors.primary, 0.1)}!important`,
},
color: theme.colors.primary,
'&:hover': {
backgroundColor: `${rgba(theme.colors.primary, 0.1)}!important`,
},
}));
const SearchButton = AddButton.withComponent(Item);
export default function SideNavigation() {
const { user } = useUser();
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<SideMenu>
<div css={{ paddingInline: '10px' }}>
<UserCard />
{user!.isAdmin && (
<AdminButton href={route('admin.dashboard').url}>
<IoShieldHalfSharp /> {t('admin')}
</AdminButton>
)}
<ModalSettings openItem={SettingsButton} />
<SearchModal openItem={SearchButton} />
<AddButton
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
<IoAdd /> {t('link.create')}
</AddButton>
<AddButton href={route('collection.create-form').url}>
<AiOutlineFolderAdd /> {t('collection.create')}
</AddButton>
</div>
<FavoriteList />
</SideMenu>
);
const { user } = useUser();
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<SideMenu>
<div css={{ paddingInline: '10px' }}>
<UserCard />
{user!.isAdmin && (
<AdminButton href={route('admin.dashboard').url}>
<IoShieldHalfSharp /> {t('admin')}
</AdminButton>
)}
<ModalSettings openItem={SettingsButton} />
<SearchModal openItem={SearchButton} />
<AddButton
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
<IoAdd /> {t('link.create')}
</AddButton>
<AddButton href={route('collection.create-form').url}>
<AiOutlineFolderAdd /> {t('collection.create')}
</AddButton>
</div>
<FavoriteList />
</SideMenu>
);
}

View File

@@ -3,19 +3,19 @@ import { Item } from '~/components/dashboard/side_nav/nav_item';
import useUser from '~/hooks/use_user';
export default function UserCard() {
const { user, isAuthenticated } = useUser();
const altImage = `${user?.fullname}'s avatar`;
return (
isAuthenticated && (
<Item className="disable-hover">
<RoundedImage
src={user.avatarUrl}
width={24}
alt={altImage}
referrerPolicy="no-referrer"
/>
{user.fullname}
</Item>
)
);
const { user, isAuthenticated } = useUser();
const altImage = `${user?.fullname}'s avatar`;
return (
isAuthenticated && (
<Item className="disable-hover">
<RoundedImage
src={user.avatarUrl}
width={24}
alt={altImage}
referrerPolicy="no-referrer"
/>
{user.fullname}
</Item>
)
);
}

View File

@@ -1,10 +1,10 @@
import styled from '@emotion/styled';
const SwiperHandler = styled.div(({ theme }) => ({
height: '100%',
width: '100%',
display: 'flex',
transition: `background-color ${theme.transition.delay}`,
height: '100%',
width: '100%',
display: 'flex',
transition: `background-color ${theme.transition.delay}`,
}));
export default SwiperHandler;

View File

@@ -7,38 +7,38 @@ import ExternalLink from '~/components/common/external_link';
import packageJson from '../../../package.json';
const FooterStyle = styled.footer(({ theme }) => ({
fontSize: '0.9em',
color: theme.colors.grey,
textAlign: 'center',
paddingBlock: '0.75em',
'& a:hover': {
textDecoration: 'underline',
},
fontSize: '0.9em',
color: theme.colors.grey,
textAlign: 'center',
paddingBlock: '0.75em',
'& a:hover': {
textDecoration: 'underline',
},
}));
export default function Footer({ className }: { className?: string }) {
const { t } = useTranslation('common');
const { t } = useTranslation('common');
return (
<FooterStyle className={className}>
<div className="row">
<Link href={route('privacy').url}>{t('privacy')}</Link>
{' • '}
<Link href={route('terms').url}>{t('terms')}</Link>
{' • '}
<ExternalLink href={PATHS.EXTENSION}>Extension</ExternalLink>
</div>
<div className="row">
{t('footer.made_by')}{' '}
<ExternalLink href={PATHS.AUTHOR}>Sonny</ExternalLink>
{' • '}
<span>
Version:{' '}
<ExternalLink href={PATHS.REPO_GITHUB}>
{packageJson.version}
</ExternalLink>
</span>
</div>
</FooterStyle>
);
return (
<FooterStyle className={className}>
<div className="row">
<Link href={route('privacy').url}>{t('privacy')}</Link>
{' • '}
<Link href={route('terms').url}>{t('terms')}</Link>
{' • '}
<ExternalLink href={PATHS.EXTENSION}>Extension</ExternalLink>
</div>
<div className="row">
{t('footer.made_by')}{' '}
<ExternalLink href={PATHS.AUTHOR}>Sonny</ExternalLink>
{' • '}
<span>
Version:{' '}
<ExternalLink href={PATHS.REPO_GITHUB}>
{packageJson.version}
</ExternalLink>
</span>
</div>
</FooterStyle>
);
}

View File

@@ -8,83 +8,83 @@ import FormLayout from '~/components/layouts/form_layout';
import { Collection } from '~/types/app';
export type FormCollectionData = {
name: string;
description: string | null;
visibility: Visibility;
nextId?: Collection['id'];
name: string;
description: string | null;
visibility: Visibility;
nextId?: Collection['id'];
};
interface FormCollectionProps {
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
data: FormCollectionData;
errors?: Record<string, Array<string>>;
disableInputs?: boolean;
submitBtnDanger?: boolean;
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
data: FormCollectionData;
errors?: Record<string, Array<string>>;
disableInputs?: boolean;
submitBtnDanger?: boolean;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
}
export default function FormCollection({
title,
canSubmit,
disableHomeLink,
data,
errors,
disableInputs = false,
submitBtnDanger = false,
title,
canSubmit,
disableHomeLink,
data,
errors,
disableInputs = false,
submitBtnDanger = false,
setData,
handleSubmit,
setData,
handleSubmit,
}: FormCollectionProps) {
const { t } = useTranslation('common');
const handleOnCheck: FormCollectionProps['setData'] = (name, value) =>
setData(name, value ? Visibility.PUBLIC : Visibility.PRIVATE);
const { t } = useTranslation('common');
const handleOnCheck: FormCollectionProps['setData'] = (name, value) =>
setData(name, value ? Visibility.PUBLIC : Visibility.PRIVATE);
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleSubmit();
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleSubmit();
};
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('collection.name')}
placeholder={t('collection.name')}
name="name"
onChange={setData}
value={data.name}
errors={errors?.name}
required
autoFocus
disabled={disableInputs}
/>
<TextBox
label={t('collection.description')}
placeholder={t('collection.description')}
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
/>
<Checkbox
label="Public"
name="visibility"
onChange={handleOnCheck}
checked={data.visibility === Visibility.PUBLIC}
disabled={disableInputs}
/>
</BackToDashboard>
</FormLayout>
);
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('collection.name')}
placeholder={t('collection.name')}
name="name"
onChange={setData}
value={data.name}
errors={errors?.name}
required
autoFocus
disabled={disableInputs}
/>
<TextBox
label={t('collection.description')}
placeholder={t('collection.description')}
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
/>
<Checkbox
label="Public"
name="visibility"
onChange={handleOnCheck}
checked={data.visibility === Visibility.PUBLIC}
disabled={disableInputs}
/>
</BackToDashboard>
</FormLayout>
);
}

View File

@@ -9,110 +9,110 @@ import useSearchParam from '~/hooks/use_search_param';
import { Collection } from '~/types/app';
export type FormLinkData = {
name: string;
description: string | null;
url: string;
favorite: boolean;
collectionId: Collection['id'];
name: string;
description: string | null;
url: string;
favorite: boolean;
collectionId: Collection['id'];
};
interface FormLinkProps {
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
data: FormLinkData;
errors?: Record<string, Array<string>>;
collections: Collection[];
disableInputs?: boolean;
submitBtnDanger?: boolean;
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
data: FormLinkData;
errors?: Record<string, Array<string>>;
collections: Collection[];
disableInputs?: boolean;
submitBtnDanger?: boolean;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
}
export default function FormLink({
title,
canSubmit,
disableHomeLink,
data,
errors,
collections,
disableInputs = false,
submitBtnDanger = false,
title,
canSubmit,
disableHomeLink,
data,
errors,
collections,
disableInputs = false,
submitBtnDanger = false,
setData,
handleSubmit,
setData,
handleSubmit,
}: FormLinkProps) {
const { t } = useTranslation('common');
const collectionId =
Number(useSearchParam('collectionId')) ?? collections?.[0].id;
const { t } = useTranslation('common');
const collectionId =
Number(useSearchParam('collectionId')) ?? collections?.[0].id;
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleSubmit();
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleSubmit();
};
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
collectionId={collectionId}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('link.name')}
placeholder={t('link.name')}
name="name"
onChange={setData}
value={data.name}
errors={errors?.name}
disabled={disableInputs}
required
autoFocus
/>
<TextBox
label={t('link.link')}
placeholder={t('link.link')}
name="url"
onChange={setData}
value={data.url}
errors={errors?.url}
disabled={disableInputs}
required
/>
<TextBox
label={t('link.description')}
placeholder={t('link.description')}
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
/>
<Selector
label={t('collection.collections')}
name="collection"
placeholder={t('collection.collections')}
value={data.collectionId}
onChangeCallback={(value) => setData('collectionId', value)}
options={collections.map(({ id, name }) => ({
label: name,
value: id,
}))}
required
/>
<Checkbox
label={t('favorite')}
name="favorite"
onChange={setData}
checked={data.favorite}
errors={errors?.favorite}
disabled={disableInputs}
/>
</BackToDashboard>
</FormLayout>
);
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
collectionId={collectionId}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('link.name')}
placeholder={t('link.name')}
name="name"
onChange={setData}
value={data.name}
errors={errors?.name}
disabled={disableInputs}
required
autoFocus
/>
<TextBox
label={t('link.link')}
placeholder={t('link.link')}
name="url"
onChange={setData}
value={data.url}
errors={errors?.url}
disabled={disableInputs}
required
/>
<TextBox
label={t('link.description')}
placeholder={t('link.description')}
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
/>
<Selector
label={t('collection.collections')}
name="collection"
placeholder={t('collection.collections')}
value={data.collectionId}
onChangeCallback={(value) => setData('collectionId', value)}
options={collections.map(({ id, name }) => ({
label: name,
value: id,
}))}
required
/>
<Checkbox
label={t('favorite')}
name="favorite"
onChange={setData}
checked={data.favorite}
errors={errors?.favorite}
disabled={disableInputs}
/>
</BackToDashboard>
</FormLayout>
);
}

View File

@@ -2,42 +2,42 @@ import styled from '@emotion/styled';
import { IconType } from 'react-icons/lib';
const AboutItemStyle = styled.li(({ theme }) => ({
width: '350px',
display: 'flex',
gap: '1em',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
width: '350px',
display: 'flex',
gap: '1em',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
'& svg': {
color: theme.colors.blue,
},
'& svg': {
color: theme.colors.blue,
},
'& div': {
fontSize: '1.25rem',
fontWeight: '500',
},
'& div': {
fontSize: '1.25rem',
fontWeight: '500',
},
'& p': {
height: '65px',
color: theme.colors.grey,
},
'& p': {
height: '65px',
color: theme.colors.grey,
},
}));
const AboutItem = ({
title,
text,
icon: Icon,
title,
text,
icon: Icon,
}: {
title: string;
text: string;
icon: IconType;
title: string;
text: string;
icon: IconType;
}) => (
<AboutItemStyle>
<Icon size={60} />
<div>{title}</div>
<p>{text}</p>
</AboutItemStyle>
<AboutItemStyle>
<Icon size={60} />
<div>{title}</div>
<p>{text}</p>
</AboutItemStyle>
);
export default AboutItem;

View File

@@ -2,12 +2,12 @@ import styled from '@emotion/styled';
import UnstyledList from '~/components/common/unstyled/unstyled_list';
const AboutList = styled(UnstyledList)({
margin: '4em 0 !important',
display: 'flex',
gap: '2em',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
margin: '4em 0 !important',
display: 'flex',
gap: '2em',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
});
export default AboutList;

View File

@@ -5,50 +5,50 @@ import { useTranslation } from 'react-i18next';
import Quotes from '~/components/quotes';
const HeroStyle = styled.header(({ theme }) => ({
height: '250px',
minHeight: '250px',
width: '100%',
backgroundColor: theme.colors.secondary,
marginTop: '0.5em',
borderRadius: theme.border.radius,
padding: '1em',
display: 'flex',
gap: '1em',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
height: '250px',
minHeight: '250px',
width: '100%',
backgroundColor: theme.colors.secondary,
marginTop: '0.5em',
borderRadius: theme.border.radius,
padding: '1em',
display: 'flex',
gap: '1em',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
'& *': {
textAlign: 'center',
},
'& *': {
textAlign: 'center',
},
}));
const HeroTitle = styled.h1({
fontSize: '32px',
fontSize: '32px',
});
const HeroQuote = styled(Quotes)({
fontSize: '20px',
fontSize: '20px',
});
const LinkButton = styled(Link)(({ theme }) => ({
fontSize: '1rem',
width: 'fit-content',
color: theme.colors.white,
backgroundColor: theme.colors.primary,
borderRadius: '5rem',
padding: '0.5em 1.5em',
fontSize: '1rem',
width: 'fit-content',
color: theme.colors.white,
backgroundColor: theme.colors.primary,
borderRadius: '5rem',
padding: '0.5em 1.5em',
}));
export default function HeroHeader() {
const { t } = useTranslation();
return (
<HeroStyle>
<HeroTitle>{t('about:hero.title')}</HeroTitle>
<HeroQuote>{t('common:slogan')}</HeroQuote>
<LinkButton href={route('dashboard').url}>
{t('about:hero.cta')}
</LinkButton>
</HeroStyle>
);
const { t } = useTranslation();
return (
<HeroStyle>
<HeroTitle>{t('about:hero.title')}</HeroTitle>
<HeroQuote>{t('common:slogan')}</HeroQuote>
<LinkButton href={route('dashboard').url}>
{t('about:hero.cta')}
</LinkButton>
</HeroStyle>
);
}

View File

@@ -2,33 +2,33 @@ import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
const ScreenshotWrapper = styled.div({
position: 'relative',
height: '360px',
width: '640px',
maxWidth: '100%',
margin: '0 auto',
position: 'relative',
height: '360px',
width: '640px',
maxWidth: '100%',
margin: '0 auto',
});
const Screenshot = styled.img(({ theme }) => ({
height: 'auto !important',
width: '100%',
boxShadow: theme.colors.boxShadow,
borderRadius: theme.border.radius,
overflow: 'hidden',
height: 'auto !important',
width: '100%',
boxShadow: theme.colors.boxShadow,
borderRadius: theme.border.radius,
overflow: 'hidden',
}));
export default function WebsitePreview() {
const { t } = useTranslation('about');
return (
<>
<h2>{t('look-title')}</h2>
<ScreenshotWrapper>
<Screenshot
src="/website-screenshot.png"
alt={t('website-screenshot-alt')}
title={t('website-screenshot-alt')}
/>
</ScreenshotWrapper>
</>
);
const { t } = useTranslation('about');
return (
<>
<h2>{t('look-title')}</h2>
<ScreenshotWrapper>
<Screenshot
src="/website-screenshot.png"
alt={t('website-screenshot-alt')}
title={t('website-screenshot-alt')}
/>
</ScreenshotWrapper>
</>
);
}

View File

@@ -7,47 +7,47 @@ import { languages } from '~/i18n';
type Country = 'fr' | 'en';
export default function LangSelector({
onSelected,
onSelected,
}: {
onSelected?: (country: Country) => void;
onSelected?: (country: Country) => void;
}) {
const { t, i18n } = useTranslation('common');
const { t, i18n } = useTranslation('common');
const onToggleLanguageClick = (newLocale: string) => {
dayjs.locale(newLocale);
i18n.changeLanguage(newLocale);
localStorage.setItem(LS_LANG_KEY, newLocale);
};
const onToggleLanguageClick = (newLocale: string) => {
dayjs.locale(newLocale);
i18n.changeLanguage(newLocale);
localStorage.setItem(LS_LANG_KEY, newLocale);
};
return (
<Selector
name="lng-select"
label={t('select-your-lang')}
value={i18n.language}
onChangeCallback={(value) => {
onToggleLanguageClick(value.toString());
if (onSelected) {
setTimeout(() => onSelected(value.toString() as Country), 150);
}
}}
options={languages.map((lang: Country) => ({
label: t(`language.${lang}`),
value: lang,
}))}
formatOptionLabel={(country) => (
<div
className="country-option"
style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}
>
<img
src={`/icons/${country.value}.svg`}
alt="country-image"
height={24}
width={24}
/>
<span>{country.label}</span>
</div>
)}
/>
);
return (
<Selector
name="lng-select"
label={t('select-your-lang')}
value={i18n.language}
onChangeCallback={(value) => {
onToggleLanguageClick(value.toString());
if (onSelected) {
setTimeout(() => onSelected(value.toString() as Country), 150);
}
}}
options={languages.map((lang: Country) => ({
label: t(`language.${lang}`),
value: lang,
}))}
formatOptionLabel={(country) => (
<div
className="country-option"
style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}
>
<img
src={`/icons/${country.value}.svg`}
alt="country-image"
height={24}
width={24}
/>
<span>{country.label}</span>
</div>
)}
/>
);
}

View File

@@ -5,11 +5,11 @@ import ContextThemeProvider from '~/components/layouts/_theme_layout';
import DarkThemeContextProvider from '~/contexts/dark_theme_context';
export default function BaseLayout({ children }: { children: ReactNode }) {
const { i18n } = useTranslation();
dayjs.locale(i18n.language);
return (
<DarkThemeContextProvider key="a">
<ContextThemeProvider>{children}</ContextThemeProvider>
</DarkThemeContextProvider>
);
const { i18n } = useTranslation();
dayjs.locale(i18n.language);
return (
<DarkThemeContextProvider key="a">
<ContextThemeProvider>{children}</ContextThemeProvider>
</DarkThemeContextProvider>
);
}

View File

@@ -5,143 +5,143 @@ import { darkTheme } from '~/styles/themes/dark_theme';
import { lightTheme } from '~/styles/themes/light_theme';
export default function ContextThemeProvider({
children,
children,
}: {
children: ReactNode;
children: ReactNode;
}) {
const { isDarkTheme } = useDarkTheme();
return (
<ThemeProvider theme={isDarkTheme ? darkTheme : lightTheme}>
<GlobalStyles />
{children}
</ThemeProvider>
);
const { isDarkTheme } = useDarkTheme();
return (
<ThemeProvider theme={isDarkTheme ? darkTheme : lightTheme}>
<GlobalStyles />
{children}
</ThemeProvider>
);
}
function GlobalStyles() {
const localTheme = useTheme();
const cssReset = css({
'*': {
boxSizing: 'border-box',
outline: 0,
margin: 0,
padding: 0,
scrollBehavior: 'smooth',
},
const localTheme = useTheme();
const cssReset = css({
'*': {
boxSizing: 'border-box',
outline: 0,
margin: 0,
padding: 0,
scrollBehavior: 'smooth',
},
'.reset': {
backgroundColor: 'inherit',
color: 'inherit',
padding: 0,
margin: 0,
border: 0,
},
'.reset': {
backgroundColor: 'inherit',
color: 'inherit',
padding: 0,
margin: 0,
border: 0,
},
a: {
width: 'fit-content',
color: localTheme.colors.primary,
textDecoration: 'none',
borderBottom: '1px solid transparent',
},
a: {
width: 'fit-content',
color: localTheme.colors.primary,
textDecoration: 'none',
borderBottom: '1px solid transparent',
},
b: {
fontWeight: 600,
letterSpacing: '0.5px',
},
b: {
fontWeight: 600,
letterSpacing: '0.5px',
},
'h1, h2, h3, h4, h5, h6': {
fontWeight: '500',
color: localTheme.colors.primary,
},
'h1, h2, h3, h4, h5, h6': {
fontWeight: '500',
color: localTheme.colors.primary,
},
kbd: {
textShadow: '0 1px 0 #fff',
fontSize: '12px',
color: 'rgb(51, 51, 51)',
backgroundColor: 'rgb(247, 247, 247)',
padding: '0.25em 0.5em',
borderRadius: '3px',
border: '1px solid rgb(204, 204, 204)',
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
display: 'inline-block',
},
kbd: {
textShadow: '0 1px 0 #fff',
fontSize: '12px',
color: 'rgb(51, 51, 51)',
backgroundColor: 'rgb(247, 247, 247)',
padding: '0.25em 0.5em',
borderRadius: '3px',
border: '1px solid rgb(204, 204, 204)',
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
display: 'inline-block',
},
hr: {
width: '100%',
marginBlock: '1em',
border: 0,
borderTop: `1px solid ${localTheme.colors.background}`,
},
});
hr: {
width: '100%',
marginBlock: '1em',
border: 0,
borderTop: `1px solid ${localTheme.colors.background}`,
},
});
const documentStyle = css({
'html, body, #app': {
height: '100svh',
width: '100%',
fontFamily: "'Poppins', sans-serif",
fontSize: '14px',
color: localTheme.colors.font,
backgroundColor: localTheme.colors.background,
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
},
});
const documentStyle = css({
'html, body, #app': {
height: '100svh',
width: '100%',
fontFamily: "'Poppins', sans-serif",
fontSize: '14px',
color: localTheme.colors.font,
backgroundColor: localTheme.colors.background,
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
},
});
const scrollbarStyle = css({
/* width */
'::-webkit-scrollbar': {
height: '0.45em',
width: '0.45em',
},
const scrollbarStyle = css({
/* width */
'::-webkit-scrollbar': {
height: '0.45em',
width: '0.45em',
},
/* Track */
'::-webkit-scrollbar-track': {
borderRadius: localTheme.border.radius,
},
/* Track */
'::-webkit-scrollbar-track': {
borderRadius: localTheme.border.radius,
},
/* Handle */
'::-webkit-scrollbar-thumb': {
background: localTheme.colors.primary,
borderRadius: localTheme.border.radius,
/* Handle */
'::-webkit-scrollbar-thumb': {
background: localTheme.colors.primary,
borderRadius: localTheme.border.radius,
'&:hover': {
background: localTheme.colors.darkBlue,
},
},
});
'&:hover': {
background: localTheme.colors.darkBlue,
},
},
});
const tableStyle = css({
table: {
height: 'auto',
width: '100%',
borderCollapse: 'collapse',
borderRadius: localTheme.border.radius,
overflow: 'hidden',
},
const tableStyle = css({
table: {
height: 'auto',
width: '100%',
borderCollapse: 'collapse',
borderRadius: localTheme.border.radius,
overflow: 'hidden',
},
th: {
textAlign: 'center',
fontWeight: 400,
backgroundColor: localTheme.colors.secondary,
},
th: {
textAlign: 'center',
fontWeight: 400,
backgroundColor: localTheme.colors.secondary,
},
'td, th': {
padding: '0.45em',
},
'td, th': {
padding: '0.45em',
},
'th, td': {
whiteSspace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'th, td': {
whiteSspace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'tr:nth-of-type(even)': {
backgroundColor: localTheme.colors.secondary,
},
});
'tr:nth-of-type(even)': {
backgroundColor: localTheme.colors.secondary,
},
});
return (
<Global styles={[cssReset, documentStyle, scrollbarStyle, tableStyle]} />
);
return (
<Global styles={[cssReset, documentStyle, scrollbarStyle, tableStyle]} />
);
}

View File

@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { fadeInScale } from '~/styles/keyframes';
const TransitionLayout = styled.div(({ theme }) => ({
animation: `${theme.transition.delay} ${fadeInScale} both`,
animation: `${theme.transition.delay} ${fadeInScale} both`,
}));
export default TransitionLayout;

View File

@@ -6,33 +6,33 @@ import Navbar from '~/components/navbar/navbar';
import BaseLayout from './_base_layout';
const ContentLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
height: '100%',
width: theme.media.small_desktop,
maxWidth: '100%',
padding: '1em',
display: 'flex',
flexDirection: 'column',
height: '100%',
width: theme.media.small_desktop,
maxWidth: '100%',
padding: '1em',
display: 'flex',
flexDirection: 'column',
'& main': {
width: '100%',
flex: 1,
},
'& main': {
width: '100%',
flex: 1,
},
}));
const ContentLayout = ({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) => (
<BaseLayout>
<ContentLayoutStyle className={className}>
<Navbar />
<main>{children}</main>
<Footer />
</ContentLayoutStyle>
</BaseLayout>
<BaseLayout>
<ContentLayoutStyle className={className}>
<Navbar />
<main>{children}</main>
<Footer />
</ContentLayoutStyle>
</BaseLayout>
);
export default ContentLayout;

View File

@@ -4,18 +4,18 @@ import TransitionLayout from '~/components/layouts/_transition_layout';
import BaseLayout from './_base_layout';
const DashboardLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
position: 'relative',
height: '100%',
width: theme.media.medium_desktop,
maxWidth: '100%',
padding: '0.75em 1em',
overflow: 'hidden',
position: 'relative',
height: '100%',
width: theme.media.medium_desktop,
maxWidth: '100%',
padding: '0.75em 1em',
overflow: 'hidden',
}));
const DashboardLayout = ({ children }: { children: ReactNode }) => (
<BaseLayout>
<DashboardLayoutStyle>{children}</DashboardLayoutStyle>
</BaseLayout>
<BaseLayout>
<DashboardLayoutStyle>{children}</DashboardLayoutStyle>
</BaseLayout>
);
export default DashboardLayout;

View File

@@ -11,59 +11,59 @@ import { appendCollectionId } from '~/lib/navigation';
import BaseLayout from './_base_layout';
const FormLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
height: 'fit-content',
width: theme.media.mobile,
maxWidth: '100%',
marginTop: '10em',
paddingInline: '1em',
display: 'flex',
gap: '0.75em',
flexDirection: 'column',
height: 'fit-content',
width: theme.media.mobile,
maxWidth: '100%',
marginTop: '10em',
paddingInline: '1em',
display: 'flex',
gap: '0.75em',
flexDirection: 'column',
}));
interface FormLayoutProps {
title: string;
children: ReactNode;
title: string;
children: ReactNode;
canSubmit: boolean;
handleSubmit: (event: FormEvent<HTMLFormElement>) => void;
textSubmitButton?: string;
canSubmit: boolean;
handleSubmit: (event: FormEvent<HTMLFormElement>) => void;
textSubmitButton?: string;
disableHomeLink?: boolean;
submitBtnDanger?: boolean;
collectionId?: number;
disableHomeLink?: boolean;
submitBtnDanger?: boolean;
collectionId?: number;
}
export default function FormLayout({
title,
children,
title,
children,
canSubmit,
handleSubmit,
textSubmitButton = i18n.t('common:confirm'),
canSubmit,
handleSubmit,
textSubmitButton = i18n.t('common:confirm'),
disableHomeLink = false,
submitBtnDanger = false,
collectionId,
disableHomeLink = false,
submitBtnDanger = false,
collectionId,
}: FormLayoutProps) {
const { t } = useTranslation('common');
return (
<BaseLayout>
<FormLayoutStyle>
<Head title={title} />
<h2>{title}</h2>
<Form onSubmit={handleSubmit}>
{children}
<Button type="submit" disabled={!canSubmit} danger={submitBtnDanger}>
{textSubmitButton}
</Button>
</Form>
{!disableHomeLink && (
<Link href={appendCollectionId(route('dashboard').url, collectionId)}>
{t('back-home')}
</Link>
)}
</FormLayoutStyle>
</BaseLayout>
);
const { t } = useTranslation('common');
return (
<BaseLayout>
<FormLayoutStyle>
<Head title={title} />
<h2>{title}</h2>
<Form onSubmit={handleSubmit}>
{children}
<Button type="submit" disabled={!canSubmit} danger={submitBtnDanger}>
{textSubmitButton}
</Button>
</Form>
{!disableHomeLink && (
<Link href={appendCollectionId(route('dashboard').url, collectionId)}>
{t('back-home')}
</Link>
)}
</FormLayoutStyle>
</BaseLayout>
);
}

View File

@@ -4,33 +4,33 @@ import ContentLayout from '~/components/layouts/content_layout';
import LegalFooter from '~/components/legal/legal_footer';
const LegalContentStyle = styled(ContentLayout)({
main: {
'h1, p': {
marginTop: '0.5em',
},
h2: {
marginTop: '1.5em',
},
h3: {
marginTop: '1em',
},
ul: {
marginLeft: '2em',
},
},
main: {
'h1, p': {
marginTop: '0.5em',
},
h2: {
marginTop: '1.5em',
},
h3: {
marginTop: '1em',
},
ul: {
marginLeft: '2em',
},
},
});
const LegalContentLayout = ({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) => (
<LegalContentStyle className={className}>
{children}
<LegalFooter />
</LegalContentStyle>
<LegalContentStyle className={className}>
{children}
<LegalFooter />
</LegalContentStyle>
);
export default LegalContentLayout;

View File

@@ -2,19 +2,19 @@ import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
export default function LegalFooter() {
const { t } = useTranslation('legal');
return (
<>
<h2>{t('contact.title')}</h2>
<p>
{t('contact.description')}{' '}
<ExternalLink href="mailto:sonnyasdev@gmail.com" target="_blank">
sonnyasdev[at]gmail[dot]com
</ExternalLink>
</p>
const { t } = useTranslation('legal');
return (
<>
<h2>{t('contact.title')}</h2>
<p>
{t('contact.description')}{' '}
<ExternalLink href="mailto:sonnyasdev@gmail.com" target="_blank">
sonnyasdev[at]gmail[dot]com
</ExternalLink>
</p>
<p>{t('footer.changes')}</p>
<p css={{ marginBottom: '2em' }}>{t('footer.thanks')}</p>
</>
);
<p>{t('footer.changes')}</p>
<p css={{ marginBottom: '2em' }}>{t('footer.thanks')}</p>
</>
);
}

View File

@@ -6,8 +6,8 @@ import { useTranslation } from 'react-i18next';
import { IoIosLogOut } from 'react-icons/io';
import Dropdown from '~/components/common/dropdown/dropdown';
import {
DropdownItemButton,
DropdownItemLink,
DropdownItemButton,
DropdownItemLink,
} from '~/components/common/dropdown/dropdown_item';
import ExternalLink from '~/components/common/external_link';
import RoundedImage from '~/components/common/rounded_image';
@@ -16,117 +16,117 @@ import ModalSettings from '~/components/settings/settings_modal';
import useUser from '~/hooks/use_user';
type NavbarListDirection = {
right?: boolean;
right?: boolean;
};
const Nav = styled.nav({
width: '100%',
padding: '0.75em 0',
display: 'flex',
alignItems: 'center',
width: '100%',
padding: '0.75em 0',
display: 'flex',
alignItems: 'center',
});
const NavList = styled(UnstyledList)<NavbarListDirection>(
({ theme, right }) => ({
display: 'flex',
flex: 1,
gap: '1.5em',
justifyContent: right ? 'flex-end' : 'flex-start',
transition: theme.transition.delay,
({ theme, right }) => ({
display: 'flex',
flex: 1,
gap: '1.5em',
justifyContent: right ? 'flex-end' : 'flex-start',
transition: theme.transition.delay,
'& li': {
display: 'flex',
alignItems: 'center',
},
})
'& li': {
display: 'flex',
alignItems: 'center',
},
})
);
const AdminLink = styled(Link)(({ theme }) => ({
color: theme.colors.lightRed,
color: theme.colors.lightRed,
}));
const UserCard = styled.div({
padding: '0.25em 0.5em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
padding: '0.25em 0.5em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
});
const DropdownItemButtonWithPadding = styled(DropdownItemButton)({
cursor: 'pointer',
padding: 0,
cursor: 'pointer',
padding: 0,
});
export default function Navbar() {
const { t } = useTranslation('common');
const { isAuthenticated, user } = useUser();
const { t } = useTranslation('common');
const { isAuthenticated, user } = useUser();
return (
<Nav>
<NavList>
<li>
<Link href={route('home').url} css={{ fontSize: '24px' }}>
MyLinks
</Link>
</li>
</NavList>
<NavList right>
<li>
<ExternalLink href={PATHS.REPO_GITHUB}>GitHub</ExternalLink>
</li>
{isAuthenticated && !!user ? (
<>
{user.isAdmin && (
<li>
<AdminLink href={route('admin.dashboard').url}>
{t('admin')}
</AdminLink>
</li>
)}
<li>
<Link href={route('dashboard').url}>Dashboard</Link>
</li>
<li>
<ProfileDropdown />
</li>
</>
) : (
<>
<li>
<ModalSettings openItem={DropdownItemButtonWithPadding} />
</li>
<li>
<Link href={route('auth.login').url}>{t('login')}</Link>
</li>
</>
)}
</NavList>
</Nav>
);
return (
<Nav>
<NavList>
<li>
<Link href={route('home').url} css={{ fontSize: '24px' }}>
MyLinks
</Link>
</li>
</NavList>
<NavList right>
<li>
<ExternalLink href={PATHS.REPO_GITHUB}>GitHub</ExternalLink>
</li>
{isAuthenticated && !!user ? (
<>
{user.isAdmin && (
<li>
<AdminLink href={route('admin.dashboard').url}>
{t('admin')}
</AdminLink>
</li>
)}
<li>
<Link href={route('dashboard').url}>Dashboard</Link>
</li>
<li>
<ProfileDropdown />
</li>
</>
) : (
<>
<li>
<ModalSettings openItem={DropdownItemButtonWithPadding} />
</li>
<li>
<Link href={route('auth.login').url}>{t('login')}</Link>
</li>
</>
)}
</NavList>
</Nav>
);
}
function ProfileDropdown() {
const { t } = useTranslation('common');
const { user } = useUser();
const { t } = useTranslation('common');
const { user } = useUser();
return (
<Dropdown
label={
<UserCard>
<RoundedImage
src={user!.avatarUrl}
width={22}
referrerPolicy="no-referrer"
/>
{user!.fullname}
</UserCard>
}
>
<ModalSettings openItem={DropdownItemButton} />
<DropdownItemLink href={route('auth.logout').url} danger>
<IoIosLogOut /> {t('logout')}
</DropdownItemLink>
</Dropdown>
);
return (
<Dropdown
label={
<UserCard>
<RoundedImage
src={user!.avatarUrl}
width={22}
referrerPolicy="no-referrer"
/>
{user!.fullname}
</UserCard>
}
>
<ModalSettings openItem={DropdownItemButton} />
<DropdownItemLink href={route('auth.logout').url} danger>
<IoIosLogOut /> {t('logout')}
</DropdownItemLink>
</Dropdown>
);
}

View File

@@ -2,27 +2,27 @@ import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const Quotes = styled.p(({ theme }) => ({
position: 'relative',
width: 'fit-content',
whiteSpace: 'pre-wrap',
textAlign: 'center',
fontStyle: 'italic',
color: rgba(theme.colors.font, 0.75),
position: 'relative',
width: 'fit-content',
whiteSpace: 'pre-wrap',
textAlign: 'center',
fontStyle: 'italic',
color: rgba(theme.colors.font, 0.75),
'&::before, &::after': {
position: 'absolute',
fontFamily: 'sans-serif',
fontSize: '2.25em',
},
'&::before, &::after': {
position: 'absolute',
fontFamily: 'sans-serif',
fontSize: '2.25em',
},
'&::before': {
left: '-0.65em',
content: '"“"',
},
'&::after': {
right: '-0.5em',
content: '"”"',
},
'&::before': {
left: '-0.65em',
content: '"“"',
},
'&::after': {
right: '-0.5em',
content: '"”"',
},
}));
export default Quotes;

View File

@@ -10,57 +10,57 @@ import { DATE_FORMAT } from '~/constants';
import useUser from '~/hooks/use_user';
const ProfileStyle = styled(UnstyledList)({
display: 'flex',
gap: '1.25em',
display: 'flex',
gap: '1.25em',
});
const Column = styled.div({
display: 'flex',
gap: '1rem',
flexDirection: 'column',
display: 'flex',
gap: '1rem',
flexDirection: 'column',
});
const Field = styled.li({
display: 'flex',
flexDirection: 'column',
display: 'flex',
flexDirection: 'column',
});
export default function Profile() {
const { user, isAuthenticated } = useUser();
const { t } = useTranslation('common');
const { user, isAuthenticated } = useUser();
const { t } = useTranslation('common');
const avatarLabel = t('avatar', {
name: user?.name,
});
const avatarLabel = t('avatar', {
name: user?.name,
});
if (!isAuthenticated) {
return <Fragment />;
}
if (!isAuthenticated) {
return <Fragment />;
}
return (
<ProfileStyle>
<Column>
<RoundedImage
src={user.avatarUrl}
width={96}
height={96}
alt={avatarLabel}
title={avatarLabel}
/>
<Link href={route('auth.logout').url}>{t('logout')}</Link>
</Column>
<Column>
<Field>
<b>{t('name')}</b> {user.fullname}
</Field>
<Field>
<b>{t('email')}</b> {user.email}
</Field>
<Field>
<b>{t('member-since')}</b>{' '}
{dayjs(user.createdAt.toString()).format(DATE_FORMAT)}
</Field>
</Column>
</ProfileStyle>
);
return (
<ProfileStyle>
<Column>
<RoundedImage
src={user.avatarUrl}
width={96}
height={96}
alt={avatarLabel}
title={avatarLabel}
/>
<Link href={route('auth.logout').url}>{t('logout')}</Link>
</Column>
<Column>
<Field>
<b>{t('name')}</b> {user.fullname}
</Field>
<Field>
<b>{t('email')}</b> {user.email}
</Field>
<Field>
<b>{t('member-since')}</b>{' '}
{dayjs(user.createdAt.toString()).format(DATE_FORMAT)}
</Field>
</Column>
</ProfileStyle>
);
}

View File

@@ -12,55 +12,55 @@ import ThemeSwitcher from '~/components/theme_switcher';
import useToggle from '~/hooks/use_modal';
import useUser from '~/hooks/use_user';
export default function ModalSettings({
openItem: OpenItem,
openItem: OpenItem,
}: {
// TODO: fix this :()
openItem: any;
// TODO: fix this :()
openItem: any;
}) {
const { t } = useTranslation('common');
const { isShowing, open, close } = useToggle();
const { isAuthenticated } = useUser();
const { t } = useTranslation('common');
const { isShowing, open, close } = useToggle();
const { isAuthenticated } = useUser();
let tabs: Tab[] = [
{
title: t('lang'),
content: <LangSelector />,
icon: IoLanguageOutline,
},
{
title: 'Theme',
content: (
<FormField css={{ flexDirection: 'row' }}>
<label>Dark theme?</label>
<ThemeSwitcher />
</FormField>
),
icon: CiDark,
},
];
let tabs: Tab[] = [
{
title: t('lang'),
content: <LangSelector />,
icon: IoLanguageOutline,
},
{
title: 'Theme',
content: (
<FormField css={{ flexDirection: 'row' }}>
<label>Dark theme?</label>
<ThemeSwitcher />
</FormField>
),
icon: CiDark,
},
];
if (isAuthenticated) {
tabs.push({
title: t('profile'),
content: <Profile />,
icon: FaUser,
});
}
if (isAuthenticated) {
tabs.push({
title: t('profile'),
content: <Profile />,
icon: FaUser,
});
}
return (
<>
<OpenItem onClick={open}>
<PiGearLight size={20} />
{t('settings')}
</OpenItem>
<Modal
title={t('settings')}
opened={isShowing}
close={close}
css={{ justifyContent: 'flex-start' }}
>
<Tabs tabs={tabs} />
</Modal>
</>
);
return (
<>
<OpenItem onClick={open}>
<PiGearLight size={20} />
{t('settings')}
</OpenItem>
<Modal
title={t('settings')}
opened={isShowing}
close={close}
css={{ justifyContent: 'flex-start' }}
>
<Tabs tabs={tabs} />
</Modal>
</>
);
}

View File

@@ -3,22 +3,22 @@ import Toggle from 'react-toggle';
import useDarkTheme from '~/hooks/use_dark_theme';
export default function ThemeSwitcher() {
const { isDarkTheme, toggleDarkTheme } = useDarkTheme();
const { isDarkTheme, toggleDarkTheme } = useDarkTheme();
if (typeof window === 'undefined') {
return <Fragment />;
}
if (typeof window === 'undefined') {
return <Fragment />;
}
return (
<Toggle
onChange={({ target }) => toggleDarkTheme(target.checked)}
checked={isDarkTheme}
name="theme-switcher"
id="theme-switcher"
icons={{
checked: '☀️',
unchecked: '🌑',
}}
/>
);
return (
<Toggle
onChange={({ target }) => toggleDarkTheme(target.checked)}
checked={isDarkTheme}
name="theme-switcher"
id="theme-switcher"
icons={{
checked: '☀️',
unchecked: '🌑',
}}
/>
);
}

View File

@@ -3,29 +3,29 @@ import styled from '@emotion/styled';
import { IoEarthOutline } from 'react-icons/io5';
const VisibilityStyle = styled.span(({ theme }) => ({
userSelect: 'none',
fontWeight: 300,
fontSize: '0.6em',
color: theme.colors.primary,
border: `1px solid ${theme.colors.primary}`,
borderRadius: '50px',
padding: '0.15em 0.65em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
userSelect: 'none',
fontWeight: 300,
fontSize: '0.6em',
color: theme.colors.primary,
border: `1px solid ${theme.colors.primary}`,
borderRadius: '50px',
padding: '0.15em 0.65em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
}));
const VisibilityBadge = ({
label,
visibility,
label,
visibility,
}: {
label: string;
visibility: Visibility;
label: string;
visibility: Visibility;
}) =>
visibility === Visibility.PUBLIC && (
<VisibilityStyle>
{label} <IoEarthOutline size="1em" />
</VisibilityStyle>
);
visibility === Visibility.PUBLIC && (
<VisibilityStyle>
{label} <IoEarthOutline size="1em" />
</VisibilityStyle>
);
export default VisibilityBadge;

View File

@@ -2,14 +2,14 @@ import { createContext } from 'react';
import { CollectionWithLinks } from '~/types/app';
type ActiveCollectionContextType = {
activeCollection: CollectionWithLinks | null;
setActiveCollection: (collection: CollectionWithLinks) => void;
activeCollection: CollectionWithLinks | null;
setActiveCollection: (collection: CollectionWithLinks) => void;
};
const iActiveCollectionContextState: ActiveCollectionContextType = {
activeCollection: null,
setActiveCollection: (_: CollectionWithLinks) => {},
activeCollection: null,
setActiveCollection: (_: CollectionWithLinks) => {},
};
export const ActiveCollectionContext =
createContext<ActiveCollectionContextType>(iActiveCollectionContextState);
createContext<ActiveCollectionContextType>(iActiveCollectionContextState);

View File

@@ -2,19 +2,19 @@ import { createContext } from 'react';
import { CollectionWithLinks } from '~/types/app';
type CollectionsContextType = {
collections: CollectionWithLinks[];
setCollections: (
collections: CollectionWithLinks[]
) => void | CollectionWithLinks[];
collections: CollectionWithLinks[];
setCollections: (
collections: CollectionWithLinks[]
) => void | CollectionWithLinks[];
};
const iCollectionsContextState: CollectionsContextType = {
collections: [] as CollectionWithLinks[],
setCollections: (_: CollectionWithLinks[]) => {},
collections: [] as CollectionWithLinks[],
setCollections: (_: CollectionWithLinks[]) => {},
};
const CollectionsContext = createContext<CollectionsContextType>(
iCollectionsContextState
iCollectionsContextState
);
export default CollectionsContext;

View File

@@ -6,41 +6,41 @@ import { makeRequest } from '~/lib/request';
const LS_KEY = 'dark_theme';
export const DarkThemeContext = createContext({
isDarkTheme: true,
toggleDarkTheme: (_value: boolean) => {},
isDarkTheme: true,
toggleDarkTheme: (_value: boolean) => {},
});
export default function DarkThemeContextProvider({
children,
children,
}: {
children: ReactNode;
children: ReactNode;
}) {
const { preferDarkTheme } = usePage<{ preferDarkTheme: boolean }>().props;
const [isDarkTheme, setDarkTheme] = useState<boolean>(preferDarkTheme);
const toggleDarkTheme = (value: boolean) => {
setDarkTheme(value);
const { method, url } = route('user.theme');
makeRequest({
method,
url,
body: {
preferDarkTheme: value,
},
});
};
const { preferDarkTheme } = usePage<{ preferDarkTheme: boolean }>().props;
const [isDarkTheme, setDarkTheme] = useState<boolean>(preferDarkTheme);
const toggleDarkTheme = (value: boolean) => {
setDarkTheme(value);
const { method, url } = route('user.theme');
makeRequest({
method,
url,
body: {
preferDarkTheme: value,
},
});
};
useEffect(() => {
localStorage.setItem(LS_KEY, String(isDarkTheme));
}, [isDarkTheme]);
useEffect(() => {
localStorage.setItem(LS_KEY, String(isDarkTheme));
}, [isDarkTheme]);
return (
<DarkThemeContext.Provider
value={{
isDarkTheme,
toggleDarkTheme,
}}
>
{children}
</DarkThemeContext.Provider>
);
return (
<DarkThemeContext.Provider
value={{
isDarkTheme,
toggleDarkTheme,
}}
>
{children}
</DarkThemeContext.Provider>
);
}

View File

@@ -2,15 +2,15 @@ import { createContext } from 'react';
import { LinkWithCollection } from '~/types/app';
type FavoritesContextType = {
favorites: LinkWithCollection[];
favorites: LinkWithCollection[];
};
const iFavoritesContextState = {
favorites: [] as LinkWithCollection[],
favorites: [] as LinkWithCollection[],
};
const FavoritesContext = createContext<FavoritesContextType>(
iFavoritesContextState
iFavoritesContextState
);
export default FavoritesContext;

View File

@@ -1,17 +1,17 @@
import { createContext } from 'react';
type GlobalHotkeysContext = {
globalHotkeysEnabled: boolean;
setGlobalHotkeysEnabled: (value: boolean) => void;
globalHotkeysEnabled: boolean;
setGlobalHotkeysEnabled: (value: boolean) => void;
};
const iGlobalHotkeysContextState = {
globalHotkeysEnabled: true,
setGlobalHotkeysEnabled: (_: boolean) => {},
globalHotkeysEnabled: true,
setGlobalHotkeysEnabled: (_: boolean) => {},
};
const GlobalHotkeysContext = createContext<GlobalHotkeysContext>(
iGlobalHotkeysContextState
iGlobalHotkeysContextState
);
export default GlobalHotkeysContext;

View File

@@ -6,17 +6,17 @@ import { RefObject, useEffect } from 'react';
* This Hook can be used for detecting clicks outside the Opened Menu
*/
export default function useClickOutside(
ref: RefObject<HTMLElement>,
onClickOutside: () => void
ref: RefObject<HTMLElement>,
onClickOutside: () => void
) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref?.current && !ref.current?.contains(event.target as any)) {
onClickOutside();
}
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref?.current && !ref.current?.contains(event.target as any)) {
onClickOutside();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, onClickOutside]);
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, onClickOutside]);
}

View File

@@ -1,8 +1,8 @@
const MOBILE_SCREEN_SIZE = 768;
export default function useIsMobile() {
return (
typeof window !== 'undefined' &&
window.matchMedia(`screen and (max-width: ${MOBILE_SCREEN_SIZE}px)`).matches
);
return (
typeof window !== 'undefined' &&
window.matchMedia(`screen and (max-width: ${MOBILE_SCREEN_SIZE}px)`).matches
);
}

View File

@@ -1,30 +1,30 @@
import { useState } from 'react';
export function useLocalStorage(key: string, initialValue: any) {
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
const setValue = (value: any) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
const setValue = (value: any) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}

View File

@@ -1,18 +1,18 @@
import { useState } from 'react';
const useToggle = (defaultValue: boolean = false) => {
const [isShowing, setIsShowing] = useState<boolean>(defaultValue);
const [isShowing, setIsShowing] = useState<boolean>(defaultValue);
const toggle = () => setIsShowing((value) => !value);
const open = () => setIsShowing(true);
const close = () => setIsShowing(false);
const toggle = () => setIsShowing((value) => !value);
const open = () => setIsShowing(true);
const close = () => setIsShowing(false);
return {
isShowing,
toggle,
open,
close,
};
return {
isShowing,
toggle,
open,
close,
};
};
export default useToggle;

View File

@@ -1,12 +1,12 @@
import { usePage } from '@inertiajs/react';
const useSearchParam = (urlParam: string) => {
const { url } = usePage();
const urlParams = url.split('?');
urlParams.shift();
const { url } = usePage();
const urlParams = url.split('?');
urlParams.shift();
const urlSearchParam = new URLSearchParams(urlParams.join(''));
return urlSearchParam.get(urlParam);
const urlSearchParam = new URLSearchParams(urlParams.join(''));
return urlSearchParam.get(urlParam);
};
export default useSearchParam;

View File

@@ -3,28 +3,28 @@ import { useHotkeys } from 'react-hotkeys-hook';
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
type ShortcutOptions = {
enabled?: boolean;
disableGlobalCheck?: boolean;
enabled?: boolean;
disableGlobalCheck?: boolean;
};
export default function useShortcut(
key: keyof typeof KEYS,
cb: () => void,
{ enabled, disableGlobalCheck }: ShortcutOptions = {
enabled: true,
disableGlobalCheck: false,
}
key: keyof typeof KEYS,
cb: () => void,
{ enabled, disableGlobalCheck }: ShortcutOptions = {
enabled: true,
disableGlobalCheck: false,
}
) {
const { globalHotkeysEnabled } = useGlobalHotkeys();
return useHotkeys(
KEYS[key],
(event) => {
event.preventDefault();
cb();
},
{
enabled: disableGlobalCheck ? enabled : enabled && globalHotkeysEnabled,
enableOnFormTags: ['INPUT'],
}
);
const { globalHotkeysEnabled } = useGlobalHotkeys();
return useHotkeys(
KEYS[key],
(event) => {
event.preventDefault();
cb();
},
{
enabled: disableGlobalCheck ? enabled : enabled && globalHotkeysEnabled,
enableOnFormTags: ['INPUT'],
}
);
}

View File

@@ -1,24 +1,24 @@
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
const [matches, setMatches] = useState<boolean>(false);
const handleMediaChange = () => setMatches(getMediaMatches(query));
const handleMediaChange = () => setMatches(getMediaMatches(query));
useEffect(() => {
const matchMedia = window.matchMedia(query);
handleMediaChange();
useEffect(() => {
const matchMedia = window.matchMedia(query);
handleMediaChange();
matchMedia.addEventListener('change', handleMediaChange);
return () => matchMedia.removeEventListener('change', handleMediaChange);
}, [query]);
matchMedia.addEventListener('change', handleMediaChange);
return () => matchMedia.removeEventListener('change', handleMediaChange);
}, [query]);
return matches;
return matches;
}
function getMediaMatches(query: string): boolean {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
}

View File

@@ -4,6 +4,6 @@ import { media } from '~/styles/media_queries';
type ScreenTypes = keyof Theme['media'];
const useScreenType = (screen: ScreenTypes) =>
useMediaQuery(`(max-width: ${media[screen]})`);
useMediaQuery(`(max-width: ${media[screen]})`);
export default useScreenType;

View File

@@ -26,54 +26,54 @@ import enResourceTerms from './locales/en/terms.json';
import enResourceLegal from './locales/en/legal.json';
type I18nFR =
| RemoveSuffix<Leaves<typeof frResourceAbout>>
| RemoveSuffix<Leaves<typeof frResourceAdmin>>
| RemoveSuffix<Leaves<typeof frResourceCommon>>
| RemoveSuffix<Leaves<typeof frResourceHome>>
| RemoveSuffix<Leaves<typeof frResourceLogin>>
| RemoveSuffix<Leaves<typeof frResourcePrivacy>>
| RemoveSuffix<Leaves<typeof frResourceTerms>>
| RemoveSuffix<Leaves<typeof frResourceLegal>>;
| RemoveSuffix<Leaves<typeof frResourceAbout>>
| RemoveSuffix<Leaves<typeof frResourceAdmin>>
| RemoveSuffix<Leaves<typeof frResourceCommon>>
| RemoveSuffix<Leaves<typeof frResourceHome>>
| RemoveSuffix<Leaves<typeof frResourceLogin>>
| RemoveSuffix<Leaves<typeof frResourcePrivacy>>
| RemoveSuffix<Leaves<typeof frResourceTerms>>
| RemoveSuffix<Leaves<typeof frResourceLegal>>;
export type I18nKey = I18nFR;
export const resources = {
en: {
about: enResourceAbout,
admin: enResourceAdmin,
common: enResourceCommon,
home: enResourceHome,
login: enResourceLogin,
privacy: enResourcePrivacy,
terms: enResourceTerms,
legal: enResourceLegal,
},
fr: {
about: frResourceAbout,
admin: frResourceAdmin,
common: frResourceCommon,
home: frResourceHome,
login: frResourceLogin,
privacy: frResourcePrivacy,
terms: frResourceTerms,
legal: frResourceLegal,
},
en: {
about: enResourceAbout,
admin: enResourceAdmin,
common: enResourceCommon,
home: enResourceHome,
login: enResourceLogin,
privacy: enResourcePrivacy,
terms: enResourceTerms,
legal: enResourceLegal,
},
fr: {
about: frResourceAbout,
admin: frResourceAdmin,
common: frResourceCommon,
home: frResourceHome,
login: frResourceLogin,
privacy: frResourcePrivacy,
terms: frResourceTerms,
legal: frResourceLegal,
},
} as const;
export const languages = ['en', 'fr'] as const;
const lng =
typeof window !== 'undefined'
? localStorage.getItem(LS_LANG_KEY) || undefined
: undefined;
typeof window !== 'undefined'
? localStorage.getItem(LS_LANG_KEY) || undefined
: undefined;
i18n.use(initReactI18next).init({
returnNull: false,
resources,
lng,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
defaultNS: 'common',
returnNull: false,
resources,
lng,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
defaultNS: 'common',
});
export default i18n;

View File

@@ -1,42 +1,42 @@
import i18n from '~/i18n';
export function groupItemBy(array: any[], property: string) {
const hash: any = {};
const props = property.split('.');
const hash: any = {};
const props = property.split('.');
for (const item of array) {
const key = props.reduce((acc, prop) => acc && acc[prop], item);
const hashKey =
key !== undefined ? key : i18n.t('common:collection.collections');
for (const item of array) {
const key = props.reduce((acc, prop) => acc && acc[prop], item);
const hashKey =
key !== undefined ? key : i18n.t('common:collection.collections');
if (!hash[hashKey]) {
hash[hashKey] = [];
}
if (!hash[hashKey]) {
hash[hashKey] = [];
}
hash[hashKey].push(item);
}
hash[hashKey].push(item);
}
return hash;
return hash;
}
// Thanks S/O
export function arrayMove<T>(
arr: T[],
previousIndex: number,
nextIndex: number
arr: T[],
previousIndex: number,
nextIndex: number
): T[] {
const arrayCopy = [...arr];
const [removedElement] = arrayCopy.splice(previousIndex, 1);
const arrayCopy = [...arr];
const [removedElement] = arrayCopy.splice(previousIndex, 1);
if (nextIndex >= arr.length) {
// Pad the array with undefined elements if needed
const padding = nextIndex - arr.length + 1;
arrayCopy.push(...new Array(padding).fill(undefined));
}
if (nextIndex >= arr.length) {
// Pad the array with undefined elements if needed
const padding = nextIndex - arr.length + 1;
arrayCopy.push(...new Array(padding).fill(undefined));
}
arrayCopy.splice(nextIndex, 0, removedElement);
return arrayCopy;
arrayCopy.splice(nextIndex, 0, removedElement);
return arrayCopy;
}
export const sortByCreationDate = <T extends { createdAt: any }>(arr: T[]) =>
arr.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));
arr.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));

View File

@@ -1,28 +1,28 @@
import { CollectionWithLinks } from '~/types/app';
export default function sortCcollectionsByNextId(
collections: CollectionWithLinks[]
collections: CollectionWithLinks[]
): CollectionWithLinks[] {
const sortedCollections: CollectionWithLinks[] = [];
const sortedCollections: CollectionWithLinks[] = [];
const visit = (collection: CollectionWithLinks) => {
// Check if the collection has been visited
if (sortedCollections.includes(collection)) {
return;
}
const visit = (collection: CollectionWithLinks) => {
// Check if the collection has been visited
if (sortedCollections.includes(collection)) {
return;
}
// Visit the next collection recursively
const nextCollection = collections.find((c) => c.id === collection.nextId);
if (nextCollection) {
visit(nextCollection);
}
// Visit the next collection recursively
const nextCollection = collections.find((c) => c.id === collection.nextId);
if (nextCollection) {
visit(nextCollection);
}
// Add the current collection to the sorted array
sortedCollections.push(collection);
};
// Add the current collection to the sorted array
sortedCollections.push(collection);
};
// Visit each collection to build the sorted array
collections.forEach((collection) => visit(collection));
// Visit each collection to build the sorted array
collections.forEach((collection) => visit(collection));
return sortedCollections.reverse();
return sortedCollections.reverse();
}

View File

@@ -1,6 +1,6 @@
import hexRgb from 'hex-rgb';
export const rgba = (hex: string, alpha: number) => {
const rgb = hexRgb(hex, { format: 'array' }).slice(0, -1).join(',');
return `rgba(${rgb},${alpha})`;
const rgb = hexRgb(hex, { format: 'array' }).slice(0, -1).join(',');
return `rgba(${rgb},${alpha})`;
};

View File

@@ -3,20 +3,20 @@ import { makeRequest } from '~/lib/request';
import { Link } from '~/types/app';
export const onFavorite = (
linkId: Link['id'],
isFavorite: boolean,
cb: () => void
linkId: Link['id'],
isFavorite: boolean,
cb: () => void
) => {
const { url, method } = route('link.toggle-favorite', {
params: { id: linkId.toString() },
});
makeRequest({
url,
method,
body: {
favorite: isFavorite,
},
})
.then(() => cb())
.catch(console.error);
const { url, method } = route('link.toggle-favorite', {
params: { id: linkId.toString() },
});
makeRequest({
url,
method,
body: {
favorite: isFavorite,
},
})
.then(() => cb())
.catch(console.error);
};

View File

@@ -3,4 +3,4 @@ export const isImage = (type: string) => type.includes('image');
export const isBase64Image = (data: string) => data.startsWith('data:image/');
export const convertBase64ToBuffer = (base64: string) =>
Buffer.from(base64, 'base64');
Buffer.from(base64, 'base64');

View File

@@ -1,28 +1,28 @@
import { Collection, Link } from '~/types/app';
export const appendCollectionId = (
url: string,
collectionId?: Collection['id'] | null | undefined
url: string,
collectionId?: Collection['id'] | null | undefined
) => `${url}${collectionId ? `?collectionId=${collectionId}` : ''}`;
export const appendLinkId = (
url: string,
linkId?: Link['id'] | null | undefined
url: string,
linkId?: Link['id'] | null | undefined
) => `${url}${linkId ? `?linkId=${linkId}` : ''}`;
export const appendResourceId = (
url: string,
resourceId?: Collection['id'] | Link['id']
url: string,
resourceId?: Collection['id'] | Link['id']
) => `${url}${resourceId ? `/${resourceId}` : ''}`;
export function isValidHttpUrl(urlParam: string) {
let url;
let url;
try {
url = new URL(urlParam);
} catch (_) {
return false;
}
try {
url = new URL(urlParam);
} catch (_) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
return url.protocol === 'http:' || url.protocol === 'https:';
}

View File

@@ -1,27 +1,27 @@
import i18n from '~/i18n';
export async function makeRequest({
method = 'GET',
url,
body,
controller,
method = 'GET',
url,
body,
controller,
}: {
method?: RequestInit['method'];
url: string;
body?: object | any[];
controller?: AbortController;
method?: RequestInit['method'];
url: string;
body?: object | any[];
controller?: AbortController;
}): Promise<any> {
const request = await fetch(url, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
},
signal: controller ? controller.signal : undefined,
});
const request = await fetch(url, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
'Content-Type': 'application/json',
},
signal: controller ? controller.signal : undefined,
});
const data = await request.json();
return request.ok
? data
: Promise.reject(data?.error || i18n.t('common:generic-error'));
const data = await request.json();
return request.ok
? data
: Promise.reject(data?.error || i18n.t('common:generic-error'));
}

View File

@@ -13,109 +13,109 @@ import { UserWithRelationCount } from '~/types/app';
dayjs.extend(relativeTime);
interface AdminDashboardProps {
users: UserWithRelationCount[];
totalCollections: number;
totalLinks: number;
users: UserWithRelationCount[];
totalCollections: number;
totalLinks: number;
}
const CenteredCell = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
});
const RenderDateCell = (info: any) => (
<CenteredCell>
<span>{dayjs(info.getValue().toString()).fromNow()}</span>
<span>{dayjs(info.getValue().toString()).format(DATE_FORMAT)}</span>
</CenteredCell>
<CenteredCell>
<span>{dayjs(info.getValue().toString()).fromNow()}</span>
<span>{dayjs(info.getValue().toString()).format(DATE_FORMAT)}</span>
</CenteredCell>
);
const ThemeProvider = (props: AdminDashboardProps) => (
<ContentLayout>
<AdminDashboard {...props} />
</ContentLayout>
<ContentLayout>
<AdminDashboard {...props} />
</ContentLayout>
);
export default ThemeProvider;
function AdminDashboard({
users,
totalCollections,
totalLinks,
users,
totalCollections,
totalLinks,
}: AdminDashboardProps) {
const { t } = useTranslation();
const theme = useTheme();
const columns = useMemo(
() =>
[
{
accessorKey: 'id',
header: (
<>
# <span css={{ color: theme.colors.grey }}>({users.length})</span>
</>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'fullname',
header: t('common:name'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'email',
header: t('common:email'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'count',
header: (
<>
{t('common:collection.collections', { count: totalCollections })}{' '}
<span css={{ color: theme.colors.grey }}>
({totalCollections})
</span>
</>
),
cell: (info) => (info.getValue() as any)?.collection,
},
{
accessorKey: 'count',
header: (
<>
{t('common:link.links', { count: totalLinks })}{' '}
<span css={{ color: theme.colors.grey }}>({totalLinks})</span>
</>
),
cell: (info: any) => info.getValue()?.link,
},
{
accessorKey: 'isAdmin',
header: t('admin:role'),
cell: (info) =>
info.getValue() ? (
<span style={{ color: theme.colors.lightRed }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: theme.colors.green }}>
{t('admin:user')}
</span>
),
},
{
accessorKey: 'createdAt',
header: t('admin:created_at'),
cell: RenderDateCell,
},
{
accessorKey: 'updatedAt',
header: t('admin:updated_at'),
cell: RenderDateCell,
},
] as ColumnDef<UserWithRelationCount>[],
[]
);
return <Table columns={columns} data={users} />;
const { t } = useTranslation();
const theme = useTheme();
const columns = useMemo(
() =>
[
{
accessorKey: 'id',
header: (
<>
# <span css={{ color: theme.colors.grey }}>({users.length})</span>
</>
),
cell: (info) => info.getValue(),
},
{
accessorKey: 'fullname',
header: t('common:name'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'email',
header: t('common:email'),
cell: (info) => info.getValue(),
},
{
accessorKey: 'count',
header: (
<>
{t('common:collection.collections', { count: totalCollections })}{' '}
<span css={{ color: theme.colors.grey }}>
({totalCollections})
</span>
</>
),
cell: (info) => (info.getValue() as any)?.collection,
},
{
accessorKey: 'count',
header: (
<>
{t('common:link.links', { count: totalLinks })}{' '}
<span css={{ color: theme.colors.grey }}>({totalLinks})</span>
</>
),
cell: (info: any) => info.getValue()?.link,
},
{
accessorKey: 'isAdmin',
header: t('admin:role'),
cell: (info) =>
info.getValue() ? (
<span style={{ color: theme.colors.lightRed }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: theme.colors.green }}>
{t('admin:user')}
</span>
),
},
{
accessorKey: 'createdAt',
header: t('admin:created_at'),
cell: RenderDateCell,
},
{
accessorKey: 'updatedAt',
header: t('admin:updated_at'),
cell: RenderDateCell,
},
] as ColumnDef<UserWithRelationCount>[],
[]
);
return <Table columns={columns} data={users} />;
}

Some files were not shown because too many files have changed in this diff Show More