refactor: remove all legacy files

+ comment/delete things that haven't yet migrated to mantine
This commit is contained in:
Sonny
2024-11-07 00:29:58 +01:00
committed by Sonny
parent 861906d29b
commit 5c37fe9c31
148 changed files with 469 additions and 4728 deletions

View File

@@ -1,39 +0,0 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import CollectionHeader from '~/components/dashboard/collection/header/collection_header';
import LinkList from '~/components/dashboard/link/link_list';
import { NoCollection } from '~/components/dashboard/link/no_item';
import Footer from '~/components/footer/footer';
import { useActiveCollection } from '~/store/collection_store';
export interface CollectionHeaderProps {
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',
});
export default function CollectionContainer(props: CollectionHeaderProps) {
const { activeCollection } = useActiveCollection();
if (activeCollection === null) {
return <NoCollection />;
}
return (
<CollectionContainerStyle>
<CollectionHeader {...props} />
<LinkList links={activeCollection.links} />
<Footer css={{ paddingBottom: 0 }} />
</CollectionContainerStyle>
);
}

View File

@@ -1,42 +0,0 @@
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { GoPencil } from 'react-icons/go';
import { IoIosAddCircleOutline } from 'react-icons/io';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import { DropdownItemLink } from '~/components/common/dropdown/dropdown_item';
import { appendCollectionId } from '~/lib/navigation';
import { Collection } from '~/types/app';
export default function CollectionControls({
collectionId,
}: {
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>
);
}

View File

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

View File

