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
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit="dev"
|
RUN npm ci --omit="dev" --ignore-scripts
|
||||||
|
|
||||||
# Copy app source code
|
# Copy app source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build app
|
# Build app
|
||||||
RUN npm install
|
RUN npm install --ignore-scripts
|
||||||
RUN npm run build --omit="dev"
|
RUN npm run build --omit="dev"
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
COPY ./.env ./build
|
COPY ./.env ./build
|
||||||
|
|
||||||
@@ -30,4 +31,4 @@ ENV SESSION_DRIVER=cookie
|
|||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
|
|
||||||
# Start app
|
# 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 collections = await Collection.findManyBy('author_id', auth.user!.id);
|
||||||
|
|
||||||
const collectionsWithLinks = await Promise.all(
|
const collectionsWithLinks = await Promise.all(
|
||||||
collections.map((collection) => {
|
collections.map(async (collection) => {
|
||||||
collection.load('links');
|
await collection.load('links');
|
||||||
return collection;
|
return collection;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -18,7 +18,7 @@ export default class CollectionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async showCreatePage({ inertia }: HttpContext) {
|
async showCreatePage({ inertia }: HttpContext) {
|
||||||
return inertia.render('collection/create');
|
return inertia.render('collections/create');
|
||||||
}
|
}
|
||||||
|
|
||||||
async store({ request, response, auth }: HttpContext) {
|
async store({ request, response, auth }: HttpContext) {
|
||||||
@@ -34,6 +34,6 @@ export default class CollectionsController {
|
|||||||
response: HttpContext['response'],
|
response: HttpContext['response'],
|
||||||
collectionId: Collection['id']
|
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 {
|
export default class LogRequest {
|
||||||
async handle({ request }: HttpContext, next: () => Promise<void>) {
|
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();
|
await next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
- NODE_ENV=production
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
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';
|
import BaseLayout from './_base_layout';
|
||||||
|
|
||||||
const DashboardLayoutStyle = styled.div(({ theme }) => ({
|
const DashboardLayoutStyle = styled.div(({ theme }) => ({
|
||||||
height: 'auto',
|
height: '100%',
|
||||||
width: theme.media.small_desktop,
|
width: theme.media.small_desktop,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
padding: '0.75em 1em',
|
padding: '0.75em 1em',
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const FormLayout = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
{!disableHomeLink && (
|
{!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>
|
<Link href={PATHS.DASHBOARD}>← Revenir à l'accueil</Link>
|
||||||
)}
|
)}
|
||||||
</FormLayoutStyle>
|
</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",
|
"title": "Welcome to MyLinks",
|
||||||
"cta": "Get started"
|
"cta": "Get started"
|
||||||
},
|
},
|
||||||
"category": {
|
"collection": {
|
||||||
"title": "Create categories",
|
"title": "Create collections",
|
||||||
"text": "Organize your bookmarks by categories to keep your links tidy and find them easily."
|
"text": "Organize your bookmarks by collections to keep your links tidy and find them easily."
|
||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"title": "Manage Links",
|
"title": "Manage Links",
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
},
|
},
|
||||||
"look-title": "Take a look",
|
"look-title": "Take a look",
|
||||||
"website-screenshot-alt": "A screenshot of MyLinks"
|
"website-screenshot-alt": "A screenshot of MyLinks"
|
||||||
}
|
}
|
||||||
@@ -15,18 +15,18 @@
|
|||||||
"remove": "Delete a link",
|
"remove": "Delete a link",
|
||||||
"remove-confirm": "Confirm deletion?"
|
"remove-confirm": "Confirm deletion?"
|
||||||
},
|
},
|
||||||
"category": {
|
"collection": {
|
||||||
"categories": "Categories",
|
"collections": "Collections",
|
||||||
"category": "Category",
|
"collection": "Collection",
|
||||||
"name": "Category name",
|
"name": "Collection name",
|
||||||
"description": "Category description",
|
"description": "Collection description",
|
||||||
"no-description": "No description",
|
"no-description": "No description",
|
||||||
"visibility": "Public",
|
"visibility": "Public",
|
||||||
"create": "Create a category",
|
"create": "Create a collection",
|
||||||
"edit": "Edit a category",
|
"edit": "Edit a collection",
|
||||||
"remove": "Delete a category",
|
"remove": "Delete a collection",
|
||||||
"remove-confirm": "Confirm deletion?",
|
"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",
|
"favorite": "Favorite",
|
||||||
"no-item-found": "No item found",
|
"no-item-found": "No item found",
|
||||||
@@ -50,4 +50,4 @@
|
|||||||
"footer": {
|
"footer": {
|
||||||
"made_by": "Made with ❤\uFE0F by"
|
"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",
|
"or-create-one": "or create one",
|
||||||
"no-link": "No link for <b>{{name}}</b>"
|
"no-link": "No link for <b>{{name}}</b>"
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,19 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"title": "1.2 User Data",
|
"title": "1.2 User Data",
|
||||||
"description": "To create personalized categories and links and associate them with their author, we collect the following information:",
|
"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"]
|
"fields": [
|
||||||
|
"Google ID",
|
||||||
|
"Lastname",
|
||||||
|
"Firstname",
|
||||||
|
"Email",
|
||||||
|
"Avatar"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"data_use": {
|
"data_use": {
|
||||||
"title": "2. 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": {
|
"data_storage": {
|
||||||
"title": "3. 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.",
|
"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!"
|
"thanks": "Thank you for using MyLinks!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
"title": "Bienvenue sur MyLinks",
|
"title": "Bienvenue sur MyLinks",
|
||||||
"cta": "Lancez-vous !"
|
"cta": "Lancez-vous !"
|
||||||
},
|
},
|
||||||
"category": {
|
"collection": {
|
||||||
"title": "Créer des catégories",
|
"title": "Créer des collections",
|
||||||
"text": "Organisez vos favoris dans des catégories pour garder vos liens en ordre et les retrouver facilement."
|
"text": "Organisez vos favoris dans des collections pour garder vos liens en ordre et les retrouver facilement."
|
||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"title": "Gérer les liens",
|
"title": "Gérer les liens",
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
},
|
},
|
||||||
"look-title": "Jetez un coup d'oeil",
|
"look-title": "Jetez un coup d'oeil",
|
||||||
"website-screenshot-alt": "Une capture d'écran de MyLinks"
|
"website-screenshot-alt": "Une capture d'écran de MyLinks"
|
||||||
}
|
}
|
||||||
@@ -15,18 +15,19 @@
|
|||||||
"remove": "Supprimer un lien",
|
"remove": "Supprimer un lien",
|
||||||
"remove-confirm": "Confirmer la suppression ?"
|
"remove-confirm": "Confirmer la suppression ?"
|
||||||
},
|
},
|
||||||
"category": {
|
"collection": {
|
||||||
"categories": "Catégories",
|
"collections": "Collection",
|
||||||
"category": "Catégorie",
|
"collections_other": "Collections",
|
||||||
"name": "Nom de la catégorie",
|
"collection": "Collection",
|
||||||
"description": "Description de la catégorie",
|
"name": "Nom de la collection",
|
||||||
|
"description": "Description de la collection",
|
||||||
"visibility": "Public",
|
"visibility": "Public",
|
||||||
"no-description": "Aucune description",
|
"no-description": "Aucune description",
|
||||||
"create": "Créer une catégorie",
|
"create": "Créer une collection",
|
||||||
"edit": "Modifier une catégorie",
|
"edit": "Modifier une collection",
|
||||||
"remove": "Supprimer une catégorie",
|
"remove": "Supprimer une collection",
|
||||||
"remove-confirm": "Confirmer la suppression ?",
|
"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",
|
"favorite": "Favoris",
|
||||||
"no-item-found": "Aucun élément trouvé",
|
"no-item-found": "Aucun élément trouvé",
|
||||||
@@ -50,4 +51,4 @@
|
|||||||
"footer": {
|
"footer": {
|
||||||
"made_by": "Fait avec ❤\uFE0F par"
|
"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",
|
"or-create-one": "ou en créer une",
|
||||||
"no-link": "Aucun lien pour <b>{{name}}</b>"
|
"no-link": "Aucun lien pour <b>{{name}}</b>"
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"title": "1.2 Données utilisateur",
|
"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": [
|
"fields": [
|
||||||
"Identifiant Google",
|
"Identifiant Google",
|
||||||
"Nom",
|
"Nom",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"data_use": {
|
"data_use": {
|
||||||
"title": "2. Utilisation des données",
|
"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": {
|
"data_storage": {
|
||||||
"title": "3. Stockage des données",
|
"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.",
|
"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 !"
|
"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 { ChangeEvent, FormEvent, useMemo } from 'react';
|
||||||
import FormField from '~/components/common/form/_form_field';
|
import FormField from '~/components/common/form/_form_field';
|
||||||
import TextBox from '~/components/common/form/textbox';
|
import TextBox from '~/components/common/form/textbox';
|
||||||
|
import BackToDashboard from '~/components/common/navigation/bask_to_dashboard';
|
||||||
import FormLayout from '~/components/layouts/form_layout';
|
import FormLayout from '~/components/layouts/form_layout';
|
||||||
import { Visibility } from '../../../app/enums/visibility';
|
import { Visibility } from '../../../app/enums/visibility';
|
||||||
|
|
||||||
@@ -34,28 +35,30 @@ export default function CreateCollectionPage() {
|
|||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
canSubmit={!isFormDisabled}
|
canSubmit={!isFormDisabled}
|
||||||
>
|
>
|
||||||
<TextBox
|
<BackToDashboard>
|
||||||
label="Collection name"
|
<TextBox
|
||||||
placeholder="Collection name"
|
label="Collection name"
|
||||||
name="name"
|
placeholder="Collection name"
|
||||||
onChange={setData}
|
name="name"
|
||||||
value={data.name}
|
onChange={setData}
|
||||||
required
|
value={data.name}
|
||||||
autoFocus
|
required
|
||||||
/>
|
autoFocus
|
||||||
{errors.name && <div>{errors.name}</div>}
|
/>
|
||||||
<TextBox
|
{errors.name && <div>{errors.name}</div>}
|
||||||
label="Collection description"
|
<TextBox
|
||||||
placeholder="Collection description"
|
label="Collection description"
|
||||||
name="description"
|
placeholder="Collection description"
|
||||||
onChange={setData}
|
name="description"
|
||||||
value={data.name}
|
onChange={setData}
|
||||||
/>
|
value={data.name}
|
||||||
{errors.description && <div>{errors.description}</div>}
|
/>
|
||||||
<FormField>
|
{errors.description && <div>{errors.description}</div>}
|
||||||
<label htmlFor="visibility">Public</label>
|
<FormField>
|
||||||
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
|
<label htmlFor="visibility">Public</label>
|
||||||
</FormField>
|
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
|
||||||
|
</FormField>
|
||||||
|
</BackToDashboard>
|
||||||
</FormLayout>
|
</FormLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,144 @@
|
|||||||
|
import KEYS from '#constants/keys';
|
||||||
|
import PATHS from '#constants/paths';
|
||||||
import type Collection from '#models/collection';
|
import type Collection from '#models/collection';
|
||||||
import { Link } from '@inertiajs/react';
|
import Link from '#models/link';
|
||||||
import Footer from '~/components/footer/footer';
|
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 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'>
|
interface HomePageProps {
|
||||||
type DashboardPageProps = {
|
|
||||||
collections: Collection[];
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Link href="/collections/create">Add collection</Link>
|
<HomeProviders
|
||||||
{collections.map((collection) => (
|
collections={props.collections}
|
||||||
<li key={collection.id}>{collection.name}</li>
|
activeCollection={props.activeCollection}
|
||||||
))}
|
>
|
||||||
<Footer />
|
<SwiperHandler {...handlers}>
|
||||||
|
{!isMobile && (
|
||||||
|
<SideBar>
|
||||||
|
<SideNavigation />
|
||||||
|
</SideBar>
|
||||||
|
)}
|
||||||
|
{/* <AnimatePresence>
|
||||||
|
{isShowing && (
|
||||||
|
<SideMenu close={close}>
|
||||||
|
<SideNavigation />
|
||||||
|
</SideMenu>
|
||||||
|
)}
|
||||||
|
</AnimatePresence> */}
|
||||||
|
<Links isMobile={isMobile} openSideMenu={open} />
|
||||||
|
</SwiperHandler>
|
||||||
|
</HomeProviders>
|
||||||
</DashboardLayout>
|
</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',
|
fontWeight: '500',
|
||||||
color: theme.colors.primary,
|
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 lightGreen = '#c1ffbab9';
|
||||||
const green = 'green';
|
const green = 'green';
|
||||||
|
|
||||||
|
const yellow = '#ffc107';
|
||||||
|
|
||||||
export const theme: Theme = {
|
export const theme: Theme = {
|
||||||
colors: {
|
colors: {
|
||||||
font: black,
|
font: black,
|
||||||
@@ -51,6 +53,8 @@ export const theme: Theme = {
|
|||||||
|
|
||||||
lightGreen,
|
lightGreen,
|
||||||
green,
|
green,
|
||||||
|
|
||||||
|
yellow,
|
||||||
},
|
},
|
||||||
|
|
||||||
border: {
|
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;
|
lightGreen: string;
|
||||||
green: string;
|
green: string;
|
||||||
|
|
||||||
|
yellow: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
border: {
|
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",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.1",
|
"react-i18next": "^14.1.1",
|
||||||
|
"react-icons": "^5.1.0",
|
||||||
|
"react-swipeable": "^7.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@@ -2342,6 +2347,21 @@
|
|||||||
"validator": "^13.9.0"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.17.0",
|
"version": "4.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.0.tgz",
|
"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",
|
"version": "15.7.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
|
||||||
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
|
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.9.15",
|
"version": "6.9.15",
|
||||||
@@ -2943,7 +2963,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
|
"integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -4746,6 +4766,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
"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": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
@@ -8762,6 +8792,43 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
@@ -8774,6 +8841,15 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-i18next": {
|
||||||
"version": "14.1.1",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz",
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -8810,6 +8894,14 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/read-package-up": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
|
||||||
@@ -9103,6 +9195,14 @@
|
|||||||
"node": ">= 10.13.0"
|
"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": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
|||||||
@@ -79,8 +79,13 @@
|
|||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.1",
|
"react-i18next": "^14.1.1",
|
||||||
|
"react-icons": "^5.1.0",
|
||||||
|
"react-swipeable": "^7.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.tsx' } }),
|
inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.tsx' } }),
|
||||||
react(),
|
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