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

@@ -7,14 +7,15 @@ WORKDIR /usr/src/app
# Install dependencies
COPY package*.json ./
RUN npm ci --omit="dev"
RUN npm ci --omit="dev" --ignore-scripts
# Copy app source code
COPY . .
# Build app
RUN npm install
RUN npm install --ignore-scripts
RUN npm run build --omit="dev"
RUN npx vite build
COPY ./.env ./build
@@ -30,4 +31,4 @@ ENV SESSION_DRIVER=cookie
EXPOSE $PORT
# Start app
CMD node build/bin/console.js migration:run --force && node build/bin/server.js
CMD node build/bin/console.js migration:run && node build/bin/server.js

19
app/constants/keys.ts Normal file
View File

@@ -0,0 +1,19 @@
const OPEN_SEARCH_KEY = 's';
const ESCAPE_KEY = 'escape';
const OPEN_CREATE_LINK_KEY = 'l';
const OPEN_CREATE_COLLECTION_KEY = 'c';
const ARROW_UP = 'ArrowUp';
const ARROW_DOWN = 'ArrowDown';
const KEYS = {
ARROW_DOWN,
ARROW_UP,
ESCAPE_KEY,
OPEN_CREATE_COLLECTION_KEY,
OPEN_CREATE_LINK_KEY,
OPEN_SEARCH_KEY,
};
export default KEYS;

View File