@@ -1,87 +0,0 @@
import styled from '@emotion/styled';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import TextEllipsis from '~/components/common/text_ellipsis';
import { CollectionHeaderProps } from '~/components/dashboard/collection/collection_container';
import CollectionControls from '~/components/dashboard/collection/header/collection_controls';
import CollectionDescription from '~/components/dashboard/collection/header/collection_description';
import VisibilityBadge from '~/components/visibilty/visibilty';
import { useActiveCollection } from '~/store/collection_store';
const paddingLeft = '1.25em';
const paddingRight = '1.65em';
const CollectionHeaderWrapper = styled.div(({ theme }) => ({
minWidth: 0,
width: '100%',
paddingInline: `${paddingLeft} ${paddingRight}`,
marginBottom: '0.5em',
[`@media (max-width: ${theme.media.tablet})`]: {
paddingInline: 0,
marginBottom: '1rem',
},
}));
const CollectionHeaderStyle = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
'& > svg': {
display: 'flex',
},
});
const CollectionName = styled.h2(({ theme }) => ({
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))`,
},
}));
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.8em',
color: theme.colors.grey,
}));
export default function CollectionHeader({
showButtons,
showControls = true,
openNavigationItem,
openCollectionItem,
}: CollectionHeaderProps) {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
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>
);
}

View File

@@ -0,0 +1,44 @@
.link {
width: 100%;
max-width: 100%;
display: flex;
align-items: center;
text-decoration: none;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
.linkIcon {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
&[data-active] {
&,
&:hover {
background-color: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-light-color);
.linkIcon {
color: var(--mantine-color-blue-light-color);
}
}
}
}
.linkIcon {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-right: var(--mantine-spacing-sm);
width: rem(25px);
height: rem(25px);
min-width: rem(25px);
}

View File

@@ -0,0 +1,48 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Text } from '@mantine/core';
import { useEffect, useRef } from 'react';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { CollectionWithLinks } from '~/types/app';
import classes from './collection_item.module.css';
export default function CollectionItem({
collection,
}: {
collection: CollectionWithLinks;
}) {
const itemRef = useRef<HTMLAnchorElement>(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]);
const linksCount = collection?.links.length ?? 0;
const showLinks = linksCount > 0;
return (
<Link
className={classes.link}
data-active={isActiveCollection || undefined}
href={appendCollectionId(route('dashboard').path, collection.id)}
key={collection.id}
ref={itemRef}
>
<FolderIcon className={classes.linkIcon} />
<Text lineClamp={1} maw={showLinks ? '160px' : '200px'}>
{collection.name}
</Text>
{showLinks && (
<Text c="var(--mantine-color-gray-5)" ml="xs">
{linksCount}
</Text>
)}
</Link>
);
}

View File

@@ -1,57 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useEffect, useRef } from 'react';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import TextEllipsis from '~/components/common/text_ellipsis';
import { Item } from '~/components/dashboard/side_nav/nav_item';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { CollectionWithLinks } from '~/types/app';
const CollectionItemStyle = styled(Item, {
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
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,
}));
export default function CollectionItem({
collection,
}: {
collection: CollectionWithLinks;
}) {
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]);
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

@@ -0,0 +1,24 @@
.sideMenu {
height: 100%;
width: 100%;
display: flex;
gap: 0.35em;
flex-direction: column;
}
.listContainer {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.collectionList {
padding: 1px;
padding-right: 5px;
display: flex;
flex: 1;
gap: 0.35em;
flex-direction: column;
overflow: auto;
}

View File

@@ -1,37 +1,9 @@
import styled from '@emotion/styled';
import { Box, ScrollArea, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CollectionItem from '~/components/dashboard/collection/list/collection_item';
import CollectionListContainer from '~/components/dashboard/collection/list/collection_list_container';
import CollectionItem from '~/components/dashboard/collection/item/collection_item';
import useShortcut from '~/hooks/use_shortcut';
import { useActiveCollection, useCollections } from '~/store/collection_store';
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',
}));
const CollectionLabel = styled.p(({ theme }) => ({
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',
});
import styles from './collection_list.module.css';
export default function CollectionList() {
const { t } = useTranslation('common');
@@ -64,18 +36,20 @@ export default function CollectionList() {
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>
<Box className={styles.sideMenu}>
<Box className={styles.listContainer}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text c="dimmed" ml="md" mb="sm">
{t('collection.collections', { count: collections.length })} {' '}
{collections.length}
</Text>
<ScrollArea className={styles.collectionList}>
{collections.map((collection) => (
<CollectionItem collection={collection} key={collection.id} />
))}
</ScrollArea>
</div>
</Box>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,19 @@
import { AppShell, Burger, Group, ScrollArea, Text } from '@mantine/core';
import CollectionList from '~/components/dashboard/collection/list/collection_list';
interface DashboardAsideProps {
isOpen: boolean;
toggle: () => void;
}
export const DashboardAside = ({ isOpen, toggle }: DashboardAsideProps) => (
<AppShell.Aside p="md">
<Group justify="space-between" hiddenFrom="md" mb="md">
<Text>Collections</Text>
<Burger opened={isOpen} onClick={toggle} hiddenFrom="md" size="md" />
</Group>
<AppShell.Section grow component={ScrollArea}>
<CollectionList />
</AppShell.Section>
</AppShell.Aside>
);

View File

@@ -0,0 +1,90 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { ActionIcon, AppShell, Burger, Group, Menu, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { GoPencil } from 'react-icons/go';
import { IoIosAddCircleOutline } from 'react-icons/io';
import { IoTrashOutline } from 'react-icons/io5';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
interface DashboardHeaderProps {
navbar: {
opened: boolean;
toggle: () => void;
};
aside: {
opened: boolean;
toggle: () => void;
};
}
export function DashboardHeader({ navbar, aside }: DashboardHeaderProps) {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<AppShell.Header style={{ display: 'flex', alignItems: 'center' }}>
<Group justify="space-between" px="md" flex={1}>
<Group h="100%">
<Burger
opened={navbar.opened}
onClick={navbar.toggle}
hiddenFrom="sm"
size="sm"
/>
<Text lineClamp={1}>{activeCollection?.name}</Text>
</Group>
<Group>
<Menu withinPortal shadow="md" width={225}>
<Menu.Target>
<ActionIcon variant="subtle" color="var(--mantine-color-text)">
<BsThreeDotsVertical />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
component={Link}
href={appendCollectionId(
route('link.create-form').path,
activeCollection?.id
)}
leftSection={<IoIosAddCircleOutline />}
color="blue"
>
{t('link.create')}
</Menu.Item>
<Menu.Item
component={Link}
href={appendCollectionId(
route('collection.edit-form').path,
activeCollection?.id
)}
leftSection={<GoPencil />}
color="blue"
>
{t('collection.edit')}
</Menu.Item>
<Menu.Item
component={Link}
href={appendCollectionId(
route('collection.delete-form').path,
activeCollection?.id
)}
leftSection={<IoTrashOutline />}
color="red"
>
{t('collection.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Burger
opened={aside.opened}
onClick={aside.toggle}
hiddenFrom="md"
size="md"
/>
</Group>
</Group>
</AppShell.Header>
);
}

View File

@@ -0,0 +1,90 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import {
AppShell,
Burger,
Divider,
Group,
NavLink,
rem,
ScrollArea,
Text,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoIosSearch } from 'react-icons/io';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import { PiGearLight } from 'react-icons/pi';
import { MantineUserCard } from '~/components/common/user_card';
import { FavoriteList } from '~/components/dashboard/favorite/favorite_list';
import useUser from '~/hooks/use_user';
interface DashboardNavbarProps {
isOpen: boolean;
toggle: () => void;
}
export function DashboardNavbar({ isOpen, toggle }: DashboardNavbarProps) {
const { t } = useTranslation('common');
const { isAuthenticated, user } = useUser();
const common = {
variant: 'subtle',
color: 'blue',
active: true,
styles: {
label: {
fontSize: rem(16),
},
root: {
borderRadius: rem(5),
},
},
};
return (
<AppShell.Navbar p="md">
<Group hiddenFrom="sm" mb="md">
<Burger opened={isOpen} onClick={toggle} size="sm" />
<Text>Navigation</Text>
</Group>
<MantineUserCard />
<Divider mt="xs" mb="md" />
{isAuthenticated && user.isAdmin && (
<NavLink
{...common}
component={Link}
href={route('admin.dashboard').url}
label={t('admin')}
leftSection={<IoShieldHalfSharp size="1.5rem" />}
color="var(--mantine-color-red-5)"
/>
)}
<NavLink
{...common}
label={t('settings')}
leftSection={<PiGearLight size="1.5rem" />}
color="var(--mantine-color-text)"
/>
<NavLink
{...common}
label={t('search')}
leftSection={<IoIosSearch size="1.5rem" />}
/>
<NavLink
{...common}
component={Link}
href={route('link.create-form').url}
label={t('link.create')}
leftSection={<IoAdd size="1.5rem" />}
/>
<NavLink
{...common}
component={Link}
href={route('collection.create-form').url}
label={t('collection.create')}
leftSection={<AiOutlineFolderAdd size="1.5rem" />}
/>
<AppShell.Section grow component={ScrollArea}>
<FavoriteList />
</AppShell.Section>
</AppShell.Navbar>
);
}

View File

@@ -1,95 +0,0 @@
import { router } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { ReactNode, useMemo, useState } from 'react';
import { ActiveCollectionContext } from '~/contexts/active_collection_context';
import CollectionsContext from '~/contexts/collections_context';
import FavoritesContext from '~/contexts/favorites_context';
import GlobalHotkeysContext from '~/contexts/global_hotkeys_context';
import useShortcut from '~/hooks/use_shortcut';
import { appendCollectionId } from '~/lib/navigation';
import { CollectionWithLinks, LinkWithCollection } from '~/types/app';
export default function DashboardProviders(
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 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]
);
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,
}
);
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

@@ -0,0 +1,28 @@
.sideMenu {
height: 100%;
width: 100%;
display: flex;
gap: 0.35em;
flex-direction: column;
}
.listContainer {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.collectionList {
padding: 1px;
padding-right: 5px;
display: flex;
flex: 1;
gap: 0.35em;
flex-direction: column;
overflow: auto;
}
.noFavorite {
text-align: center;
}

View File

@@ -0,0 +1,39 @@
import { Box, Group, ScrollArea, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FavoriteItem } from '~/components/dashboard/favorite/item/favorite_item';
import { useFavorites } from '~/store/collection_store';
import styles from './favorite_list.module.css';
export function FavoriteList() {
const { t } = useTranslation('common');
const { favorites } = useFavorites();
if (favorites.length === 0) {
return (
<Group justify="center">
<Text c="dimmed" size="sm" mt="sm">
{t('favorites-appears-here')}
</Text>
</Group>
);
}
return (
<Box className={styles.sideMenu}>
<Box className={styles.listContainer}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text c="dimmed" mt="xs" ml="md" mb={4}>
{t('favorite')} {favorites.length}
</Text>
<ScrollArea className={styles.collectionList}>
<Stack gap={4}>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />
))}
</Stack>
</ScrollArea>
</div>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,44 @@
.linkWrapper {
user-select: none;
cursor: pointer;
width: 100%;
background-color: light-dark(var(--mantine-color-gray-1), rgb(50, 58, 71));
padding: 0.25em 0.5em !important;
border-radius: var(--border-radius);
border: 1px solid transparent;
}
.linkWrapper:hover {
border: 1px solid var(--mantine-color-blue-4);
}
.linkName {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.linkDescription {
margin-top: 0.5em;
font-size: 0.8em;
word-wrap: break-word;
}
.linkUrl {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-size: 0.8em;
transition: opacity 0.3s;
}
.linkWrapper:hover .linkUrlPathname {
opacity: 1;
}
.linkUrlPathname {
opacity: 0;
transition: opacity 0.3s;
}

View File

@@ -0,0 +1,23 @@
import { Card, Group, Text } from '@mantine/core';
import { ExternalLinkStyled } from '~/components/common/external_link_styled';
import LinkControls from '~/components/dashboard/link/link_controls';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { LinkWithCollection } from '~/types/app';
import styles from './favorite_item.module.css';
export const FavoriteItem = ({ link }: { link: LinkWithCollection }) => (
<Card className={styles.linkWrapper} radius="sm" withBorder>
<Group justify="center" gap="xs">
<LinkFavicon size={32} url={link.url} />
<ExternalLinkStyled href={link.url} style={{ flex: 1 }}>
<div className={styles.linkName}>
<Text lineClamp={1}>{link.name} </Text>
</div>
<Text c="gray" size="xs" mb={4} lineClamp={1}>
{link.collection.name}
</Text>
</ExternalLinkStyled>
<LinkControls link={link} showGoToCollection />
</Group>
</Card>
);

View File

@@ -1,38 +0,0 @@
import styled from '@emotion/styled';
import LinkItem from '~/components/dashboard/link/link_item';
import { NoLink } from '~/components/dashboard/link/no_item';
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',
});
export default function LinkList({
links,
showControls = true,
}: {
links: Link[];
showControls?: boolean;
}) {
if (links.length === 0) {
return <NoLink />;
}
return (
<LinkListStyle>
{sortByCreationDate(links).map((link) => (
<LinkItem link={link} key={link.id} showUserControls={showControls} />
))}
</LinkListStyle>
);
}

View File

@@ -1,66 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
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={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>
);
}

View File

@@ -0,0 +1,27 @@
.noCollection {
min-width: 0;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
flex-direction: column;
animation: fadeIn 0.3s both;
}
.text {
min-width: 0;
width: 100%;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -0,0 +1,37 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Box, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import styles from './no_link.module.css';
export function NoLink() {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<Box className={styles.noCollection} p="xl">
<Text
className={styles.text}
dangerouslySetInnerHTML={{
__html: t(
'home:no-link',
{ name: activeCollection?.name ?? '' } as any,
{
interpolation: { escapeValue: false },
}
),
}}
/>
<Anchor
component={Link}
href={appendCollectionId(
route('link.create-form').path,
activeCollection?.id
)}
>
{t('link.create')}
</Anchor>
</Box>
);
}

View File

@@ -1,29 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import { FcGoogle } from 'react-icons/fc';
const NoSearchResultStyle = styled.i({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25em',
'& > 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>
);
}

View File

@@ -1,155 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IoIosSearch } from 'react-icons/io';
import Modal from '~/components/common/modal/modal';
import NoSearchResult from '~/components/dashboard/search/no_search_result';
import SearchResultList from '~/components/dashboard/search/search_result_list';
import { GOOGLE_SEARCH_URL } from '~/constants';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
import { makeRequest } from '~/lib/request';
import { useActiveCollection, useCollections } from '~/store/collection_store';
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',
}));
interface SearchModalProps {
openItem: any;
}
function SearchModal({ openItem: OpenItem }: SearchModalProps) {
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 searchModal = useToggle(!!searchTerm);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearchTerm('');
}, [searchModal]);
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 (!selectedItem) return;
if (selectedItem.type === 'collection') {
const collection = collections.find((c) => c.id === selectedItem.id);
if (collection) {
setActiveCollection(collection);
}
return;
}
window.open(selectedItem.url);
};
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
disableGlobalCheck: true,
});
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]);
});
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>
</>
);
}
export default SearchModal;

View File

@@ -1,123 +0,0 @@
import styled from '@emotion/styled';
import { RefObject, useEffect, useRef, useState } from 'react';
import { AiOutlineFolder } from 'react-icons/ai';
import Legend from '~/components/common/legend';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { useCollections } from '~/store/collection_store';
import {
SearchResult,
SearchResultCollection,
SearchResultLink,
} from '~/types/search';
const SearchItemStyle = styled('li', {
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',
}));
interface CommonResultProps {
innerRef: RefObject<HTMLLIElement>;
isActive: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export default function SearchResultItem({
result,
isActive,
onHover,
}: {
result: SearchResult;
isActive: boolean;
onHover: (result: SearchResult) => void;
}) {
const itemRef = useRef<HTMLLIElement>(null);
const [isHovering, setHovering] = useState<boolean>(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]);
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: 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);
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>
);
}
const ResultCollection = ({
result,
innerRef,
...props
}: {
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>
);

View File

@@ -1,52 +0,0 @@
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import SearchResultItem from '~/components/dashboard/search/search_result_item';
import useShortcut from '~/hooks/use_shortcut';
import { SearchResult } from '~/types/search';
export default function SearchResultList({
results,
selectedItem,
setSelectedItem,
}: {
results: SearchResult[];
selectedItem: SearchResult;
setSelectedItem: (result: SearchResult) => void;
}) {
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,
}
);
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 +0,0 @@
import styled from '@emotion/styled';
const FavoriteListContainer = styled.div({
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
});
export default FavoriteListContainer;

View File

@@ -1,33 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
import { DropdownItemButton } from '~/components/common/dropdown/dropdown_item';
import { onFavorite } from '~/lib/favorite';
import { useFavorites } from '~/store/collection_store';
import { Link } from '~/types/app';
const StarItem = styled(DropdownItemButton)(({ theme }) => ({
color: theme.colors.yellow,
}));
export default function FavoriteDropdownItem({ link }: { link: Link }) {
const { toggleFavorite } = useFavorites();
const { t } = useTranslation();
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

@@ -1,69 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { FaRegEye } from 'react-icons/fa';
import { GoPencil } from 'react-icons/go';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import { DropdownItemLink } from '~/components/common/dropdown/dropdown_item';
import Legend from '~/components/common/legend';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import FavoriteDropdownItem from '~/components/dashboard/side_nav/favorite/favorite_dropdown_item';
import { ItemExternalLink } from '~/components/dashboard/side_nav/nav_item';
import { appendCollectionId, appendLinkId } from '~/lib/navigation';
import { LinkWithCollection } from '~/types/app';
const FavoriteItemStyle = styled(ItemExternalLink)(({ theme }) => ({
height: 'auto',
backgroundColor: theme.colors.secondary,
}));
const FavoriteDropdown = styled(Dropdown)(({ theme }) => ({
backgroundColor: theme.colors.secondary,
}));
const FavoriteContainer = styled.div({
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>
);
}

View File

@@ -1,51 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import FavoriteListContainer from '~/components/dashboard/side_nav/favorite/favorite_container';
import FavoriteItem from '~/components/dashboard/side_nav/favorite/favorite_item';
import { useFavorites } from '~/store/collection_store';
const FavoriteLabel = styled.p(({ theme }) => ({
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 FavoriteListStyle = styled.div({
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" />;
}
return (
<FavoriteListContainer>
<FavoriteLabel>
{t('favorite')} {favorites.length}
</FavoriteLabel>
<FavoriteListStyle>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />
))}
</FavoriteListStyle>
</FavoriteListContainer>
);
}

View File

@@ -1,31 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
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',
'& > svg': {
height: '24px',
width: '24px',
},
// Disable hover effect for UserCard
'&:hover:not(.disable-hover)': {
cursor: 'pointer',
backgroundColor: rgba(theme.colors.font, 0.1),
},
}));
export const ItemLink = Item.withComponent(Link);
export const ItemExternalLink = Item.withComponent(ExternalLink);

View File

@@ -1,80 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import SearchModal from '~/components/dashboard/search/search_modal';
import FavoriteList from '~/components/dashboard/side_nav/favorite/favorite_list';
import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item';
import UserCard from '~/components/dashboard/side_nav/user_card';
import ModalSettings from '~/components/settings/settings_modal';
import useUser from '~/hooks/use_user';
import { rgba } from '~/lib/color';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
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',
}));
const AdminButton = styled(ItemLink)(({ theme }) => ({
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`,
},
}));
const AddButton = styled(ItemLink)(({ theme }) => ({
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>
);
}

View File

@@ -1,21 +0,0 @@
import RoundedImage from '~/components/common/rounded_image';
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>
)
);
}

View File

@@ -1,10 +0,0 @@
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;