mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 23:15:36 +00:00
feat: recreate dashboard page from previous version
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import styled from '@emotion/styled';
|
||||
import QuickResourceAction from '~/components/dashboard/quick_action/quick_action';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionControlsStyle = styled.span({
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export default function CollectionControls() {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
return (
|
||||
activeCollection && (
|
||||
<CollectionControlsStyle>
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="create"
|
||||
collectionId={activeCollection.id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="collection"
|
||||
action="edit"
|
||||
resourceId={activeCollection.id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="collection"
|
||||
action="remove"
|
||||
resourceId={activeCollection.id}
|
||||
/>
|
||||
</CollectionControlsStyle>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import styled from '@emotion/styled';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionDescriptionStyle = styled.p({
|
||||
fontSize: '0.85em',
|
||||
marginBottom: '0.5em',
|
||||
});
|
||||
|
||||
export default function CollectionDescription() {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
return (
|
||||
activeCollection && (
|
||||
<CollectionDescriptionStyle>
|
||||
{activeCollection?.description}
|
||||
</CollectionDescriptionStyle>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisibilityBadge from '~/components/dashboard/side_nav/visibilty/visibilty';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionNameWrapper = styled.div({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionName = styled.div({
|
||||
minWidth: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const LinksCount = styled.div(({ theme }) => ({
|
||||
minWidth: 'fit-content',
|
||||
fontWeight: 300,
|
||||
fontSize: '0.8em',
|
||||
color: theme.colors.grey,
|
||||
}));
|
||||
|
||||
export default function CollectionHeader() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
if (!activeCollection) return <Fragment />;
|
||||
|
||||
const { name, links, visibility } = activeCollection;
|
||||
return (
|
||||
<CollectionNameWrapper>
|
||||
<CollectionName>{name}</CollectionName>
|
||||
{links.length > 0 && <LinksCount> — {links.length}</LinksCount>}
|
||||
<VisibilityBadge
|
||||
label={t('collection.visibility')}
|
||||
visibility={visibility}
|
||||
/>
|
||||
</CollectionNameWrapper>
|
||||
);
|
||||
}
|
||||
72
inertia/components/dashboard/link/link_favicon.tsx
Normal file
72
inertia/components/dashboard/link/link_favicon.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { TbLoader3 } from 'react-icons/tb';
|
||||
import { TfiWorld } from 'react-icons/tfi';
|
||||
|
||||
interface LinkFaviconProps {
|
||||
url: string;
|
||||
size?: number;
|
||||
noMargin?: boolean;
|
||||
}
|
||||
|
||||
const Favicon = styled.div({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
const FaviconLoader = styled.div(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.colors.white,
|
||||
|
||||
'& > *': {
|
||||
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,
|
||||
noMargin = false,
|
||||
}: LinkFaviconProps) {
|
||||
const [isFailed, setFailed] = useState<boolean>(false);
|
||||
const [isLoading, setLoading] = useState<boolean>(true);
|
||||
const baseUrlApi =
|
||||
(process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
(typeof window !== 'undefined' && window?.location?.origin)) + '/api';
|
||||
if (!baseUrlApi) {
|
||||
console.warn('Missing API URL');
|
||||
}
|
||||
|
||||
const setFallbackFavicon = () => setFailed(true);
|
||||
const handleStopLoading = () => setLoading(false);
|
||||
|
||||
return (
|
||||
<Favicon style={{ marginRight: !noMargin ? '1em' : '0' }}>
|
||||
{!isFailed && baseUrlApi ? (
|
||||
<img
|
||||
src={`${baseUrlApi}/favicon?urlParam=${url}`}
|
||||
onError={() => {
|
||||
setFallbackFavicon();
|
||||
handleStopLoading();
|
||||
}}
|
||||
onLoad={handleStopLoading}
|
||||
height={size}
|
||||
width={size}
|
||||
alt="icon"
|
||||
/>
|
||||
) : (
|
||||
<TfiWorld size={size} />
|
||||
)}
|
||||
{isLoading && (
|
||||
<FaviconLoader style={{ height: `${size}px`, width: `${size}px` }}>
|
||||
<TbLoader3 size={size} />
|
||||
</FaviconLoader>
|
||||
)}
|
||||
</Favicon>
|
||||
);
|
||||
}
|
||||
197
inertia/components/dashboard/link_list/link_item.tsx
Normal file
197
inertia/components/dashboard/link_list/link_item.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import type Link from '#models/link';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback } from 'react';
|
||||
import { AiFillStar } from 'react-icons/ai';
|
||||
import ExternalLink from '~/components/common/external_link';
|
||||
import LinkFavicon from '~/components/dashboard/link/link_favicon';
|
||||
import QuickResourceAction from '~/components/dashboard/quick_action/quick_action';
|
||||
import QuickLinkFavorite from '~/components/dashboard/quick_action/quick_favorite_link';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import { makeRequest } from '~/lib/request';
|
||||
import { theme as globalTheme } from '~/styles/theme';
|
||||
|
||||
const LinkWrapper = styled.li(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
color: theme.colors.primary,
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: '0.75em 1em',
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
outline: '3px solid transparent',
|
||||
}));
|
||||
|
||||
const LinkHeader = styled.div(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
'& > a': {
|
||||
height: '100%',
|
||||
maxWidth: 'calc(100% - 125px)', // TODO: fix this, it is ugly af :(
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&, &:hover': {
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const LinkName = styled.div({
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const LinkControls = styled.div({
|
||||
display: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
|
||||
'& svg': {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
},
|
||||
|
||||
'&:hover *': {
|
||||
transform: 'scale(1.3)',
|
||||
},
|
||||
});
|
||||
|
||||
const LinkDescription = styled.div(({ theme }) => ({
|
||||
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',
|
||||
}));
|
||||
|
||||
const LinkUrlPathname = styled.span({
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export default function LinkItem({
|
||||
link,
|
||||
showUserControls = false,
|
||||
}: {
|
||||
link: Link;
|
||||
showUserControls: boolean;
|
||||
}) {
|
||||
const { id, name, url, description, favorite } = link;
|
||||
const { collections, setCollections } = useCollections();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
setCollections(collectionsCopy);
|
||||
},
|
||||
[collections, setCollections]
|
||||
);
|
||||
|
||||
const onFavorite = () => {
|
||||
makeRequest({
|
||||
url: `${PATHS.API.LINK}/${link.id}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name,
|
||||
url,
|
||||
favorite: !favorite,
|
||||
collectionId: link.collectionId,
|
||||
},
|
||||
})
|
||||
.then(() => toggleFavorite(link.id))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkWrapper key={id}>
|
||||
<LinkHeader>
|
||||
<LinkFavicon url={url} />
|
||||
<ExternalLink href={url} className="reset">
|
||||
<LinkName>
|
||||
{name}{' '}
|
||||
{showUserControls && favorite && (
|
||||
<AiFillStar color={globalTheme.colors.yellow} />
|
||||
)}
|
||||
</LinkName>
|
||||
<LinkItemURL url={url} />
|
||||
</ExternalLink>
|
||||
{showUserControls && (
|
||||
<LinkControls>
|
||||
<QuickLinkFavorite onClick={onFavorite} isFavorite={favorite} />
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="edit"
|
||||
resourceId={id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="remove"
|
||||
resourceId={id}
|
||||
/>
|
||||
</LinkControls>
|
||||
)}
|
||||
</LinkHeader>
|
||||
{description && <LinkDescription>{description}</LinkDescription>}
|
||||
</LinkWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkItemURL({ url }: { url: Link['url'] }) {
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(url);
|
||||
let text = '';
|
||||
|
||||
if (pathname !== '/') {
|
||||
text += pathname;
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
if (text === '') {
|
||||
text += '/';
|
||||
}
|
||||
text += search;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkUrl>
|
||||
{origin}
|
||||
<LinkUrlPathname>{text}</LinkUrlPathname>
|
||||
</LinkUrl>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
return <LinkUrl>{url}</LinkUrl>;
|
||||
}
|
||||
}
|
||||
93
inertia/components/dashboard/link_list/link_list.tsx
Normal file
93
inertia/components/dashboard/link_list/link_list.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { RxHamburgerMenu } from 'react-icons/rx';
|
||||
import CollectionControls from '~/components/dashboard/collection/collection_controls';
|
||||
import CollectionDescription from '~/components/dashboard/collection/collection_description';
|
||||
import CollectionHeader from '~/components/dashboard/collection/collection_header';
|
||||
import LinkItem from '~/components/dashboard/link_list/link_item';
|
||||
import { NoCollection, NoLink } from '~/components/dashboard/link_list/no_item';
|
||||
import Footer from '~/components/footer/footer';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const LinksWrapper = styled.div({
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
padding: '0.5em 0.5em 0',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
const CollectionHeaderWrapper = styled.h2(({ theme }) => ({
|
||||
color: theme.colors.blue,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
gap: '0.4em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
'& > svg': {
|
||||
display: 'flex',
|
||||
},
|
||||
}));
|
||||
|
||||
interface LinksProps {
|
||||
isMobile: boolean;
|
||||
openSideMenu: () => void;
|
||||
}
|
||||
|
||||
export default function Links({
|
||||
isMobile,
|
||||
openSideMenu,
|
||||
}: Readonly<LinksProps>) {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
if (activeCollection === null) {
|
||||
return <NoCollection />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinksWrapper>
|
||||
<CollectionHeaderWrapper>
|
||||
{isMobile && (
|
||||
<Link
|
||||
href="/#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openSideMenu();
|
||||
}}
|
||||
title="Open side nav bar"
|
||||
>
|
||||
<RxHamburgerMenu size={'1.5em'} />
|
||||
</Link>
|
||||
)}
|
||||
<CollectionHeader />
|
||||
<CollectionControls />
|
||||
</CollectionHeaderWrapper>
|
||||
<CollectionDescription />
|
||||
{activeCollection?.links.length !== 0 ? (
|
||||
<LinkListStyle>
|
||||
{activeCollection?.links.map((link) => (
|
||||
<LinkItem link={link} key={link.id} showUserControls />
|
||||
))}
|
||||
</LinkListStyle>
|
||||
) : (
|
||||
<NoLink />
|
||||
)}
|
||||
<Footer />
|
||||
</LinksWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
59
inertia/components/dashboard/link_list/no_item.tsx
Normal file
59
inertia/components/dashboard/link_list/no_item.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
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`,
|
||||
});
|
||||
|
||||
const Text = styled.p({
|
||||
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={PATHS.COLLECTION.CREATE}>{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(PATHS.LINK.CREATE, activeCollection?.id)}>
|
||||
{t('link.create')}
|
||||
</Link>
|
||||
</NoCollectionStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const ActionStyle = styled.div(({ theme }) => ({
|
||||
border: '0 !important',
|
||||
margin: '0 !important',
|
||||
padding: '0 !important',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
'& svg': {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
transition: theme.transition.delay,
|
||||
},
|
||||
|
||||
'&:hover svg': {
|
||||
transform: 'scale(1.25)',
|
||||
},
|
||||
}));
|
||||
|
||||
export default ActionStyle;
|
||||
55
inertia/components/dashboard/quick_action/quick_action.tsx
Normal file
55
inertia/components/dashboard/quick_action/quick_action.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type Collection from '#models/collection';
|
||||
import type Link from '#models/link';
|
||||
import { Link as LinkTag } from '@inertiajs/react';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AiOutlineEdit } from 'react-icons/ai';
|
||||
import { CgTrashEmpty } from 'react-icons/cg';
|
||||
import { IoAddOutline } from 'react-icons/io5';
|
||||
import ActionStyle from '~/components/dashboard/quick_action/_quick_action_style';
|
||||
import { appendCollectionId, appendResourceId } from '~/lib/navigation';
|
||||
|
||||
type Resource = 'collection' | 'link';
|
||||
type Action = 'create' | 'edit' | 'remove';
|
||||
|
||||
const getIconFromAction = (action: Action) => {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return IoAddOutline;
|
||||
case 'edit':
|
||||
return AiOutlineEdit;
|
||||
case 'remove':
|
||||
return CgTrashEmpty;
|
||||
}
|
||||
};
|
||||
|
||||
const QuickResourceActionStyle = ActionStyle.withComponent(LinkTag);
|
||||
export default function QuickResourceAction({
|
||||
resource,
|
||||
action,
|
||||
collectionId,
|
||||
resourceId,
|
||||
onClick,
|
||||
}: {
|
||||
resource: Resource;
|
||||
action: Action;
|
||||
collectionId?: Collection['id'];
|
||||
resourceId?: Collection['id'] | Link['id'];
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const ActionIcon = getIconFromAction(action);
|
||||
|
||||
return (
|
||||
<QuickResourceActionStyle
|
||||
href={appendCollectionId(
|
||||
appendResourceId(`/${resource}/${action}`, resourceId),
|
||||
collectionId
|
||||
)}
|
||||
title={t(`${resource}.${action}`)}
|
||||
onClick={onClick ? (event) => onClick(event) : undefined}
|
||||
>
|
||||
<ActionIcon />
|
||||
</QuickResourceActionStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
|
||||
import ActionStyle from '~/components/dashboard/quick_action/_quick_action_style';
|
||||
import { theme } from '~/styles/theme';
|
||||
|
||||
const QuickLinkFavoriteStyle = ActionStyle.withComponent('div');
|
||||
const QuickLinkFavorite = ({
|
||||
isFavorite,
|
||||
onClick,
|
||||
}: {
|
||||
isFavorite?: boolean;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
}) => (
|
||||
<QuickLinkFavoriteStyle
|
||||
onClick={onClick ? (event) => onClick(event) : undefined}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<AiFillStar color={theme.colors.yellow} />
|
||||
) : (
|
||||
<AiOutlineStar color={theme.colors.yellow} />
|
||||
)}
|
||||
</QuickLinkFavoriteStyle>
|
||||
);
|
||||
|
||||
export default QuickLinkFavorite;
|
||||
@@ -0,0 +1,223 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import type Collection from '#models/collection';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
|
||||
import VisibilityBadge from '~/components/dashboard/side_nav/visibilty/visibilty';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import { arrayMove } from '~/lib/array';
|
||||
import sortCcollectionsByNextId from '~/lib/collection';
|
||||
import { makeRequest } from '~/lib/request';
|
||||
|
||||
interface CollectionItemProps {
|
||||
collection: Collection;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type CollectionDragItem = {
|
||||
collectionId: Collection['id'];
|
||||
index: number;
|
||||
};
|
||||
|
||||
const CollectionItemStyle = styled.li<{ active: boolean }>(
|
||||
({ theme, active }) => ({
|
||||
color: active ? theme.colors.blue : theme.colors.font,
|
||||
borderBottom: active
|
||||
? `'2px solid ${theme.colors.lightestGrey} !important'`
|
||||
: '2px solid transparent !important',
|
||||
display: 'flex',
|
||||
gap: '0.25em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: `${theme.transition.delay} box-shadow`,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.blue,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const CollectionContent = styled.div({
|
||||
width: 'calc(100% - 24px)',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionNameWrapper = styled.div({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionName = styled.div({
|
||||
minWidth: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const CollectionLinksCount = styled.div(({ theme }) => ({
|
||||
minWidth: 'fit-content',
|
||||
fontSize: '0.85em',
|
||||
color: theme.colors.grey,
|
||||
}));
|
||||
|
||||
export default function CollectionItem({
|
||||
collection,
|
||||
index,
|
||||
}: Readonly<CollectionItemProps>): JSX.Element {
|
||||
const { activeCollection, setActiveCollection } = useActiveCollection();
|
||||
const { collections, setCollections } = useCollections();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = useRef<HTMLLIElement>();
|
||||
|
||||
const sendMoveCollectionRequest = useCallback(
|
||||
async (currentCollection: Collection, nextId?: string) => {
|
||||
if (currentCollection.id === nextId) return;
|
||||
|
||||
await makeRequest({
|
||||
url: `${PATHS.API.COLLECTION}/${currentCollection.id}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: currentCollection.name,
|
||||
nextId,
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
setCollections((prevCollections) => {
|
||||
const newCollections = [...prevCollections];
|
||||
const collectionIndex = newCollections.findIndex(
|
||||
(c) => c.id === currentCollection.id
|
||||
);
|
||||
|
||||
const previousCollectionIndex = newCollections.findIndex(
|
||||
(c) => c.nextId === currentCollection.id
|
||||
);
|
||||
const prevNextCollectionIndex = newCollections.findIndex(
|
||||
(c) => c.nextId === nextId
|
||||
);
|
||||
|
||||
newCollections[collectionIndex] = {
|
||||
...newCollections[collectionIndex],
|
||||
nextId,
|
||||
};
|
||||
if (previousCollectionIndex !== -1) {
|
||||
newCollections[previousCollectionIndex] = {
|
||||
...newCollections[previousCollectionIndex],
|
||||
nextId: currentCollection.nextId,
|
||||
};
|
||||
}
|
||||
if (prevNextCollectionIndex !== -1) {
|
||||
newCollections[prevNextCollectionIndex] = {
|
||||
...newCollections[prevNextCollectionIndex],
|
||||
nextId: currentCollection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return sortCcollectionsByNextId(newCollections);
|
||||
});
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
const moveCollection = useCallback(
|
||||
(currentIndex: number, newIndex: number) => {
|
||||
// @ts-ignore
|
||||
setCollections((prevCollections: Collection[]) =>
|
||||
arrayMove(prevCollections, currentIndex, newIndex)
|
||||
);
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const [_, drop] = useDrop({
|
||||
accept: 'collection',
|
||||
hover: (dragItem: CollectionDragItem) => {
|
||||
if (ref.current && dragItem.collectionId !== collection.id) {
|
||||
moveCollection(dragItem.index, index);
|
||||
dragItem.index = index;
|
||||
}
|
||||
},
|
||||
drop: (item) => {
|
||||
const currentCollection = collections.find(
|
||||
(c) => c.id === item.collectionId
|
||||
);
|
||||
const nextCollection = collections[item.index + 1];
|
||||
if (
|
||||
currentCollection?.nextId === null &&
|
||||
nextCollection?.id === undefined
|
||||
)
|
||||
return;
|
||||
if (currentCollection?.nextId !== nextCollection?.id) {
|
||||
sendMoveCollectionRequest(
|
||||
currentCollection!,
|
||||
nextCollection?.id ?? null
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [{ opacity }, drag] = useDrag({
|
||||
type: 'collection',
|
||||
item: () => ({ index, collectionId: collection.id }),
|
||||
collect: (monitor: any) => ({
|
||||
opacity: monitor.isDragging() ? 0.1 : 1,
|
||||
}),
|
||||
end: (dragItem: CollectionDragItem, monitor) => {
|
||||
const didDrop = monitor.didDrop();
|
||||
if (!didDrop) {
|
||||
moveCollection(dragItem.index, index);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collection.id === activeCollection?.id) {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [collection.id, activeCollection?.id]);
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<CollectionItemStyle
|
||||
style={{
|
||||
transition: 'none',
|
||||
opacity,
|
||||
}}
|
||||
onClick={() => setActiveCollection(collection)}
|
||||
title={collection.name}
|
||||
active={collection.id === activeCollection?.id}
|
||||
// @ts-ignore
|
||||
ref={ref}
|
||||
>
|
||||
{collection.id === activeCollection?.id ? (
|
||||
<AiFillFolderOpen size={24} />
|
||||
) : (
|
||||
<AiOutlineFolder size={24} />
|
||||
)}
|
||||
|
||||
<CollectionContent>
|
||||
<CollectionNameWrapper>
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
<CollectionLinksCount>
|
||||
— {collection.links.length}
|
||||
</CollectionLinksCount>
|
||||
</CollectionNameWrapper>
|
||||
<VisibilityBadge
|
||||
label={t('common:collection.visibility')}
|
||||
visibility={collection.visibility}
|
||||
/>
|
||||
</CollectionContent>
|
||||
</CollectionItemStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo } from 'react';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UnstyledList from '~/components/common/unstyled/unstyled_list';
|
||||
import CollectionItem from '~/components/dashboard/side_nav/collection/collection_item';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
|
||||
import Keys from '../../../../../app/constants/keys';
|
||||
|
||||
const CollectionListStyle = styled.div({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export default function CollectionList() {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useCollections();
|
||||
const { activeCollection, setActiveCollection } = useActiveCollection();
|
||||
const { globalHotkeysEnabled } = useGlobalHotkeys();
|
||||
|
||||
const linksCount = useMemo(
|
||||
() =>
|
||||
collections.reduce((acc, current) => (acc += current.links.length), 0),
|
||||
[collections]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_UP,
|
||||
() => {
|
||||
const currentCollectionIndex = collections.findIndex(
|
||||
({ id }) => id === activeCollection?.id
|
||||
);
|
||||
if (currentCollectionIndex === -1 || currentCollectionIndex === 0) return;
|
||||
|
||||
setActiveCollection(collections[currentCollectionIndex - 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled }
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_DOWN,
|
||||
() => {
|
||||
const currentCollectionIndex = collections.findIndex(
|
||||
({ id }) => id === activeCollection?.id
|
||||
);
|
||||
if (
|
||||
currentCollectionIndex === -1 ||
|
||||
currentCollectionIndex === collections.length - 1
|
||||
)
|
||||
return;
|
||||
|
||||
setActiveCollection(collections[currentCollectionIndex + 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled }
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionListStyle>
|
||||
<h4>
|
||||
{t('collection.collections', { count: linksCount })} • {linksCount}
|
||||
</h4>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ListOfCollections />
|
||||
</DndProvider>
|
||||
</CollectionListStyle>
|
||||
);
|
||||
}
|
||||
|
||||
function ListOfCollections() {
|
||||
const [, drop] = useDrop(() => ({ accept: 'collection' }));
|
||||
const { collections } = useCollections();
|
||||
|
||||
return (
|
||||
<UnstyledList
|
||||
css={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
// @ts-ignore
|
||||
ref={drop}
|
||||
>
|
||||
{collections.map((collection, index) => (
|
||||
<CollectionItem
|
||||
collection={collection}
|
||||
key={collection.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</UnstyledList>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type Link from '#models/link';
|
||||
import styled from '@emotion/styled';
|
||||
import ExternalLink from '~/components/common/external_link';
|
||||
import LinkFavicon from '~/components/dashboard/link/link_favicon';
|
||||
|
||||
const FavoriteLinkStyle = styled.li(({ theme }) => ({
|
||||
width: '100%',
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
borderBottom: `2px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.blue,
|
||||
backgroundColor: theme.colors.lightGrey,
|
||||
borderBottom: `2px solid ${theme.colors.blue}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const LinkStyle = styled(ExternalLink)({
|
||||
width: '100%',
|
||||
color: 'inherit',
|
||||
padding: '0.5em 1em',
|
||||
border: '0 !important',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25em',
|
||||
});
|
||||
|
||||
const LinkName = styled.span({
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const CollectionName = styled.span(({ theme }) => ({
|
||||
maxWidth: '75px',
|
||||
fontSize: '0.85em',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.colors.grey,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const FavoriteItem = ({
|
||||
link,
|
||||
}: Readonly<{
|
||||
link: Link;
|
||||
}>) => (
|
||||
<FavoriteLinkStyle>
|
||||
<LinkStyle
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={link.name}
|
||||
>
|
||||
<LinkFavicon url={link.url} size={24} noMargin />
|
||||
<LinkName>{link.name}</LinkName>
|
||||
<CollectionName> - {link.collection.name}</CollectionName>
|
||||
</LinkStyle>
|
||||
</FavoriteLinkStyle>
|
||||
);
|
||||
export default FavoriteItem;
|
||||
@@ -0,0 +1,44 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UnstyledList from '~/components/common/unstyled/unstyled_list';
|
||||
import FavoriteItem from '~/components/dashboard/side_nav/favorites/favorite_item';
|
||||
import useFavorites from '~/hooks/use_favorites';
|
||||
|
||||
const FavoriteListWrapper = styled.div({
|
||||
height: 'auto',
|
||||
width: '100%',
|
||||
marginBottom: '15px',
|
||||
});
|
||||
|
||||
const FavoriteListLegend = styled.h4(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.85em',
|
||||
fontWeight: '500',
|
||||
color: theme.colors.grey,
|
||||
marginBottom: '5px',
|
||||
}));
|
||||
|
||||
const FavoriteListStyle = styled(UnstyledList)({
|
||||
display: 'flex',
|
||||
gap: '5px',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export default function FavoriteList() {
|
||||
const { t } = useTranslation('common');
|
||||
const { favorites } = useFavorites();
|
||||
|
||||
return (
|
||||
favorites.length !== 0 && (
|
||||
<FavoriteListWrapper>
|
||||
<FavoriteListLegend>{t('favorite')}</FavoriteListLegend>
|
||||
<FavoriteListStyle>
|
||||
{favorites.map((link) => (
|
||||
<FavoriteItem link={link} key={link.id} />
|
||||
))}
|
||||
</FavoriteListStyle>
|
||||
</FavoriteListWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const MenuControls = styled.div({
|
||||
margin: '10px 0',
|
||||
display: 'flex',
|
||||
gap: '0.25em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
const MenuAction = styled.div({
|
||||
fontSize: '0.95em',
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
});
|
||||
|
||||
export default function NavigationLinks() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
return (
|
||||
<MenuControls>
|
||||
{/* <MenuAction>
|
||||
<SearchModal>{t('common:search')}</SearchModal>
|
||||
<kbd>S</kbd>
|
||||
</MenuAction> */}
|
||||
<MenuAction>
|
||||
<Link href={PATHS.COLLECTION.CREATE}>{t('collection.create')}</Link>
|
||||
<kbd>C</kbd>
|
||||
</MenuAction>
|
||||
<MenuAction>
|
||||
<Link
|
||||
href={`${PATHS.LINK.CREATE}?collectionId=${activeCollection?.id}`}
|
||||
>
|
||||
{t('link.create')}
|
||||
</Link>
|
||||
<kbd>L</kbd>
|
||||
</MenuAction>
|
||||
</MenuControls>
|
||||
);
|
||||
}
|
||||
70
inertia/components/dashboard/side_nav/side_navigation.tsx
Normal file
70
inertia/components/dashboard/side_nav/side_navigation.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import styled from '@emotion/styled';
|
||||
import CollectionList from '~/components/dashboard/side_nav/collection/collection_list';
|
||||
import FavoriteList from '~/components/dashboard/side_nav/favorites/favorite_list';
|
||||
import NavigationLinks from '~/components/dashboard/side_nav/navigation_links/navigation_links';
|
||||
import UserCard from '~/components/dashboard/side_nav/user_card/user_card';
|
||||
|
||||
const SideMenu = styled.nav({
|
||||
height: '100%',
|
||||
width: '325px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const BlockWrapper = styled.section(({ theme }) => ({
|
||||
height: 'auto',
|
||||
width: '100%',
|
||||
|
||||
'& h4': {
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.85em',
|
||||
fontWeight: 500,
|
||||
color: theme.colors.grey,
|
||||
marginBottom: '5px',
|
||||
},
|
||||
|
||||
'& ul': {
|
||||
flex: 1,
|
||||
animation: 'fadein 0.3s both',
|
||||
},
|
||||
|
||||
'& ul li': {
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
fontSize: '0.9em',
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: '0.5em 1em',
|
||||
borderBottom: `2px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:not(:last-child)': {
|
||||
marginBottom: '5px',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SideNavigation = () => (
|
||||
<SideMenu>
|
||||
<BlockWrapper>
|
||||
<FavoriteList />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper css={{ minHeight: '0', flex: '1' }}>
|
||||
<CollectionList />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<NavigationLinks />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<UserCard />
|
||||
</BlockWrapper>
|
||||
</SideMenu>
|
||||
);
|
||||
|
||||
export default SideNavigation;
|
||||
@@ -0,0 +1,73 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MdAdminPanelSettings } from 'react-icons/md';
|
||||
import RoundedImage from '~/components/common/rounded_image';
|
||||
import useUser from '~/hooks/use_user';
|
||||
|
||||
const UserCardWrapper = styled.div(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
color: theme.colors.black,
|
||||
backgroundColor: theme.colors.white,
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
padding: '7px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
||||
'& button, & a': {
|
||||
cursor: 'pointer',
|
||||
color: theme.colors.blue,
|
||||
display: 'flex',
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:hover': {
|
||||
transform: 'scale(1.3)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const UserCardStyle = styled.div({
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const UserControls = styled.div({
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
});
|
||||
|
||||
export default function UserCard() {
|
||||
const { user } = useUser();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const avatarLabel = t('avatar', {
|
||||
name: user.name,
|
||||
});
|
||||
return (
|
||||
<UserCardWrapper>
|
||||
<UserCardStyle>
|
||||
<RoundedImage
|
||||
src={user.avatarUrl}
|
||||
width={28}
|
||||
height={28}
|
||||
alt={avatarLabel}
|
||||
title={avatarLabel}
|
||||
/>
|
||||
{user.nickName}
|
||||
</UserCardStyle>
|
||||
<UserControls>
|
||||
{user.is_admin && (
|
||||
<Link href={PATHS.ADMIN} className="reset">
|
||||
<MdAdminPanelSettings color="red" size={24} />
|
||||
</Link>
|
||||
)}
|
||||
{/* <SettingsModal /> */}
|
||||
</UserControls>
|
||||
</UserCardWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IoEarthOutline } from 'react-icons/io5';
|
||||
import { Visibility } from '../../../../../app/enums/visibility';
|
||||
|
||||
const VisibilityStyle = styled.span(({ theme }) => ({
|
||||
fontWeight: 300,
|
||||
fontSize: '0.6em',
|
||||
color: theme.colors.lightBlue,
|
||||
border: `1px solid ${theme.colors.lightBlue}`,
|
||||
borderRadius: '50px',
|
||||
padding: '0.15em 0.65em',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const VisibilityBadge = ({
|
||||
label,
|
||||
visibility,
|
||||
}: {
|
||||
label: string;
|
||||
visibility: Visibility;
|
||||
}) =>
|
||||
visibility === Visibility.PUBLIC && (
|
||||
<VisibilityStyle>
|
||||
{label} <IoEarthOutline size="1em" />
|
||||
</VisibilityStyle>
|
||||
);
|
||||
|
||||
export default VisibilityBadge;
|
||||
10
inertia/components/dashboard/swiper_handler.tsx
Normal file
10
inertia/components/dashboard/swiper_handler.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const SwiperHandler = styled.div(({ theme }) => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
transition: `background-color ${theme.transition.delay}`,
|
||||
}));
|
||||
|
||||
export default SwiperHandler;
|
||||
Reference in New Issue
Block a user