feat: recreate dashboard page from previous version

This commit is contained in:
Sonny
2024-05-03 02:10:15 +02:00
committed by Sonny
parent 2cc490b611
commit 2cf8c5ae02
66 changed files with 2087 additions and 86 deletions

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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