mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 22:53:25 +00:00
feat: recreate dashboard page from previous version
This commit is contained in:
@@ -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
19
app/constants/keys.ts
Normal 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;
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,15 @@ import logger from '@adonisjs/core/services/logger';
|
||||
|
||||
export default class LogRequest {
|
||||
async handle({ request }: HttpContext, next: () => Promise<void>) {
|
||||
logger.info(`-> ${request.method()}: ${request.url()}`);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- HOST=0.0.0.0
|
||||
- NODE_ENV=production
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
18
inertia/components/common/navigation/bask_to_dashboard.tsx
Normal file
18
inertia/components/common/navigation/bask_to_dashboard.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import styled from '@emotion/styled';
|
||||
import QuickResourceAction from '~/components/dashboard/quick_action/quick_action';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionControlsStyle = styled.span({
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export default function CollectionControls() {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
return (
|
||||
activeCollection && (
|
||||
<CollectionControlsStyle>
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="create"
|
||||
collectionId={activeCollection.id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="collection"
|
||||
action="edit"
|
||||
resourceId={activeCollection.id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="collection"
|
||||
action="remove"
|
||||
resourceId={activeCollection.id}
|
||||
/>
|
||||
</CollectionControlsStyle>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import styled from '@emotion/styled';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionDescriptionStyle = styled.p({
|
||||
fontSize: '0.85em',
|
||||
marginBottom: '0.5em',
|
||||
});
|
||||
|
||||
export default function CollectionDescription() {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
return (
|
||||
activeCollection && (
|
||||
<CollectionDescriptionStyle>
|
||||
{activeCollection?.description}
|
||||
</CollectionDescriptionStyle>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisibilityBadge from '~/components/dashboard/side_nav/visibilty/visibilty';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const CollectionNameWrapper = styled.div({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionName = styled.div({
|
||||
minWidth: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const LinksCount = styled.div(({ theme }) => ({
|
||||
minWidth: 'fit-content',
|
||||
fontWeight: 300,
|
||||
fontSize: '0.8em',
|
||||
color: theme.colors.grey,
|
||||
}));
|
||||
|
||||
export default function CollectionHeader() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
if (!activeCollection) return <Fragment />;
|
||||
|
||||
const { name, links, visibility } = activeCollection;
|
||||
return (
|
||||
<CollectionNameWrapper>
|
||||
<CollectionName>{name}</CollectionName>
|
||||
{links.length > 0 && <LinksCount> — {links.length}</LinksCount>}
|
||||
<VisibilityBadge
|
||||
label={t('collection.visibility')}
|
||||
visibility={visibility}
|
||||
/>
|
||||
</CollectionNameWrapper>
|
||||
);
|
||||
}
|
||||
72
inertia/components/dashboard/link/link_favicon.tsx
Normal file
72
inertia/components/dashboard/link/link_favicon.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { TbLoader3 } from 'react-icons/tb';
|
||||
import { TfiWorld } from 'react-icons/tfi';
|
||||
|
||||
interface LinkFaviconProps {
|
||||
url: string;
|
||||
size?: number;
|
||||
noMargin?: boolean;
|
||||
}
|
||||
|
||||
const Favicon = styled.div({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
const FaviconLoader = styled.div(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.colors.white,
|
||||
|
||||
'& > *': {
|
||||
animation: 'rotate 1s both reverse infinite linear',
|
||||
},
|
||||
}));
|
||||
|
||||
// The Favicon API should always return an image, so it's not really useful to keep the loader nor placeholder icon,
|
||||
// but for slow connections and other random stuff, I'll keep this
|
||||
export default function LinkFavicon({
|
||||
url,
|
||||
size = 32,
|
||||
noMargin = false,
|
||||
}: LinkFaviconProps) {
|
||||
const [isFailed, setFailed] = useState<boolean>(false);
|
||||
const [isLoading, setLoading] = useState<boolean>(true);
|
||||
const baseUrlApi =
|
||||
(process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
(typeof window !== 'undefined' && window?.location?.origin)) + '/api';
|
||||
if (!baseUrlApi) {
|
||||
console.warn('Missing API URL');
|
||||
}
|
||||
|
||||
const setFallbackFavicon = () => setFailed(true);
|
||||
const handleStopLoading = () => setLoading(false);
|
||||
|
||||
return (
|
||||
<Favicon style={{ marginRight: !noMargin ? '1em' : '0' }}>
|
||||
{!isFailed && baseUrlApi ? (
|
||||
<img
|
||||
src={`${baseUrlApi}/favicon?urlParam=${url}`}
|
||||
onError={() => {
|
||||
setFallbackFavicon();
|
||||
handleStopLoading();
|
||||
}}
|
||||
onLoad={handleStopLoading}
|
||||
height={size}
|
||||
width={size}
|
||||
alt="icon"
|
||||
/>
|
||||
) : (
|
||||
<TfiWorld size={size} />
|
||||
)}
|
||||
{isLoading && (
|
||||
<FaviconLoader style={{ height: `${size}px`, width: `${size}px` }}>
|
||||
<TbLoader3 size={size} />
|
||||
</FaviconLoader>
|
||||
)}
|
||||
</Favicon>
|
||||
);
|
||||
}
|
||||
197
inertia/components/dashboard/link_list/link_item.tsx
Normal file
197
inertia/components/dashboard/link_list/link_item.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import type Link from '#models/link';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback } from 'react';
|
||||
import { AiFillStar } from 'react-icons/ai';
|
||||
import ExternalLink from '~/components/common/external_link';
|
||||
import LinkFavicon from '~/components/dashboard/link/link_favicon';
|
||||
import QuickResourceAction from '~/components/dashboard/quick_action/quick_action';
|
||||
import QuickLinkFavorite from '~/components/dashboard/quick_action/quick_favorite_link';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import { makeRequest } from '~/lib/request';
|
||||
import { theme as globalTheme } from '~/styles/theme';
|
||||
|
||||
const LinkWrapper = styled.li(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
color: theme.colors.primary,
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: '0.75em 1em',
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
outline: '3px solid transparent',
|
||||
}));
|
||||
|
||||
const LinkHeader = styled.div(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
'& > a': {
|
||||
height: '100%',
|
||||
maxWidth: 'calc(100% - 125px)', // TODO: fix this, it is ugly af :(
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&, &:hover': {
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const LinkName = styled.div({
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const LinkControls = styled.div({
|
||||
display: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
|
||||
'& svg': {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
},
|
||||
|
||||
'&:hover *': {
|
||||
transform: 'scale(1.3)',
|
||||
},
|
||||
});
|
||||
|
||||
const LinkDescription = styled.div(({ theme }) => ({
|
||||
marginTop: '0.5em',
|
||||
color: theme.colors.font,
|
||||
fontSize: '0.8em',
|
||||
wordWrap: 'break-word',
|
||||
}));
|
||||
|
||||
const LinkUrl = styled.span(({ theme }) => ({
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
color: theme.colors.grey,
|
||||
fontSize: '0.8em',
|
||||
}));
|
||||
|
||||
const LinkUrlPathname = styled.span({
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export default function LinkItem({
|
||||
link,
|
||||
showUserControls = false,
|
||||
}: {
|
||||
link: Link;
|
||||
showUserControls: boolean;
|
||||
}) {
|
||||
const { id, name, url, description, favorite } = link;
|
||||
const { collections, setCollections } = useCollections();
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
(linkId: Link['id']) => {
|
||||
let linkIndex = 0;
|
||||
const collectionIndex = collections.findIndex(({ links }) => {
|
||||
const lIndex = links.findIndex((l) => l.id === linkId);
|
||||
if (lIndex !== -1) {
|
||||
linkIndex = lIndex;
|
||||
}
|
||||
return lIndex !== -1;
|
||||
});
|
||||
|
||||
const collectionLink = collections[collectionIndex].links[linkIndex];
|
||||
const collectionsCopy = [...collections];
|
||||
collectionsCopy[collectionIndex].links[linkIndex] = {
|
||||
...collectionLink,
|
||||
favorite: !collectionLink.favorite,
|
||||
};
|
||||
|
||||
setCollections(collectionsCopy);
|
||||
},
|
||||
[collections, setCollections]
|
||||
);
|
||||
|
||||
const onFavorite = () => {
|
||||
makeRequest({
|
||||
url: `${PATHS.API.LINK}/${link.id}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name,
|
||||
url,
|
||||
favorite: !favorite,
|
||||
collectionId: link.collectionId,
|
||||
},
|
||||
})
|
||||
.then(() => toggleFavorite(link.id))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<LinkWrapper key={id}>
|
||||
<LinkHeader>
|
||||
<LinkFavicon url={url} />
|
||||
<ExternalLink href={url} className="reset">
|
||||
<LinkName>
|
||||
{name}{' '}
|
||||
{showUserControls && favorite && (
|
||||
<AiFillStar color={globalTheme.colors.yellow} />
|
||||
)}
|
||||
</LinkName>
|
||||
<LinkItemURL url={url} />
|
||||
</ExternalLink>
|
||||
{showUserControls && (
|
||||
<LinkControls>
|
||||
<QuickLinkFavorite onClick={onFavorite} isFavorite={favorite} />
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="edit"
|
||||
resourceId={id}
|
||||
/>
|
||||
<QuickResourceAction
|
||||
resource="link"
|
||||
action="remove"
|
||||
resourceId={id}
|
||||
/>
|
||||
</LinkControls>
|
||||
)}
|
||||
</LinkHeader>
|
||||
{description && <LinkDescription>{description}</LinkDescription>}
|
||||
</LinkWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkItemURL({ url }: { url: Link['url'] }) {
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(url);
|
||||
let text = '';
|
||||
|
||||
if (pathname !== '/') {
|
||||
text += pathname;
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
if (text === '') {
|
||||
text += '/';
|
||||
}
|
||||
text += search;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkUrl>
|
||||
{origin}
|
||||
<LinkUrlPathname>{text}</LinkUrlPathname>
|
||||
</LinkUrl>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
return <LinkUrl>{url}</LinkUrl>;
|
||||
}
|
||||
}
|
||||
93
inertia/components/dashboard/link_list/link_list.tsx
Normal file
93
inertia/components/dashboard/link_list/link_list.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { RxHamburgerMenu } from 'react-icons/rx';
|
||||
import CollectionControls from '~/components/dashboard/collection/collection_controls';
|
||||
import CollectionDescription from '~/components/dashboard/collection/collection_description';
|
||||
import CollectionHeader from '~/components/dashboard/collection/collection_header';
|
||||
import LinkItem from '~/components/dashboard/link_list/link_item';
|
||||
import { NoCollection, NoLink } from '~/components/dashboard/link_list/no_item';
|
||||
import Footer from '~/components/footer/footer';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const LinksWrapper = styled.div({
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
padding: '0.5em 0.5em 0',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
const CollectionHeaderWrapper = styled.h2(({ theme }) => ({
|
||||
color: theme.colors.blue,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
gap: '0.4em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
'& > svg': {
|
||||
display: 'flex',
|
||||
},
|
||||
}));
|
||||
|
||||
interface LinksProps {
|
||||
isMobile: boolean;
|
||||
openSideMenu: () => void;
|
||||
}
|
||||
|
||||
export default function Links({
|
||||
isMobile,
|
||||
openSideMenu,
|
||||
}: Readonly<LinksProps>) {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
if (activeCollection === null) {
|
||||
return <NoCollection />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinksWrapper>
|
||||
<CollectionHeaderWrapper>
|
||||
{isMobile && (
|
||||
<Link
|
||||
href="/#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
openSideMenu();
|
||||
}}
|
||||
title="Open side nav bar"
|
||||
>
|
||||
<RxHamburgerMenu size={'1.5em'} />
|
||||
</Link>
|
||||
)}
|
||||
<CollectionHeader />
|
||||
<CollectionControls />
|
||||
</CollectionHeaderWrapper>
|
||||
<CollectionDescription />
|
||||
{activeCollection?.links.length !== 0 ? (
|
||||
<LinkListStyle>
|
||||
{activeCollection?.links.map((link) => (
|
||||
<LinkItem link={link} key={link.id} showUserControls />
|
||||
))}
|
||||
</LinkListStyle>
|
||||
) : (
|
||||
<NoLink />
|
||||
)}
|
||||
<Footer />
|
||||
</LinksWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const LinkListStyle = styled.ul({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
gap: '0.5em',
|
||||
padding: '3px',
|
||||
flexDirection: 'column',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'scroll',
|
||||
});
|
||||
59
inertia/components/dashboard/link_list/no_item.tsx
Normal file
59
inertia/components/dashboard/link_list/no_item.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
import { appendCollectionId } from '~/lib/navigation';
|
||||
import { fadeIn } from '~/styles/keyframes';
|
||||
|
||||
const NoCollectionStyle = styled.div({
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
animation: `${fadeIn} 0.3s both`,
|
||||
});
|
||||
|
||||
const Text = styled.p({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export function NoCollection() {
|
||||
const { t } = useTranslation('home');
|
||||
return (
|
||||
<NoCollectionStyle>
|
||||
<Text>{t('select-collection')}</Text>
|
||||
<Link href={PATHS.COLLECTION.CREATE}>{t('or-create-one')}</Link>
|
||||
</NoCollectionStyle>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoLink() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
return (
|
||||
<NoCollectionStyle>
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t(
|
||||
'home:no-link',
|
||||
{ name: activeCollection?.name ?? '' } as any,
|
||||
{
|
||||
interpolation: { escapeValue: false },
|
||||
}
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Link href={appendCollectionId(PATHS.LINK.CREATE, activeCollection?.id)}>
|
||||
{t('link.create')}
|
||||
</Link>
|
||||
</NoCollectionStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const ActionStyle = styled.div(({ theme }) => ({
|
||||
border: '0 !important',
|
||||
margin: '0 !important',
|
||||
padding: '0 !important',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
'& svg': {
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
transition: theme.transition.delay,
|
||||
},
|
||||
|
||||
'&:hover svg': {
|
||||
transform: 'scale(1.25)',
|
||||
},
|
||||
}));
|
||||
|
||||
export default ActionStyle;
|
||||
55
inertia/components/dashboard/quick_action/quick_action.tsx
Normal file
55
inertia/components/dashboard/quick_action/quick_action.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type Collection from '#models/collection';
|
||||
import type Link from '#models/link';
|
||||
import { Link as LinkTag } from '@inertiajs/react';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AiOutlineEdit } from 'react-icons/ai';
|
||||
import { CgTrashEmpty } from 'react-icons/cg';
|
||||
import { IoAddOutline } from 'react-icons/io5';
|
||||
import ActionStyle from '~/components/dashboard/quick_action/_quick_action_style';
|
||||
import { appendCollectionId, appendResourceId } from '~/lib/navigation';
|
||||
|
||||
type Resource = 'collection' | 'link';
|
||||
type Action = 'create' | 'edit' | 'remove';
|
||||
|
||||
const getIconFromAction = (action: Action) => {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return IoAddOutline;
|
||||
case 'edit':
|
||||
return AiOutlineEdit;
|
||||
case 'remove':
|
||||
return CgTrashEmpty;
|
||||
}
|
||||
};
|
||||
|
||||
const QuickResourceActionStyle = ActionStyle.withComponent(LinkTag);
|
||||
export default function QuickResourceAction({
|
||||
resource,
|
||||
action,
|
||||
collectionId,
|
||||
resourceId,
|
||||
onClick,
|
||||
}: {
|
||||
resource: Resource;
|
||||
action: Action;
|
||||
collectionId?: Collection['id'];
|
||||
resourceId?: Collection['id'] | Link['id'];
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const ActionIcon = getIconFromAction(action);
|
||||
|
||||
return (
|
||||
<QuickResourceActionStyle
|
||||
href={appendCollectionId(
|
||||
appendResourceId(`/${resource}/${action}`, resourceId),
|
||||
collectionId
|
||||
)}
|
||||
title={t(`${resource}.${action}`)}
|
||||
onClick={onClick ? (event) => onClick(event) : undefined}
|
||||
>
|
||||
<ActionIcon />
|
||||
</QuickResourceActionStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
|
||||
import ActionStyle from '~/components/dashboard/quick_action/_quick_action_style';
|
||||
import { theme } from '~/styles/theme';
|
||||
|
||||
const QuickLinkFavoriteStyle = ActionStyle.withComponent('div');
|
||||
const QuickLinkFavorite = ({
|
||||
isFavorite,
|
||||
onClick,
|
||||
}: {
|
||||
isFavorite?: boolean;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
}) => (
|
||||
<QuickLinkFavoriteStyle
|
||||
onClick={onClick ? (event) => onClick(event) : undefined}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<AiFillStar color={theme.colors.yellow} />
|
||||
) : (
|
||||
<AiOutlineStar color={theme.colors.yellow} />
|
||||
)}
|
||||
</QuickLinkFavoriteStyle>
|
||||
);
|
||||
|
||||
export default QuickLinkFavorite;
|
||||
@@ -0,0 +1,223 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import type Collection from '#models/collection';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
|
||||
import VisibilityBadge from '~/components/dashboard/side_nav/visibilty/visibilty';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import { arrayMove } from '~/lib/array';
|
||||
import sortCcollectionsByNextId from '~/lib/collection';
|
||||
import { makeRequest } from '~/lib/request';
|
||||
|
||||
interface CollectionItemProps {
|
||||
collection: Collection;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type CollectionDragItem = {
|
||||
collectionId: Collection['id'];
|
||||
index: number;
|
||||
};
|
||||
|
||||
const CollectionItemStyle = styled.li<{ active: boolean }>(
|
||||
({ theme, active }) => ({
|
||||
color: active ? theme.colors.blue : theme.colors.font,
|
||||
borderBottom: active
|
||||
? `'2px solid ${theme.colors.lightestGrey} !important'`
|
||||
: '2px solid transparent !important',
|
||||
display: 'flex',
|
||||
gap: '0.25em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: `${theme.transition.delay} box-shadow`,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.blue,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const CollectionContent = styled.div({
|
||||
width: 'calc(100% - 24px)',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionNameWrapper = styled.div({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const CollectionName = styled.div({
|
||||
minWidth: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const CollectionLinksCount = styled.div(({ theme }) => ({
|
||||
minWidth: 'fit-content',
|
||||
fontSize: '0.85em',
|
||||
color: theme.colors.grey,
|
||||
}));
|
||||
|
||||
export default function CollectionItem({
|
||||
collection,
|
||||
index,
|
||||
}: Readonly<CollectionItemProps>): JSX.Element {
|
||||
const { activeCollection, setActiveCollection } = useActiveCollection();
|
||||
const { collections, setCollections } = useCollections();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = useRef<HTMLLIElement>();
|
||||
|
||||
const sendMoveCollectionRequest = useCallback(
|
||||
async (currentCollection: Collection, nextId?: string) => {
|
||||
if (currentCollection.id === nextId) return;
|
||||
|
||||
await makeRequest({
|
||||
url: `${PATHS.API.COLLECTION}/${currentCollection.id}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: currentCollection.name,
|
||||
nextId,
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
setCollections((prevCollections) => {
|
||||
const newCollections = [...prevCollections];
|
||||
const collectionIndex = newCollections.findIndex(
|
||||
(c) => c.id === currentCollection.id
|
||||
);
|
||||
|
||||
const previousCollectionIndex = newCollections.findIndex(
|
||||
(c) => c.nextId === currentCollection.id
|
||||
);
|
||||
const prevNextCollectionIndex = newCollections.findIndex(
|
||||
(c) => c.nextId === nextId
|
||||
);
|
||||
|
||||
newCollections[collectionIndex] = {
|
||||
...newCollections[collectionIndex],
|
||||
nextId,
|
||||
};
|
||||
if (previousCollectionIndex !== -1) {
|
||||
newCollections[previousCollectionIndex] = {
|
||||
...newCollections[previousCollectionIndex],
|
||||
nextId: currentCollection.nextId,
|
||||
};
|
||||
}
|
||||
if (prevNextCollectionIndex !== -1) {
|
||||
newCollections[prevNextCollectionIndex] = {
|
||||
...newCollections[prevNextCollectionIndex],
|
||||
nextId: currentCollection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return sortCcollectionsByNextId(newCollections);
|
||||
});
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
const moveCollection = useCallback(
|
||||
(currentIndex: number, newIndex: number) => {
|
||||
// @ts-ignore
|
||||
setCollections((prevCollections: Collection[]) =>
|
||||
arrayMove(prevCollections, currentIndex, newIndex)
|
||||
);
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const [_, drop] = useDrop({
|
||||
accept: 'collection',
|
||||
hover: (dragItem: CollectionDragItem) => {
|
||||
if (ref.current && dragItem.collectionId !== collection.id) {
|
||||
moveCollection(dragItem.index, index);
|
||||
dragItem.index = index;
|
||||
}
|
||||
},
|
||||
drop: (item) => {
|
||||
const currentCollection = collections.find(
|
||||
(c) => c.id === item.collectionId
|
||||
);
|
||||
const nextCollection = collections[item.index + 1];
|
||||
if (
|
||||
currentCollection?.nextId === null &&
|
||||
nextCollection?.id === undefined
|
||||
)
|
||||
return;
|
||||
if (currentCollection?.nextId !== nextCollection?.id) {
|
||||
sendMoveCollectionRequest(
|
||||
currentCollection!,
|
||||
nextCollection?.id ?? null
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [{ opacity }, drag] = useDrag({
|
||||
type: 'collection',
|
||||
item: () => ({ index, collectionId: collection.id }),
|
||||
collect: (monitor: any) => ({
|
||||
opacity: monitor.isDragging() ? 0.1 : 1,
|
||||
}),
|
||||
end: (dragItem: CollectionDragItem, monitor) => {
|
||||
const didDrop = monitor.didDrop();
|
||||
if (!didDrop) {
|
||||
moveCollection(dragItem.index, index);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collection.id === activeCollection?.id) {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [collection.id, activeCollection?.id]);
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<CollectionItemStyle
|
||||
style={{
|
||||
transition: 'none',
|
||||
opacity,
|
||||
}}
|
||||
onClick={() => setActiveCollection(collection)}
|
||||
title={collection.name}
|
||||
active={collection.id === activeCollection?.id}
|
||||
// @ts-ignore
|
||||
ref={ref}
|
||||
>
|
||||
{collection.id === activeCollection?.id ? (
|
||||
<AiFillFolderOpen size={24} />
|
||||
) : (
|
||||
<AiOutlineFolder size={24} />
|
||||
)}
|
||||
|
||||
<CollectionContent>
|
||||
<CollectionNameWrapper>
|
||||
<CollectionName>{collection.name}</CollectionName>
|
||||
<CollectionLinksCount>
|
||||
— {collection.links.length}
|
||||
</CollectionLinksCount>
|
||||
</CollectionNameWrapper>
|
||||
<VisibilityBadge
|
||||
label={t('common:collection.visibility')}
|
||||
visibility={collection.visibility}
|
||||
/>
|
||||
</CollectionContent>
|
||||
</CollectionItemStyle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo } from 'react';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UnstyledList from '~/components/common/unstyled/unstyled_list';
|
||||
import CollectionItem from '~/components/dashboard/side_nav/collection/collection_item';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
import useCollections from '~/hooks/use_collections';
|
||||
import useGlobalHotkeys from '~/hooks/use_global_hotkeys';
|
||||
import Keys from '../../../../../app/constants/keys';
|
||||
|
||||
const CollectionListStyle = styled.div({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export default function CollectionList() {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useCollections();
|
||||
const { activeCollection, setActiveCollection } = useActiveCollection();
|
||||
const { globalHotkeysEnabled } = useGlobalHotkeys();
|
||||
|
||||
const linksCount = useMemo(
|
||||
() =>
|
||||
collections.reduce((acc, current) => (acc += current.links.length), 0),
|
||||
[collections]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_UP,
|
||||
() => {
|
||||
const currentCollectionIndex = collections.findIndex(
|
||||
({ id }) => id === activeCollection?.id
|
||||
);
|
||||
if (currentCollectionIndex === -1 || currentCollectionIndex === 0) return;
|
||||
|
||||
setActiveCollection(collections[currentCollectionIndex - 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled }
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_DOWN,
|
||||
() => {
|
||||
const currentCollectionIndex = collections.findIndex(
|
||||
({ id }) => id === activeCollection?.id
|
||||
);
|
||||
if (
|
||||
currentCollectionIndex === -1 ||
|
||||
currentCollectionIndex === collections.length - 1
|
||||
)
|
||||
return;
|
||||
|
||||
setActiveCollection(collections[currentCollectionIndex + 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled }
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionListStyle>
|
||||
<h4>
|
||||
{t('collection.collections', { count: linksCount })} • {linksCount}
|
||||
</h4>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ListOfCollections />
|
||||
</DndProvider>
|
||||
</CollectionListStyle>
|
||||
);
|
||||
}
|
||||
|
||||
function ListOfCollections() {
|
||||
const [, drop] = useDrop(() => ({ accept: 'collection' }));
|
||||
const { collections } = useCollections();
|
||||
|
||||
return (
|
||||
<UnstyledList
|
||||
css={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
// @ts-ignore
|
||||
ref={drop}
|
||||
>
|
||||
{collections.map((collection, index) => (
|
||||
<CollectionItem
|
||||
collection={collection}
|
||||
key={collection.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</UnstyledList>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type Link from '#models/link';
|
||||
import styled from '@emotion/styled';
|
||||
import ExternalLink from '~/components/common/external_link';
|
||||
import LinkFavicon from '~/components/dashboard/link/link_favicon';
|
||||
|
||||
const FavoriteLinkStyle = styled.li(({ theme }) => ({
|
||||
width: '100%',
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
borderBottom: `2px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.colors.blue,
|
||||
backgroundColor: theme.colors.lightGrey,
|
||||
borderBottom: `2px solid ${theme.colors.blue}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const LinkStyle = styled(ExternalLink)({
|
||||
width: '100%',
|
||||
color: 'inherit',
|
||||
padding: '0.5em 1em',
|
||||
border: '0 !important',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25em',
|
||||
});
|
||||
|
||||
const LinkName = styled.span({
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const CollectionName = styled.span(({ theme }) => ({
|
||||
maxWidth: '75px',
|
||||
fontSize: '0.85em',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.colors.grey,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const FavoriteItem = ({
|
||||
link,
|
||||
}: Readonly<{
|
||||
link: Link;
|
||||
}>) => (
|
||||
<FavoriteLinkStyle>
|
||||
<LinkStyle
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={link.name}
|
||||
>
|
||||
<LinkFavicon url={link.url} size={24} noMargin />
|
||||
<LinkName>{link.name}</LinkName>
|
||||
<CollectionName> - {link.collection.name}</CollectionName>
|
||||
</LinkStyle>
|
||||
</FavoriteLinkStyle>
|
||||
);
|
||||
export default FavoriteItem;
|
||||
@@ -0,0 +1,44 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UnstyledList from '~/components/common/unstyled/unstyled_list';
|
||||
import FavoriteItem from '~/components/dashboard/side_nav/favorites/favorite_item';
|
||||
import useFavorites from '~/hooks/use_favorites';
|
||||
|
||||
const FavoriteListWrapper = styled.div({
|
||||
height: 'auto',
|
||||
width: '100%',
|
||||
marginBottom: '15px',
|
||||
});
|
||||
|
||||
const FavoriteListLegend = styled.h4(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.85em',
|
||||
fontWeight: '500',
|
||||
color: theme.colors.grey,
|
||||
marginBottom: '5px',
|
||||
}));
|
||||
|
||||
const FavoriteListStyle = styled(UnstyledList)({
|
||||
display: 'flex',
|
||||
gap: '5px',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export default function FavoriteList() {
|
||||
const { t } = useTranslation('common');
|
||||
const { favorites } = useFavorites();
|
||||
|
||||
return (
|
||||
favorites.length !== 0 && (
|
||||
<FavoriteListWrapper>
|
||||
<FavoriteListLegend>{t('favorite')}</FavoriteListLegend>
|
||||
<FavoriteListStyle>
|
||||
{favorites.map((link) => (
|
||||
<FavoriteItem link={link} key={link.id} />
|
||||
))}
|
||||
</FavoriteListStyle>
|
||||
</FavoriteListWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useActiveCollection from '~/hooks/use_active_collection';
|
||||
|
||||
const MenuControls = styled.div({
|
||||
margin: '10px 0',
|
||||
display: 'flex',
|
||||
gap: '0.25em',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
const MenuAction = styled.div({
|
||||
fontSize: '0.95em',
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
});
|
||||
|
||||
export default function NavigationLinks() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
|
||||
return (
|
||||
<MenuControls>
|
||||
{/* <MenuAction>
|
||||
<SearchModal>{t('common:search')}</SearchModal>
|
||||
<kbd>S</kbd>
|
||||
</MenuAction> */}
|
||||
<MenuAction>
|
||||
<Link href={PATHS.COLLECTION.CREATE}>{t('collection.create')}</Link>
|
||||
<kbd>C</kbd>
|
||||
</MenuAction>
|
||||
<MenuAction>
|
||||
<Link
|
||||
href={`${PATHS.LINK.CREATE}?collectionId=${activeCollection?.id}`}
|
||||
>
|
||||
{t('link.create')}
|
||||
</Link>
|
||||
<kbd>L</kbd>
|
||||
</MenuAction>
|
||||
</MenuControls>
|
||||
);
|
||||
}
|
||||
70
inertia/components/dashboard/side_nav/side_navigation.tsx
Normal file
70
inertia/components/dashboard/side_nav/side_navigation.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import styled from '@emotion/styled';
|
||||
import CollectionList from '~/components/dashboard/side_nav/collection/collection_list';
|
||||
import FavoriteList from '~/components/dashboard/side_nav/favorites/favorite_list';
|
||||
import NavigationLinks from '~/components/dashboard/side_nav/navigation_links/navigation_links';
|
||||
import UserCard from '~/components/dashboard/side_nav/user_card/user_card';
|
||||
|
||||
const SideMenu = styled.nav({
|
||||
height: '100%',
|
||||
width: '325px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const BlockWrapper = styled.section(({ theme }) => ({
|
||||
height: 'auto',
|
||||
width: '100%',
|
||||
|
||||
'& h4': {
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.85em',
|
||||
fontWeight: 500,
|
||||
color: theme.colors.grey,
|
||||
marginBottom: '5px',
|
||||
},
|
||||
|
||||
'& ul': {
|
||||
flex: 1,
|
||||
animation: 'fadein 0.3s both',
|
||||
},
|
||||
|
||||
'& ul li': {
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
fontSize: '0.9em',
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: '0.5em 1em',
|
||||
borderBottom: `2px solid ${theme.colors.lightestGrey}`,
|
||||
borderRadius: theme.border.radius,
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:not(:last-child)': {
|
||||
marginBottom: '5px',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SideNavigation = () => (
|
||||
<SideMenu>
|
||||
<BlockWrapper>
|
||||
<FavoriteList />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper css={{ minHeight: '0', flex: '1' }}>
|
||||
<CollectionList />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<NavigationLinks />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<UserCard />
|
||||
</BlockWrapper>
|
||||
</SideMenu>
|
||||
);
|
||||
|
||||
export default SideNavigation;
|
||||
@@ -0,0 +1,73 @@
|
||||
import PATHS from '#constants/paths';
|
||||
import styled from '@emotion/styled';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MdAdminPanelSettings } from 'react-icons/md';
|
||||
import RoundedImage from '~/components/common/rounded_image';
|
||||
import useUser from '~/hooks/use_user';
|
||||
|
||||
const UserCardWrapper = styled.div(({ theme }) => ({
|
||||
userSelect: 'none',
|
||||
height: 'fit-content',
|
||||
width: '100%',
|
||||
color: theme.colors.black,
|
||||
backgroundColor: theme.colors.white,
|
||||
border: `1px solid ${theme.colors.lightestGrey}`,
|
||||
padding: '7px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
||||
'& button, & a': {
|
||||
cursor: 'pointer',
|
||||
color: theme.colors.blue,
|
||||
display: 'flex',
|
||||
transition: theme.transition.delay,
|
||||
|
||||
'&:hover': {
|
||||
transform: 'scale(1.3)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const UserCardStyle = styled.div({
|
||||
display: 'flex',
|
||||
gap: '0.5em',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const UserControls = styled.div({
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
});
|
||||
|
||||
export default function UserCard() {
|
||||
const { user } = useUser();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const avatarLabel = t('avatar', {
|
||||
name: user.name,
|
||||
});
|
||||
return (
|
||||
<UserCardWrapper>
|
||||
<UserCardStyle>
|
||||
<RoundedImage
|
||||
src={user.avatarUrl}
|
||||
width={28}
|
||||
height={28}
|
||||
alt={avatarLabel}
|
||||
title={avatarLabel}
|
||||
/>
|
||||
{user.nickName}
|
||||
</UserCardStyle>
|
||||
<UserControls>
|
||||
{user.is_admin && (
|
||||
<Link href={PATHS.ADMIN} className="reset">
|
||||
<MdAdminPanelSettings color="red" size={24} />
|
||||
</Link>
|
||||
)}
|
||||
{/* <SettingsModal /> */}
|
||||
</UserControls>
|
||||
</UserCardWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IoEarthOutline } from 'react-icons/io5';
|
||||
import { Visibility } from '../../../../../app/enums/visibility';
|
||||
|
||||
const VisibilityStyle = styled.span(({ theme }) => ({
|
||||
fontWeight: 300,
|
||||
fontSize: '0.6em',
|
||||
color: theme.colors.lightBlue,
|
||||
border: `1px solid ${theme.colors.lightBlue}`,
|
||||
borderRadius: '50px',
|
||||
padding: '0.15em 0.65em',
|
||||
display: 'flex',
|
||||
gap: '0.35em',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const VisibilityBadge = ({
|
||||
label,
|
||||
visibility,
|
||||
}: {
|
||||
label: string;
|
||||
visibility: Visibility;
|
||||
}) =>
|
||||
visibility === Visibility.PUBLIC && (
|
||||
<VisibilityStyle>
|
||||
{label} <IoEarthOutline size="1em" />
|
||||
</VisibilityStyle>
|
||||
);
|
||||
|
||||
export default VisibilityBadge;
|
||||
10
inertia/components/dashboard/swiper_handler.tsx
Normal file
10
inertia/components/dashboard/swiper_handler.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const SwiperHandler = styled.div(({ theme }) => ({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
transition: `background-color ${theme.transition.delay}`,
|
||||
}));
|
||||
|
||||
export default SwiperHandler;
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
inertia/contexts/active_collection_context.tsx
Normal file
15
inertia/contexts/active_collection_context.tsx
Normal 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);
|
||||
18
inertia/contexts/collections_context.ts
Normal file
18
inertia/contexts/collections_context.ts
Normal 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;
|
||||
16
inertia/contexts/favorites_context.ts
Normal file
16
inertia/contexts/favorites_context.ts
Normal 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;
|
||||
17
inertia/contexts/global_hotkeys_context.ts
Normal file
17
inertia/contexts/global_hotkeys_context.ts
Normal 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;
|
||||
5
inertia/hooks/use_active_collection.tsx
Normal file
5
inertia/hooks/use_active_collection.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import { ActiveCollectionContext } from '~/contexts/active_collection_context';
|
||||
|
||||
const useActiveCollection = () => useContext(ActiveCollectionContext);
|
||||
export default useActiveCollection;
|
||||
9
inertia/hooks/use_auto_focus.tsx
Normal file
9
inertia/hooks/use_auto_focus.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export default function useAutoFocus() {
|
||||
return useCallback((inputElement: any) => {
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
5
inertia/hooks/use_collections.tsx
Normal file
5
inertia/hooks/use_collections.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import CollectionsContext from '~/contexts/collections_context';
|
||||
|
||||
const useCollections = () => useContext(CollectionsContext);
|
||||
export default useCollections;
|
||||
5
inertia/hooks/use_favorites.tsx
Normal file
5
inertia/hooks/use_favorites.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import FavoritesContext from '~/contexts/favorites_context';
|
||||
|
||||
const useFavorites = () => useContext(FavoritesContext);
|
||||
export default useFavorites;
|
||||
5
inertia/hooks/use_global_hotkeys.tsx
Normal file
5
inertia/hooks/use_global_hotkeys.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import GlobalHotkeysContext from '~/contexts/global_hotkeys_context';
|
||||
|
||||
const useGlobalHotkeys = () => useContext(GlobalHotkeysContext);
|
||||
export default useGlobalHotkeys;
|
||||
8
inertia/hooks/use_is_mobile.tsx
Normal file
8
inertia/hooks/use_is_mobile.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
30
inertia/hooks/use_local_storage.tsx
Normal file
30
inertia/hooks/use_local_storage.tsx
Normal 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];
|
||||
}
|
||||
24
inertia/hooks/use_media_query.tsx
Normal file
24
inertia/hooks/use_media_query.tsx
Normal 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;
|
||||
}
|
||||
18
inertia/hooks/use_modal.tsx
Normal file
18
inertia/hooks/use_modal.tsx
Normal 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;
|
||||
43
inertia/hooks/use_search_item.tsx
Normal file
43
inertia/hooks/use_search_item.tsx
Normal 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;
|
||||
}
|
||||
12
inertia/hooks/use_search_param.tsx
Normal file
12
inertia/hooks/use_search_param.tsx
Normal 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;
|
||||
@@ -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",
|
||||
@@ -25,4 +25,4 @@
|
||||
},
|
||||
"look-title": "Take a look",
|
||||
"website-screenshot-alt": "A screenshot of MyLinks"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -50,4 +50,4 @@
|
||||
"footer": {
|
||||
"made_by": "Made with ❤\uFE0F by"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -42,4 +48,4 @@
|
||||
"changes": "We reserve the right to update this privacy policy. We encourage you to regularly check this page to stay informed of any changes.",
|
||||
"thanks": "Thank you for using MyLinks!"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -25,4 +25,4 @@
|
||||
},
|
||||
"look-title": "Jetez un coup d'oeil",
|
||||
"website-screenshot-alt": "Une capture d'écran de MyLinks"
|
||||
}
|
||||
}
|
||||
@@ -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é",
|
||||
@@ -50,4 +51,4 @@
|
||||
"footer": {
|
||||
"made_by": "Fait avec ❤\uFE0F par"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -48,4 +48,4 @@
|
||||
"changes": "Nous nous réservons le droit de mettre à jour cette politique de confidentialité. Nous vous encourageons à consulter régulièrement cette page pour rester informé des changements éventuels.",
|
||||
"thanks": "Merci d'utiliser MyLinks !"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
inertia/lib/array.ts
Normal file
39
inertia/lib/array.ts
Normal 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
28
inertia/lib/collection.ts
Normal 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
6
inertia/lib/image.ts
Normal 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');
|
||||
9
inertia/lib/navigation.tsx
Normal file
9
inertia/lib/navigation.tsx
Normal 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
24
inertia/lib/request.ts
Normal 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
53
inertia/lib/search.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,28 +35,30 @@ export default function CreateCollectionPage() {
|
||||
handleSubmit={handleSubmit}
|
||||
canSubmit={!isFormDisabled}
|
||||
>
|
||||
<TextBox
|
||||
label="Collection name"
|
||||
placeholder="Collection name"
|
||||
name="name"
|
||||
onChange={setData}
|
||||
value={data.name}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
<TextBox
|
||||
label="Collection description"
|
||||
placeholder="Collection description"
|
||||
name="description"
|
||||
onChange={setData}
|
||||
value={data.name}
|
||||
/>
|
||||
{errors.description && <div>{errors.description}</div>}
|
||||
<FormField>
|
||||
<label htmlFor="visibility">Public</label>
|
||||
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
|
||||
</FormField>
|
||||
<BackToDashboard>
|
||||
<TextBox
|
||||
label="Collection name"
|
||||
placeholder="Collection name"
|
||||
name="name"
|
||||
onChange={setData}
|
||||
value={data.name}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
<TextBox
|
||||
label="Collection description"
|
||||
placeholder="Collection description"
|
||||
name="description"
|
||||
onChange={setData}
|
||||
value={data.name}
|
||||
/>
|
||||
{errors.description && <div>{errors.description}</div>}
|
||||
<FormField>
|
||||
<label htmlFor="visibility">Public</label>
|
||||
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
|
||||
</FormField>
|
||||
</BackToDashboard>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
2
inertia/types/emotion.d.ts
vendored
2
inertia/types/emotion.d.ts
vendored
@@ -29,6 +29,8 @@ declare module '@emotion/react' {
|
||||
|
||||
lightGreen: string;
|
||||
green: string;
|
||||
|
||||
yellow: string;
|
||||
};
|
||||
|
||||
border: {
|
||||
|
||||
6
inertia/types/favicon.d.ts
vendored
Normal file
6
inertia/types/favicon.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
interface Favicon {
|
||||
buffer: Buffer;
|
||||
url: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
17
inertia/types/search.d.ts
vendored
Normal file
17
inertia/types/search.d.ts
vendored
Normal 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
104
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user