@@ -8,8 +8,8 @@ export default class CollectionsController {
const collections = await Collection.findManyBy('author_id', auth.user!.id);
const collectionsWithLinks = await Promise.all(
collections.map((collection) => {
collection.load('links');
collections.map(async (collection) => {
await collection.load('links');
return collection;
})
);
@@ -18,7 +18,7 @@ export default class CollectionsController {
}
async showCreatePage({ inertia }: HttpContext) {
return inertia.render('collection/create');
return inertia.render('collections/create');
}
async store({ request, response, auth }: HttpContext) {
@@ -34,6 +34,6 @@ export default class CollectionsController {
response: HttpContext['response'],
collectionId: Collection['id']
) {
return response.redirect(`${PATHS.DASHBOARD}?categoryId=${collectionId}`);
return response.redirect(`${PATHS.DASHBOARD}?collectionId=${collectionId}`);
}
}

View File

@@ -3,7 +3,15 @@ import logger from '@adonisjs/core/services/logger';
export default class LogRequest {
async handle({ request }: HttpContext, next: () => Promise<void>) {
if (
!request.url().startsWith('/node_modules') &&
!request.url().startsWith('/inertia') &&
!request.url().startsWith('/@vite') &&
!request.url().startsWith('/@react-refresh') &&
!request.url().includes('.ts')
) {
logger.info(`-> ${request.method()}: ${request.url()}`);
}
await next();
}
}

View File

@@ -21,6 +21,7 @@ services:
environment:
- DB_HOST=postgres
- HOST=0.0.0.0
- NODE_ENV=production
env_file:
- .env
depends_on:

View File

@@ -0,0 +1,18 @@
import KEYS from '#constants/keys';
import PATHS from '#constants/paths';
import { router } from '@inertiajs/react';
import { ReactNode } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
export default function BackToDashboard({ children }: { children: ReactNode }) {
const { globalHotkeysEnabled } = useGlobalHotkeys();
useHotkeys(
KEYS.ESCAPE_KEY,
() => {
router.visit(PATHS.DASHBOARD);
},
{ enabled: globalHotkeysEnabled, enableOnFormTags: ['INPUT'] }
);
return <>{children}</>;
}

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;

View File

@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
import BaseLayout from './_base_layout';
const DashboardLayoutStyle = styled.div(({ theme }) => ({
height: 'auto',
height: '100%',
width: theme.media.small_desktop,
maxWidth: '100%',
padding: '0.75em 1em',

View File

@@ -46,7 +46,7 @@ const FormLayout = ({
</Button>
</Form>
{!disableHomeLink && (
// <Link href={categoryId ? `/?categoryId=${categoryId}` : '/'}>{t('common:back-home')}</Link>
// <Link href={collectionId ? `/?collectionId=${collectionId}` : '/'}>{t('common:back-home')}</Link>
<Link href={PATHS.DASHBOARD}> Revenir à l'accueil</Link>
)}
</FormLayoutStyle>

View File

@@ -0,0 +1,15 @@
import type Collection from '#models/collection';
import { createContext } from 'react';
type ActiveCollectionContextType = {
activeCollection: Collection | null;
setActiveCollection: (collection: Collection) => void;
};
const iActiveCollectionContextState: ActiveCollectionContextType = {
activeCollection: null,
setActiveCollection: (_: Collection) => {},
};
export const ActiveCollectionContext =
createContext<ActiveCollectionContextType>(iActiveCollectionContextState);

View File

@@ -0,0 +1,18 @@
import Collection from '#models/collection';
import { createContext } from 'react';
type CollectionsContextType = {
collections: Collection[];
setCollections: (collections: Collection[]) => void | Collection[];
};
const iCollectionsContextState: CollectionsContextType = {
collections: [] as Collection[],
setCollections: (_: Collection[]) => {},
};
const CollectionsContext = createContext<CollectionsContextType>(
iCollectionsContextState
);
export default CollectionsContext;

View File

@@ -0,0 +1,16 @@
import type Link from '#models/link';
import { createContext } from 'react';
type FavoritesContextType = {
favorites: Link[];
};
const iFavoritesContextState = {
favorites: [] as Link[],
};
const FavoritesContext = createContext<FavoritesContextType>(
iFavoritesContextState
);
export default FavoritesContext;

View File

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

View File

@@ -0,0 +1,5 @@
import { useContext } from 'react';
import { ActiveCollectionContext } from '~/contexts/active_collection_context';
const useActiveCollection = () => useContext(ActiveCollectionContext);
export default useActiveCollection;

View File

@@ -0,0 +1,9 @@
import { useCallback } from 'react';
export default function useAutoFocus() {
return useCallback((inputElement: any) => {
if (inputElement) {
inputElement.focus();
}
}, []);
}

View File

@@ -0,0 +1,5 @@
import { useContext } from 'react';
import CollectionsContext from '~/contexts/collections_context';
const useCollections = () => useContext(CollectionsContext);
export default useCollections;

View File

@@ -0,0 +1,5 @@
import { useContext } from 'react';
import FavoritesContext from '~/contexts/favorites_context';
const useFavorites = () => useContext(FavoritesContext);
export default useFavorites;

View File

@@ -0,0 +1,5 @@
import { useContext } from 'react';
import GlobalHotkeysContext from '~/contexts/global_hotkeys_context';
const useGlobalHotkeys = () => useContext(GlobalHotkeysContext);
export default useGlobalHotkeys;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { buildSearchItem, formatSearchItem } from 'lib/search';
import { useMemo } from 'react';
import useCollections from '~/hooks/use_collections';
import { SearchItem, SearchResult } from '~/types/search';
export default function useSearchItem({
searchTerm = '',
disableLinks = false,
disableCollections = false,
}: {
searchTerm: string;
disableLinks?: boolean;
disableCollections?: boolean;
}) {
const { collections } = useCollections();
const itemsSearch: SearchItem[] = useMemo<SearchItem[]>(() => {
return collections.reduce((acc, collection) => {
const collectionItem = buildSearchItem(collection, 'collection');
const items: SearchItem[] = collection.links.map((link) =>
buildSearchItem(link, 'link')
);
return [...acc, ...items, collectionItem];
}, [] as SearchItem[]);
}, [collections]);
const itemsCompletion: SearchResult[] = useMemo(() => {
return itemsSearch.reduce((acc, item) => {
const formattedItem = formatSearchItem(item, searchTerm);
if (
(!disableLinks && item.type === 'link') ||
(!disableCollections && item.type === 'collection')
) {
return formattedItem ? [...acc, formattedItem] : acc;
}
return acc;
}, [] as SearchResult[]);
}, [itemsSearch, searchTerm, disableLinks, disableCollections]);
return itemsCompletion;
}

View File

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

View File

@@ -3,9 +3,9 @@
"title": "Welcome to MyLinks",
"cta": "Get started"
},
"category": {
"title": "Create categories",
"text": "Organize your bookmarks by categories to keep your links tidy and find them easily."
"collection": {
"title": "Create collections",
"text": "Organize your bookmarks by collections to keep your links tidy and find them easily."
},
"link": {
"title": "Manage Links",

View File

@@ -15,18 +15,18 @@
"remove": "Delete a link",
"remove-confirm": "Confirm deletion?"
},
"category": {
"categories": "Categories",
"category": "Category",
"name": "Category name",
"description": "Category description",
"collection": {
"collections": "Collections",
"collection": "Collection",
"name": "Collection name",
"description": "Collection description",
"no-description": "No description",
"visibility": "Public",
"create": "Create a category",
"edit": "Edit a category",
"remove": "Delete a category",
"create": "Create a collection",
"edit": "Edit a collection",
"remove": "Delete a collection",
"remove-confirm": "Confirm deletion?",
"remove-description": "You must delete all links in this category before you can delete this category."
"remove-description": "You must delete all links in this collection before you can delete this collection."
},
"favorite": "Favorite",
"no-item-found": "No item found",

View File

@@ -1,5 +1,5 @@
{
"select-category": "Please select a category",
"select-collection": "Please select a collection",
"or-create-one": "or create one",
"no-link": "No link for <b>{{name}}</b>"
}

View File

@@ -10,13 +10,19 @@
},
"user": {
"title": "1.2 User Data",
"description": "To create personalized categories and links and associate them with their author, we collect the following information:",
"fields": ["Google ID", "Lastname", "Firstname", "Email", "Avatar"]
"description": "To create personalized collections and links and associate them with their author, we collect the following information:",
"fields": [
"Google ID",
"Lastname",
"Firstname",
"Email",
"Avatar"
]
}
},
"data_use": {
"title": "2. Data Use",
"description": "The collected data is neither resold nor used for purposes other than initially intended, namely the management of categories and links created by the user."
"description": "The collected data is neither resold nor used for purposes other than initially intended, namely the management of collections and links created by the user."
},
"data_storage": {
"title": "3. Data Storage",

View File

@@ -3,9 +3,9 @@
"title": "Bienvenue sur MyLinks",
"cta": "Lancez-vous !"
},
"category": {
"title": "Créer des catégories",
"text": "Organisez vos favoris dans des catégories pour garder vos liens en ordre et les retrouver facilement."
"collection": {
"title": "Créer des collections",
"text": "Organisez vos favoris dans des collections pour garder vos liens en ordre et les retrouver facilement."
},
"link": {
"title": "Gérer les liens",

View File

@@ -15,18 +15,19 @@
"remove": "Supprimer un lien",
"remove-confirm": "Confirmer la suppression ?"
},
"category": {
"categories": "Catégories",
"category": "Catégorie",
"name": "Nom de la catégorie",
"description": "Description de la catégorie",
"collection": {
"collections": "Collection",
"collections_other": "Collections",
"collection": "Collection",
"name": "Nom de la collection",
"description": "Description de la collection",
"visibility": "Public",
"no-description": "Aucune description",
"create": "Créer une catégorie",
"edit": "Modifier une catégorie",
"remove": "Supprimer une catégorie",
"create": "Créer une collection",
"edit": "Modifier une collection",
"remove": "Supprimer une collection",
"remove-confirm": "Confirmer la suppression ?",
"remove-description": "Vous devez supprimer tous les liens de cette catégorie avant de pouvoir supprimer cette catégorie"
"remove-description": "Vous devez supprimer tous les liens de cette collection avant de pouvoir supprimer cette collection"
},
"favorite": "Favoris",
"no-item-found": "Aucun élément trouvé",

View File

@@ -1,5 +1,5 @@
{
"select-category": "Veuillez sélectionner une categories",
"select-collection": "Veuillez sélectionner une collection",
"or-create-one": "ou en créer une",
"no-link": "Aucun lien pour <b>{{name}}</b>"
}

View File

@@ -10,7 +10,7 @@
},
"user": {
"title": "1.2 Données utilisateur",
"description": "Pour créer des catégories et liens personnalisés et les associer à leur auteur, nous collectons les informations suivantes :",
"description": "Pour créer des collections et liens personnalisés et les associer à leur auteur, nous collectons les informations suivantes :",
"fields": [
"Identifiant Google",
"Nom",
@@ -22,7 +22,7 @@
},
"data_use": {
"title": "2. Utilisation des données",
"description": "Les données collectées ne sont ni revendues ni utilisées à d'autres fins que celles prévues initialement, à savoir la gestion des catégories et des liens créés par l'utilisateur."
"description": "Les données collectées ne sont ni revendues ni utilisées à d'autres fins que celles prévues initialement, à savoir la gestion des collections et des liens créés par l'utilisateur."
},
"data_storage": {
"title": "3. Stockage des données",

39
inertia/lib/array.ts Normal file
View File

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

28
inertia/lib/collection.ts Normal file
View File

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

6
inertia/lib/image.ts Normal file
View File

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

View File

@@ -0,0 +1,9 @@
import Collection from '#models/collection';
export const appendCollectionId = (
url: string,
collectionId?: Collection['id']
) => `${url}${collectionId && `?collectionId=${collectionId}`}}`;
export const appendResourceId = (url: string, resourceId?: string) =>
`${url}${resourceId && `/${resourceId}`}}`;

24
inertia/lib/request.ts Normal file
View File

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

53
inertia/lib/search.tsx Normal file
View File

@@ -0,0 +1,53 @@
import PATHS from '#constants/paths';
import Collection from '#models/collection';
import Link from '#models/link';
import { SearchItem, SearchResult } from '~/types/search';
export function buildSearchItem(
item: Collection | Link,
type: SearchItem['type']
): SearchItem {
return {
id: item.id,
name: item.name,
url:
type === 'link'
? (item as Link).url
: `${PATHS.DASHBOARD}?collectionId=${item.id}`,
type,
collection: type === 'link' ? (item as Link).collection : undefined,
};
}
export function formatSearchItem(
item: SearchItem,
searchTerm: string
): SearchResult | null {
const lowerCaseSearchTerm = searchTerm.toLowerCase().trim();
const lowerCaseName = item.name.toLowerCase().trim();
let currentIndex = 0;
let formattedName = '';
for (const [i, element] of Object(lowerCaseName).entries()) {
if (element === lowerCaseSearchTerm[currentIndex]) {
formattedName += `<b>${item.name[i]}</b>`;
currentIndex++;
} else {
formattedName += item.name[i];
}
}
if (currentIndex !== lowerCaseSearchTerm.length) {
// Search term not fully matched
return null;
}
return {
id: item.id,
name: <div dangerouslySetInnerHTML={{ __html: formattedName }} />,
url: item.url,
type: item.type,
collection: item.collection,
};
}

View File

@@ -2,6 +2,7 @@ import { useForm } from '@inertiajs/react';
import { ChangeEvent, FormEvent, useMemo } from 'react';
import FormField from '~/components/common/form/_form_field';
import TextBox from '~/components/common/form/textbox';
import BackToDashboard from '~/components/common/navigation/bask_to_dashboard';
import FormLayout from '~/components/layouts/form_layout';
import { Visibility } from '../../../app/enums/visibility';
@@ -34,6 +35,7 @@ export default function CreateCollectionPage() {
handleSubmit={handleSubmit}
canSubmit={!isFormDisabled}
>
<BackToDashboard>
<TextBox
label="Collection name"
placeholder="Collection name"
@@ -56,6 +58,7 @@ export default function CreateCollectionPage() {
<label htmlFor="visibility">Public</label>
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
</FormField>
</BackToDashboard>
</FormLayout>
);
}

View File

@@ -1,22 +1,144 @@
import KEYS from '#constants/keys';
import PATHS from '#constants/paths';
import type Collection from '#models/collection';
import { Link } from '@inertiajs/react';
import Footer from '~/components/footer/footer';
import Link from '#models/link';
import styled from '@emotion/styled';
import { router } from '@inertiajs/react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSwipeable } from 'react-swipeable';
import Links from '~/components/dashboard/link_list/link_list';
import SideNavigation from '~/components/dashboard/side_nav/side_navigation';
import SwiperHandler from '~/components/dashboard/swiper_handler';
import DashboardLayout from '~/components/layouts/dashboard_layout';
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 { useMediaQuery } from '~/hooks/use_media_query';
import useModal from '~/hooks/use_modal';
// type DashboardPageProps = InferPageProps<CollectionsController, 'index'>
type DashboardPageProps = {
interface HomePageProps {
collections: Collection[];
};
activeCollection: Collection;
}
const SideBar = styled.div(({ theme }) => ({
paddingRight: '0.75em',
borderRight: `1px solid ${theme.colors.lightestGrey}`,
marginRight: '5px',
}));
export default function HomePage(props: Readonly<HomePageProps>) {
const isMobile = useMediaQuery('(max-width: 768px)');
const { isShowing, open, close } = useModal();
const handlers = useSwipeable({
trackMouse: true,
onSwipedRight: open,
});
console.log(props.collections);
useEffect(() => {
if (!isMobile && isShowing) {
close();
}
}, [close, isMobile, isShowing]);
export default function DashboardPage({ collections }: DashboardPageProps) {
console.log(collections);
return (
<DashboardLayout>
<Link href="/collections/create">Add collection</Link>
{collections.map((collection) => (
<li key={collection.id}>{collection.name}</li>
))}
<Footer />
<HomeProviders
collections={props.collections}
activeCollection={props.activeCollection}
>
<SwiperHandler {...handlers}>
{!isMobile && (
<SideBar>
<SideNavigation />
</SideBar>
)}
{/* <AnimatePresence>
{isShowing && (
<SideMenu close={close}>
<SideNavigation />
</SideMenu>
)}
</AnimatePresence> */}
<Links isMobile={isMobile} openSideMenu={open} />
</SwiperHandler>
</HomeProviders>
</DashboardLayout>
);
}
function HomeProviders(
props: Readonly<{
children: ReactNode;
collections: Collection[];
activeCollection: Collection;
}>
) {
const [globalHotkeysEnabled, setGlobalHotkeysEnabled] =
useState<boolean>(true);
const [collections, setCollections] = useState<Collection[]>(
props.collections
);
const [activeCollection, setActiveCollection] = useState<Collection | null>(
props.activeCollection || collections?.[0]
);
const handleChangeCollection = (collection: Collection) =>
setActiveCollection(collection);
const favorites = useMemo<Link[]>(
() =>
collections.reduce((acc, collection) => {
collection.links.forEach((link) =>
link.favorite ? acc.push(link) : null
);
return acc;
}, [] as Link[]),
[collections]
);
const collectionsContextValue = useMemo(
() => ({ collections, setCollections }),
[collections]
);
const activeCollectionContextValue = useMemo(
() => ({ activeCollection, setActiveCollection: handleChangeCollection }),
[activeCollection, handleChangeCollection]
);
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled: globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled]
);
useHotkeys(
KEYS.OPEN_CREATE_LINK_KEY,
() => {
router.visit(`${PATHS.LINK.CREATE}?collectionId=${activeCollection?.id}`);
},
{ enabled: globalHotkeysEnabled }
);
useHotkeys(
KEYS.OPEN_CREATE_COLLECTION_KEY,
() => {
router.visit(PATHS.COLLECTION.CREATE);
},
{ 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

@@ -34,4 +34,16 @@ export const cssReset = css({
fontWeight: '500',
color: theme.colors.primary,
},
kbd: {
textShadow: '0 1px 0 #fff',
fontSize: '12px',
color: 'rgb(51, 51, 51)',
backgroundColor: 'rgb(247, 247, 247)',
padding: '0.25em 0.5em',
borderRadius: '3px',
border: '1px solid rgb(204, 204, 204)',
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
display: 'inline-block',
},
});

View File

@@ -23,6 +23,8 @@ const red = '#d8000c';
const lightGreen = '#c1ffbab9';
const green = 'green';
const yellow = '#ffc107';
export const theme: Theme = {
colors: {
font: black,
@@ -51,6 +53,8 @@ export const theme: Theme = {
lightGreen,
green,
yellow,
},
border: {

View File

@@ -29,6 +29,8 @@ declare module '@emotion/react' {
lightGreen: string;
green: string;
yellow: string;
};
border: {

6
inertia/types/favicon.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
interface Favicon {
buffer: Buffer;
url: string;
type: string;
size: number;
}

17
inertia/types/search.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import type Link from '#models/link';
export interface SearchItem {
id: string;
name: string;
url: string;
type: 'collection' | 'link';
collection?: undefined | Link['collection'];
}
export interface SearchResult {
id: string;
name: React.ReactNode; // React node to support bold text
url: string;
type: 'collection' | 'link';
collection?: undefined | Link['collection'];
}

104
package-lock.json generated
View File

@@ -28,8 +28,13 @@
"luxon": "^3.4.4",
"pg": "^8.11.5",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"react-icons": "^5.1.0",
"react-swipeable": "^7.0.1",
"reflect-metadata": "^0.2.2",
"uuid": "^9.0.1"
},
@@ -2342,6 +2347,21 @@
"validator": "^13.9.0"
}
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.0.tgz",
@@ -2932,7 +2952,7 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"dev": true
"devOptional": true
},
"node_modules/@types/qs": {
"version": "6.9.15",
@@ -2943,7 +2963,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz",
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4746,6 +4766,16 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -8762,6 +8792,43 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -8774,6 +8841,15 @@
"react": "^18.3.1"
}
},
"node_modules/react-hotkeys-hook": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.5.0.tgz",
"integrity": "sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==",
"peerDependencies": {
"react": ">=16.8.1",
"react-dom": ">=16.8.1"
}
},
"node_modules/react-i18next": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz",
@@ -8795,6 +8871,14 @@
}
}
},
"node_modules/react-icons": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz",
"integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -8810,6 +8894,14 @@
"node": ">=0.10.0"
}
},
"node_modules/react-swipeable": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz",
"integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==",
"peerDependencies": {
"react": "^16.8.3 || ^17 || ^18"
}
},
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@@ -9103,6 +9195,14 @@
"node": ">= 10.13.0"
}
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",

View File

@@ -79,8 +79,13 @@
"luxon": "^3.4.4",
"pg": "^8.11.5",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"react-icons": "^5.1.0",
"react-swipeable": "^7.0.1",
"reflect-metadata": "^0.2.2",
"uuid": "^9.0.1"
},

View File

@@ -8,7 +8,10 @@ export default defineConfig({
plugins: [
inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.tsx' } }),
react(),
adonisjs({ entrypoints: ['inertia/app/app.tsx'], reload: ['resources/views/**/*.edge'] }),
adonisjs({
entrypoints: ['inertia/app/app.tsx'],
reload: ['resources/views/**/*.edge'],
}),
],
/**