Reorder categories (#12)

* feat: add next category id column

* feat: reorder categories (front)

* refactor: remove dead code & some optimization

* feat(wip): add order column + sql request + trigger

* refactor: fix warnings

* fix: syntax error in migration

* feat: create sql query (category reorder)

* feat: use prisma generated types instead

* feat: create some react context with hooks

* refactor: move a lot of code from home page to
dedicated components

* refactor: extend generated prisma types

* refactor: use hooks and move links footer in dedicated component

* refactor: fix bad types used

* fix: warnings caused by setState loop

* feat: use nextid instead of order column

* fix: reorder categories (front)

* fix: sort categories by nextId

* feat: prevent send update request if cat.id equal target.id

* fix: reorganization applied even if there is no change

* chore: remove unused en var

* feat: check if nextid category exist

* chore: sql query for migration

* refactor: remove useless code and prevent sending request when category not moving

* fix: redorder categories when sending request
This commit is contained in:
Sonny
2023-12-14 00:15:58 +01:00
committed by GitHub
parent 406bf281b0
commit ad682faa9e
57 changed files with 1016 additions and 572 deletions

4
.gitignore vendored
View File

@@ -41,5 +41,5 @@ public/sitemap*
public/robots.txt public/robots.txt
# pwa static files # pwa static files
/public/sw.js /public/sw*
/public/workbox-*.js /public/workbox-*

View File

@@ -1,11 +1,25 @@
version: '3.8' version: '3.8'
services: services:
my-links-dev-db: mariadb:
container_name: my-links-dev-db container_name: mariadb
image: mysql:latest image: mysql:latest
restart: always restart: always
env_file: env_file:
- .env - .env
ports: ports:
- '3306:3306' - '3306:3306'
phpmyadmin:
image: phpmyadmin:5
container_name: phpmyadmin
restart: always
environment:
- PMA_HOST=mariadb
- PMA_PORT=3306
env_file:
- .env
ports:
- '8080:80'
depends_on:
- mariadb

View File

@@ -1 +1,3 @@
CREATE DATABASE IF NOT EXISTS mylinks; CREATE DATABASE IF NOT EXISTS mylinks;
GRANT ALL PRIVILEGES ON DATABASE * TO mluser;

View File

@@ -1,6 +1,6 @@
MYSQL_USER="my-user" MYSQL_USER="root"
MYSQL_PASSWORD="my-user_passwd" MYSQL_PASSWORD="root"
MYSQL_ROOT_PASSWORD="root_passwd" MYSQL_ROOT_PASSWORD="root"
MYSQL_DATABASE="mylinks" MYSQL_DATABASE="mylinks"
# Or if you need external Database # Or if you need external Database
@@ -9,7 +9,6 @@ MYSQL_DATABASE="mylinks"
# DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${DATABASE_IP}:${DATABASE_PORT}/${MYSQL_DATABASE}" # DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${DATABASE_IP}:${DATABASE_PORT}/${MYSQL_DATABASE}"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXTAUTH_SECRET="" NEXTAUTH_SECRET=""

72
package-lock.json generated
View File

@@ -23,6 +23,8 @@
"node-html-parser": "^6.1.11", "node-html-parser": "^6.1.11",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
@@ -2323,6 +2325,21 @@
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz",
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==" "integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
}, },
"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/plugin-babel": { "node_modules/@rollup/plugin-babel": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -4413,6 +4430,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"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",
@@ -7883,6 +7910,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.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -8022,6 +8086,14 @@
"node": ">=8.10.0" "node": ">=8.10.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.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",

View File

@@ -28,6 +28,8 @@
"node-html-parser": "^6.1.11", "node-html-parser": "^6.1.11",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `category` ADD COLUMN `nextId` INTEGER NULL;

View File

@@ -0,0 +1,10 @@
-- set default next id for each category based on LEAD(id) (LEAD -> Next)
UPDATE Category AS c
JOIN (
SELECT
id,
LEAD(id) OVER (PARTITION BY authorId ORDER BY id) AS nextCategoryId
FROM Category
) AS n ON c.id = n.id
SET c.nextId = n.nextCategoryId;

View File

@@ -33,6 +33,8 @@ model Category {
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
authorId Int authorId Int
nextId Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,25 +1,50 @@
import { motion } from 'framer-motion';
import LinkTag from 'next/link';
import { AiFillStar } from 'react-icons/ai';
import PATHS from 'constants/paths';
import { Link } from 'types';
import EditItem from 'components/QuickActions/EditItem'; import EditItem from 'components/QuickActions/EditItem';
import FavoriteItem from 'components/QuickActions/FavoriteItem'; import FavoriteItem from 'components/QuickActions/FavoriteItem';
import RemoveItem from 'components/QuickActions/RemoveItem'; import RemoveItem from 'components/QuickActions/RemoveItem';
import PATHS from 'constants/paths';
import { motion } from 'framer-motion';
import useCategories from 'hooks/useCategories';
import { makeRequest } from 'lib/request';
import LinkTag from 'next/link';
import { useCallback } from 'react';
import { AiFillStar } from 'react-icons/ai';
import { LinkWithCategory } from 'types';
import LinkFavicon from './LinkFavicon'; import LinkFavicon from './LinkFavicon';
import styles from './links.module.scss'; import styles from './links.module.scss';
import { makeRequest } from 'lib/request';
export default function LinkItem({ export default function LinkItem({
link, link,
toggleFavorite,
index, index,
}: { }: {
link: Link; link: LinkWithCategory;
toggleFavorite: (linkId: Link['id']) => void;
index: number; index: number;
}) { }) {
const { id, name, url, favorite } = link; const { id, name, url, favorite } = link;
const { categories, setCategories } = useCategories();
const toggleFavorite = useCallback(
(linkId: LinkWithCategory['id']) => {
let linkIndex = 0;
const categoryIndex = categories.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const link = categories[categoryIndex].links[linkIndex];
const categoriesCopy = [...categories];
categoriesCopy[categoryIndex].links[linkIndex] = {
...link,
favorite: !link.favorite,
};
setCategories(categoriesCopy);
},
[categories, setCategories],
);
const onFavorite = () => { const onFavorite = () => {
makeRequest({ makeRequest({
url: `${PATHS.API.LINK}/${link.id}`, url: `${PATHS.API.LINK}/${link.id}`,
@@ -77,7 +102,7 @@ export default function LinkItem({
); );
} }
function LinkItemURL({ url }: { url: Link['url'] }) { function LinkItemURL({ url }: { url: LinkWithCategory['url'] }) {
try { try {
const { origin, pathname, search } = new URL(url); const { origin, pathname, search } = new URL(url);
let text = ''; let text = '';

View File

@@ -1,35 +1,28 @@
import ButtonLink from 'components/ButtonLink'; import clsx from 'clsx';
import MobileCategoriesModal from 'components/MobileCategoriesModal';
import CreateItem from 'components/QuickActions/CreateItem'; import CreateItem from 'components/QuickActions/CreateItem';
import EditItem from 'components/QuickActions/EditItem'; import EditItem from 'components/QuickActions/EditItem';
import RemoveItem from 'components/QuickActions/RemoveItem'; import RemoveItem from 'components/QuickActions/RemoveItem';
import QuickActionSearch from 'components/QuickActions/Search'; import SearchModal from 'components/SearchModal/SearchModal';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import useActiveCategory from 'hooks/useActiveCategory';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link'; import LinkTag from 'next/link';
import { RxHamburgerMenu } from 'react-icons/rx'; import { BiSearchAlt } from 'react-icons/bi';
import { Category, Link } from 'types'; import quickActionStyles from '../QuickActions/quickactions.module.scss';
import { TFunctionParam } from 'types/i18next';
import LinkItem from './LinkItem'; import LinkItem from './LinkItem';
import LinksFooter from './LinksFooter';
import styles from './links.module.scss'; import styles from './links.module.scss';
import clsx from 'clsx';
import PATHS from 'constants/paths';
export default function Links({ interface LinksProps {
category,
toggleFavorite,
isMobile,
openMobileModal,
openSearchModal,
}: {
category: Category;
toggleFavorite: (linkId: Link['id']) => void;
isMobile: boolean; isMobile: boolean;
openMobileModal: () => void; }
openSearchModal: () => void;
}) {
const { t } = useTranslation('home');
if (category === null) { export default function Links({ isMobile }: Readonly<LinksProps>) {
const { t } = useTranslation('home');
const { activeCategory } = useActiveCategory();
if (activeCategory === null) {
return ( return (
<div className={styles['no-category']}> <div className={styles['no-category']}>
<p>{t('home:select-category')}</p> <p>{t('home:select-category')}</p>
@@ -38,23 +31,11 @@ export default function Links({
); );
} }
const { id, name, links } = category; const { id, name, links } = activeCategory;
return ( return (
<div className={styles['links-wrapper']}> <div className={styles['links-wrapper']}>
<h2 className={styles['category-header']}> <h2 className={styles['category-header']}>
{isMobile && ( {isMobile && <MobileCategoriesModal />}
<ButtonLink
style={{
display: 'flex',
}}
onClick={openMobileModal}
>
<RxHamburgerMenu
size={'1.5em'}
style={{ marginRight: '.5em' }}
/>
</ButtonLink>
)}
<span className={styles['category-name']}> <span className={styles['category-name']}>
{name} {name}
{links.length > 0 && ( {links.length > 0 && (
@@ -62,10 +43,12 @@ export default function Links({
)} )}
</span> </span>
<span className={styles['category-controls']}> <span className={styles['category-controls']}>
<QuickActionSearch openSearchModal={openSearchModal} /> <SearchModal childClassname={quickActionStyles['action']}>
<BiSearchAlt />
</SearchModal>
<CreateItem <CreateItem
type='link' type='link'
categoryId={category.id} categoryId={id}
/> />
<EditItem <EditItem
type='category' type='category'
@@ -82,7 +65,6 @@ export default function Links({
{links.map((link, index) => ( {links.map((link, index) => (
<LinkItem <LinkItem
link={link} link={link}
toggleFavorite={toggleFavorite}
index={index} index={index}
key={link.id} key={link.id}
/> />
@@ -91,7 +73,7 @@ export default function Links({
) : ( ) : (
<div className={styles['no-link']}> <div className={styles['no-link']}>
<motion.p <motion.p
key={Math.random()} key={id}
initial={{ opacity: 0, scale: 0.85 }} initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ transition={{
@@ -101,7 +83,7 @@ export default function Links({
duration: 0.01, duration: 0.01,
}} }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: t('home:no-link', { name } as TFunctionParam, { __html: t('home:no-link', { name } as any, {
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
}), }),
}} }}
@@ -111,36 +93,7 @@ export default function Links({
</LinkTag> </LinkTag>
</div> </div>
)} )}
<footer className={styles['footer']}> <LinksFooter />
<div className='top'>
<LinkTag href={PATHS.PRIVACY}>{t('common:privacy')}</LinkTag>
{' • '}
<LinkTag href={PATHS.TERMS}>{t('common:terms')}</LinkTag>
</div>
<div className='bottom'>
{t('home:footer.made_by')}{' '}
<LinkTag
href={PATHS.AUTHOR}
target='_blank'
>
Sonny
</LinkTag>
{' • '}
<LinkTag
href={PATHS.REPO_GITHUB}
target='_blank'
>
Github
</LinkTag>
{' • '}
<LinkTag
href={PATHS.EXTENSION}
target='_blank'
>
Extension
</LinkTag>
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -0,0 +1,41 @@
import PATHS from 'constants/paths';
import LinkTag from 'next/link';
import { useTranslation } from 'next-i18next';
import styles from './links.module.scss';
export default function LinksFooter() {
const { t } = useTranslation('home');
return (
<footer className={styles['footer']}>
<div className='top'>
<LinkTag href={PATHS.PRIVACY}>{t('common:privacy')}</LinkTag>
{' • '}
<LinkTag href={PATHS.TERMS}>{t('common:terms')}</LinkTag>
</div>
<div className='bottom'>
{t('home:footer.made_by')}{' '}
<LinkTag
href={PATHS.AUTHOR}
target='_blank'
>
Sonny
</LinkTag>
{' • '}
<LinkTag
href={PATHS.REPO_GITHUB}
target='_blank'
>
Github
</LinkTag>
{' • '}
<LinkTag
href={PATHS.EXTENSION}
target='_blank'
>
Extension
</LinkTag>
</div>
</footer>
);
}

View File

@@ -0,0 +1,42 @@
import PATHS from 'constants/paths';
import { AnimatePresence } from 'framer-motion';
import useModal from 'hooks/useModal';
import { useTranslation } from 'next-i18next';
import { RxHamburgerMenu } from 'react-icons/rx';
import BlockWrapper from './BlockWrapper/BlockWrapper';
import ButtonLink from './ButtonLink';
import Modal from './Modal/Modal';
import Categories from './SideMenu/Categories/Categories';
export default function MobileCategoriesModal() {
const { t } = useTranslation();
const mobileModal = useModal();
return (
<>
<ButtonLink
style={{
display: 'flex',
}}
onClick={mobileModal.open}
>
<RxHamburgerMenu
size={'1.5em'}
style={{ marginRight: '.5em' }}
/>
</ButtonLink>
<AnimatePresence>
{mobileModal.isShowing && (
<Modal close={mobileModal.close}>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<ButtonLink href={PATHS.CATEGORY.CREATE}>
{t('common:category.create')}
</ButtonLink>
<Categories />
</BlockWrapper>
</Modal>
)}
</AnimatePresence>
</>
);
}

View File

@@ -1,10 +1,9 @@
import LinkTag from 'next/link';
import { useSession } from 'next-auth/react';
import PATHS from 'constants/paths'; import PATHS from 'constants/paths';
import styles from './navbar.module.scss'; import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next'; import LinkTag from 'next/link';
import RoundedImage from '../RoundedImage/RoundedImage'; import RoundedImage from '../RoundedImage/RoundedImage';
import styles from './navbar.module.scss';
export default function Navbar() { export default function Navbar() {
const { data, status } = useSession(); const { data, status } = useSession();
@@ -12,7 +11,7 @@ export default function Navbar() {
const avatarLabel = t('common:avatar', { const avatarLabel = t('common:avatar', {
name: data?.user?.name, name: data?.user?.name,
} as TFunctionParam); });
return ( return (
<nav className={styles['navbar']}> <nav className={styles['navbar']}>

View File

@@ -1,9 +1,8 @@
import clsx from 'clsx';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
import RoundedImage from '../RoundedImage/RoundedImage'; import RoundedImage from '../RoundedImage/RoundedImage';
import styles from './profile.module.scss'; import styles from './profile.module.scss';
import clsx from 'clsx';
export default function Profile() { export default function Profile() {
const { data } = useSession(); const { data } = useSession();
@@ -11,7 +10,7 @@ export default function Profile() {
const avatarLabel = t('common:avatar', { const avatarLabel = t('common:avatar', {
name: data?.user?.name, name: data?.user?.name,
} as TFunctionParam); });
return ( return (
<ul className={clsx('reset', styles['profile'])}> <ul className={clsx('reset', styles['profile'])}>

View File

@@ -1,7 +1,7 @@
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link'; import LinkTag from 'next/link';
import { IoAddOutline } from 'react-icons/io5'; import { IoAddOutline } from 'react-icons/io5';
import { Category } from 'types'; import { CategoryWithLinks } from 'types';
import styles from './quickactions.module.scss'; import styles from './quickactions.module.scss';
export default function CreateItem({ export default function CreateItem({
@@ -10,7 +10,7 @@ export default function CreateItem({
onClick, onClick,
}: { }: {
type: 'category' | 'link'; type: 'category' | 'link';
categoryId?: Category['id']; categoryId?: CategoryWithLinks['id'];
onClick?: (event: any) => void; onClick?: (event: any) => void;
}) { }) {
const { t } = useTranslation('home'); const { t } = useTranslation('home');

View File

@@ -1,7 +1,7 @@
import LinkTag from 'next/link';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link';
import { AiOutlineEdit } from 'react-icons/ai'; import { AiOutlineEdit } from 'react-icons/ai';
import { Category, Link } from 'types'; import { CategoryWithLinks, LinkWithCategory } from 'types';
import styles from './quickactions.module.scss'; import styles from './quickactions.module.scss';
export default function EditItem({ export default function EditItem({
@@ -11,7 +11,7 @@ export default function EditItem({
className = '', className = '',
}: { }: {
type: 'category' | 'link'; type: 'category' | 'link';
id: Link['id'] | Category['id']; id: LinkWithCategory['id'] | CategoryWithLinks['id'];
onClick?: (event: any) => void; onClick?: (event: any) => void;
className?: string; className?: string;
}) { }) {

View File

@@ -1,18 +1,18 @@
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link'; import LinkTag from 'next/link';
import { CgTrashEmpty } from 'react-icons/cg'; import { CgTrashEmpty } from 'react-icons/cg';
import { Category, Link } from 'types'; import { CategoryWithLinks, LinkWithCategory } from 'types';
import styles from './quickactions.module.scss'; import styles from './quickactions.module.scss';
export default function RemoveItem({ export default function RemoveItem({
type, type,
id, id,
onClick, onClick,
}: { }: Readonly<{
type: 'category' | 'link'; type: 'category' | 'link';
id: Link['id'] | Category['id']; id: LinkWithCategory['id'] | CategoryWithLinks['id'];
onClick?: (event: any) => void; onClick?: (event: any) => void;
}) { }>) {
const { t } = useTranslation('home'); const { t } = useTranslation('home');
return ( return (

View File

@@ -1,18 +0,0 @@
import ButtonLink from 'components/ButtonLink';
import { BiSearchAlt } from 'react-icons/bi';
import styles from './quickactions.module.scss';
export default function QuickActionSearch({
openSearchModal,
}: {
openSearchModal: () => void;
}) {
return (
<ButtonLink
className={styles['action']}
onClick={openSearchModal}
>
<BiSearchAlt />
</ButtonLink>
);
}

View File

@@ -4,6 +4,8 @@ import { AiOutlineFolder } from 'react-icons/ai';
import LinkFavicon from 'components/Links/LinkFavicon'; import LinkFavicon from 'components/Links/LinkFavicon';
import { SearchItem } from 'types'; import { SearchItem } from 'types';
import useActiveCategory from 'hooks/useActiveCategory';
import useCategories from 'hooks/useCategories';
import { useEffect, useId, useRef } from 'react'; import { useEffect, useId, useRef } from 'react';
import styles from './search.module.scss'; import styles from './search.module.scss';
@@ -18,6 +20,8 @@ export default function SearchListItem({
}) { }) {
const id = useId(); const id = useId();
const ref = useRef<HTMLLIElement>(null); const ref = useRef<HTMLLIElement>(null);
const { categories } = useCategories();
const { setActiveCategory } = useActiveCategory();
const { name, type, url } = item; const { name, type, url } = item;
@@ -27,6 +31,15 @@ export default function SearchListItem({
} }
}, [selected]); }, [selected]);
const handleClick = (event) => {
if (item.type === 'category') {
event.preventDefault();
const category = categories.find((c) => c.id === item.id);
setActiveCategory(category);
}
closeModal();
};
return ( return (
<li <li
className={ className={
@@ -40,7 +53,7 @@ export default function SearchListItem({
href={url} href={url}
target='_blank' target='_blank'
rel='no-referrer' rel='no-referrer'
onClick={closeModal} onClick={handleClick}
> >
{type === 'link' ? ( {type === 'link' ? (
<LinkFavicon <LinkFavicon

View File

@@ -1,31 +1,97 @@
import ButtonLink from 'components/ButtonLink';
import Modal from 'components/Modal/Modal'; import Modal from 'components/Modal/Modal';
import TextBox from 'components/TextBox'; import TextBox from 'components/TextBox';
import * as Keys from 'constants/keys';
import PATHS from 'constants/paths';
import { GOOGLE_SEARCH_URL } from 'constants/search-urls'; import { GOOGLE_SEARCH_URL } from 'constants/search-urls';
import { AnimatePresence } from 'framer-motion';
import useActiveCategory from 'hooks/useActiveCategory';
import useAutoFocus from 'hooks/useAutoFocus'; import useAutoFocus from 'hooks/useAutoFocus';
import useCategories from 'hooks/useCategories';
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
import { useLocalStorage } from 'hooks/useLocalStorage'; import { useLocalStorage } from 'hooks/useLocalStorage';
import useModal from 'hooks/useModal';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { FormEvent, useCallback, useMemo, useState } from 'react'; import {
FormEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { BsSearch } from 'react-icons/bs'; import { BsSearch } from 'react-icons/bs';
import { Category, SearchItem } from 'types'; import { CategoryWithLinks, LinkWithCategory, SearchItem } from 'types';
import LabelSearchWithGoogle from './LabelSearchWithGoogle'; import LabelSearchWithGoogle from './LabelSearchWithGoogle';
import SearchList from './SearchList'; import SearchList from './SearchList';
import styles from './search.module.scss'; import styles from './search.module.scss';
export default function SearchModal({ export default function SearchModal({
close,
handleSelectCategory,
categories,
items,
noHeader = true, noHeader = true,
children,
childClassname = '',
disableHotkeys = false,
}: { }: {
close: () => void;
handleSelectCategory: (category: Category) => void;
categories: Category[];
items: SearchItem[];
noHeader?: boolean; noHeader?: boolean;
children: ReactNode;
childClassname?: string;
disableHotkeys?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const autoFocusRef = useAutoFocus(); const autoFocusRef = useAutoFocus();
const searchModal = useModal();
const { categories } = useCategories();
const { setActiveCategory } = useActiveCategory();
const { globalHotkeysEnabled, setGlobalHotkeysEnabled } = useGlobalHotkeys();
useEffect(
() => setGlobalHotkeysEnabled(!searchModal.isShowing),
[searchModal.isShowing, setGlobalHotkeysEnabled],
);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearch('');
}, [searchModal]);
useHotkeys(
Keys.OPEN_SEARCH_KEY,
(event) => {
event.preventDefault();
searchModal.open();
},
{ enabled: !disableHotkeys && globalHotkeysEnabled },
);
useHotkeys(Keys.CLOSE_SEARCH_KEY, handleCloseModal, {
enabled: searchModal.isShowing,
enableOnFormTags: ['INPUT'],
});
const searchItemBuilder = (
item: CategoryWithLinks | LinkWithCategory,
type: SearchItem['type'],
): SearchItem => ({
id: item.id,
name: item.name,
url:
type === 'link'
? (item as LinkWithCategory).url
: `${PATHS.HOME}?categoryId=${item.id}`,
type,
category: type === 'link' ? (item as LinkWithCategory).category : undefined,
});
const itemsSearch = useMemo<SearchItem[]>(() => {
return categories.reduce((acc, category) => {
const categoryItem = searchItemBuilder(category, 'category');
const items: SearchItem[] = category.links.map((link) =>
searchItemBuilder(link, 'link'),
);
return [...acc, ...items, categoryItem];
}, [] as SearchItem[]);
}, [categories]);
const [canSearchLink, setCanSearchLink] = useLocalStorage( const [canSearchLink, setCanSearchLink] = useLocalStorage(
'search-link', 'search-link',
@@ -37,7 +103,9 @@ export default function SearchModal({
); );
const [search, setSearch] = useState<string>(''); const [search, setSearch] = useState<string>('');
const [selectedItem, setSelectedItem] = useState<SearchItem>(items[0]); const [selectedItem, setSelectedItem] = useState<SearchItem | null>(
itemsSearch[0],
);
const canSubmit = useMemo<boolean>(() => search.length > 0, [search]); const canSubmit = useMemo<boolean>(() => search.length > 0, [search]);
@@ -46,7 +114,7 @@ export default function SearchModal({
() => () =>
search.length === 0 search.length === 0
? [] ? []
: items.filter( : itemsSearch.filter(
(item) => (item) =>
((item.type === 'category' && canSearchCategory) || ((item.type === 'category' && canSearchCategory) ||
(item.type === 'link' && canSearchLink)) && (item.type === 'link' && canSearchLink)) &&
@@ -54,14 +122,9 @@ export default function SearchModal({
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(search.toLocaleLowerCase().trim()), .includes(search.toLocaleLowerCase().trim()),
), ),
[canSearchCategory, canSearchLink, items, search], [canSearchCategory, canSearchLink, itemsSearch, search],
); );
const resetForm = useCallback(() => {
setSearch('');
close();
}, [close]);
const handleSearchInputChange = useCallback( const handleSearchInputChange = useCallback(
(value: string) => setSearch(value), (value: string) => setSearch(value),
[], [],
@@ -74,77 +137,91 @@ export default function SearchModal({
const handleSubmit = useCallback( const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => { (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
resetForm(); handleCloseModal();
if (itemsCompletion.length === 0) { if (itemsCompletion.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(search.trim())); return window.open(GOOGLE_SEARCH_URL + encodeURI(search.trim()));
} }
if (!selectedItem) return;
const category = categories.find((c) => c.id === selectedItem.id); const category = categories.find((c) => c.id === selectedItem.id);
if (selectedItem.type === 'category' && category) { if (selectedItem.type === 'category' && category) {
return handleSelectCategory(category); return setActiveCategory(category);
} }
window.open(selectedItem.url); window.open(selectedItem.url);
}, },
[ [
categories, categories,
handleSelectCategory, handleCloseModal,
itemsCompletion.length, itemsCompletion.length,
resetForm,
search, search,
selectedItem, selectedItem,
setActiveCategory,
], ],
); );
return ( return (
<Modal <>
close={close} <ButtonLink
noHeader={noHeader} className={childClassname}
padding={'0'} onClick={searchModal.open}
>
<form
onSubmit={handleSubmit}
className={styles['search-form']}
> >
<div className={styles['search-input-wrapper']}> {children}
<label htmlFor='search'> </ButtonLink>
<BsSearch size={24} /> <AnimatePresence>
</label> {searchModal.isShowing && (
<TextBox <Modal
name='search' close={handleCloseModal}
onChangeCallback={handleSearchInputChange} noHeader={noHeader}
value={search} padding={'0'}
placeholder={t('common:search')} >
innerRef={autoFocusRef} <form
fieldClass={styles['search-input-field']} onSubmit={handleSubmit}
inputClass={'reset'} className={styles['search-form']}
/> >
</div> <div className={styles['search-input-wrapper']}>
<SearchFilter <label htmlFor='search'>
canSearchLink={canSearchLink} <BsSearch size={24} />
setCanSearchLink={handleCanSearchLink} </label>
canSearchCategory={canSearchCategory} <TextBox
setCanSearchCategory={handleCanSearchCategory} name='search'
/> onChangeCallback={handleSearchInputChange}
{search.length > 0 && ( value={search}
<SearchList placeholder={t('common:search')}
items={itemsCompletion} innerRef={autoFocusRef}
selectedItem={selectedItem} fieldClass={styles['search-input-field']}
setSelectedItem={setSelectedItem} inputClass={'reset'}
noItem={<LabelSearchWithGoogle />} />
closeModal={close} </div>
/> <SearchFilter
canSearchLink={canSearchLink}
setCanSearchLink={handleCanSearchLink}
canSearchCategory={canSearchCategory}
setCanSearchCategory={handleCanSearchCategory}
/>
{search.length > 0 && (
<SearchList
items={itemsCompletion}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
noItem={<LabelSearchWithGoogle />}
closeModal={handleCloseModal}
/>
)}
<button
type='submit'
disabled={!canSubmit}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
)} )}
<button </AnimatePresence>
type='submit' </>
disabled={!canSubmit}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
); );
} }

View File

@@ -1,23 +1,32 @@
import { AnimatePresence } from 'framer-motion';
import useModal from 'hooks/useModal';
import Modal from '../Modal/Modal';
import * as Keys from 'constants/keys';
import { useHotkeys } from 'react-hotkeys-hook';
import { IoLogOutOutline, IoSettingsOutline } from 'react-icons/io5';
import LangSelector from '../LangSelector';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import { TfiWorld } from 'react-icons/tfi';
import { signOut } from 'next-auth/react';
import PATHS from 'constants/paths';
import styles from './settings-modal.module.scss';
import clsx from 'clsx'; import clsx from 'clsx';
import * as Keys from 'constants/keys';
import PATHS from 'constants/paths';
import { AnimatePresence } from 'framer-motion';
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
import useModal from 'hooks/useModal';
import { signOut } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { FaUser } from 'react-icons/fa'; import { FaUser } from 'react-icons/fa';
import { IoLogOutOutline, IoSettingsOutline } from 'react-icons/io5';
import { TfiWorld } from 'react-icons/tfi';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import LangSelector from '../LangSelector';
import Modal from '../Modal/Modal';
import Profile from '../Profile/Profile'; import Profile from '../Profile/Profile';
import styles from './settings-modal.module.scss';
export default function SettingsModal() { export default function SettingsModal() {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const modal = useModal(); const modal = useModal();
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
useEffect(
() => setGlobalHotkeysEnabled(!modal.isShowing),
[modal.isShowing, setGlobalHotkeysEnabled],
);
useHotkeys(Keys.CLOSE_SEARCH_KEY, modal.close, { useHotkeys(Keys.CLOSE_SEARCH_KEY, modal.close, {
enabled: modal.isShowing, enabled: modal.isShowing,
enableOnFormTags: ['INPUT'], enableOnFormTags: ['INPUT'],

View File

@@ -1,43 +1,85 @@
import clsx from 'clsx';
import * as Keys from 'constants/keys';
import useActiveCategory from 'hooks/useActiveCategory';
import useCategories from 'hooks/useCategories';
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Category } from 'types'; import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { useHotkeys } from 'react-hotkeys-hook';
import CategoryItem from './CategoryItem'; import CategoryItem from './CategoryItem';
import styles from './categories.module.scss'; import styles from './categories.module.scss';
import clsx from 'clsx';
interface CategoriesProps { export default function Categories() {
categories: Category[];
categoryActive: Category;
handleSelectCategory: (category: Category) => void;
}
export default function Categories({
categories,
categoryActive,
handleSelectCategory,
}: CategoriesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { categories } = useCategories();
const { activeCategory, setActiveCategory } = useActiveCategory();
const { globalHotkeysEnabled } = useGlobalHotkeys();
const linksCount = useMemo( const linksCount = useMemo(
() => categories.reduce((acc, current) => (acc += current.links.length), 0), () => categories.reduce((acc, current) => (acc += current.links.length), 0),
[categories], [categories],
); );
useHotkeys(
Keys.ARROW_UP,
() => {
const currentCategoryIndex = categories.findIndex(
({ id }) => id === activeCategory.id,
);
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
setActiveCategory(categories[currentCategoryIndex - 1]);
},
{ enabled: globalHotkeysEnabled },
);
useHotkeys(
Keys.ARROW_DOWN,
() => {
const currentCategoryIndex = categories.findIndex(
({ id }) => id === activeCategory.id,
);
if (
currentCategoryIndex === -1 ||
currentCategoryIndex === categories.length - 1
)
return;
setActiveCategory(categories[currentCategoryIndex + 1]);
},
{ enabled: globalHotkeysEnabled },
);
return ( return (
<div className={styles['categories']}> <div className={styles['categories']}>
<h4> <h4>
{t('common:category.categories')} {linksCount} {t('common:category.categories')} {linksCount}
</h4> </h4>
<ul className={clsx(styles['items'], 'reset')}> <DndProvider backend={HTML5Backend}>
{categories.map((category, index) => ( <ListCategories />
<CategoryItem </DndProvider>
category={category}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
key={category.id}
index={index}
/>
))}
</ul>
</div> </div>
); );
} }
function ListCategories() {
const [, drop] = useDrop(() => ({ accept: 'category' }));
const { categories } = useCategories();
return (
<ul
className={clsx(styles['items'], 'reset')}
ref={drop}
>
{categories.map((category, index) => (
<CategoryItem
category={category}
key={category.id}
index={index}
/>
))}
</ul>
);
}

View File

@@ -1,38 +1,134 @@
import clsx from 'clsx';
import PATHS from 'constants/paths';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react'; import useActiveCategory from 'hooks/useActiveCategory';
import useCategories from 'hooks/useCategories';
import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
import { makeRequest } from 'lib/request';
import { useCallback, useEffect, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai'; import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import { Category } from 'types'; import { CategoryWithLinks } from 'types';
import { arrayMove } from 'utils/array';
import styles from './categories.module.scss'; import styles from './categories.module.scss';
interface CategoryItemProps { interface CategoryItemProps {
category: Category; category: CategoryWithLinks;
categoryActive: Category;
handleSelectCategory: (category: Category) => void;
index: number; index: number;
} }
type CategoryDragItem = {
categoryId: CategoryWithLinks['id'];
index: number;
};
export default function CategoryItem({ export default function CategoryItem({
category, category,
categoryActive,
handleSelectCategory,
index, index,
}: CategoryItemProps): JSX.Element { }: Readonly<CategoryItemProps>): JSX.Element {
const { activeCategory, setActiveCategory } = useActiveCategory();
const { categories, setCategories } = useCategories();
const ref = useRef<HTMLLIElement>(); const ref = useRef<HTMLLIElement>();
const className = `${styles['item']} ${
category.id === categoryActive.id ? styles['active'] : '' const sendMoveCategoryRequest = useCallback(
}`; async (category: CategoryWithLinks, nextId?: number) => {
const onClick = () => handleSelectCategory(category); if (category.id === nextId) return;
await makeRequest({
url: `${PATHS.API.CATEGORY}/${category.id}`,
method: 'PUT',
body: {
name: category.name,
nextId,
},
});
setCategories((prevCategories) => {
const categories = [...prevCategories];
const categoryIndex = categories.findIndex((c) => c.id === category.id);
const previousCategoryIndex = categories.findIndex(
(c) => c.nextId === category.id,
);
const prevNextCategoryIndex = categories.findIndex(
(c) => c.nextId === nextId,
);
categories[categoryIndex] = {
...categories[categoryIndex],
nextId,
};
if (previousCategoryIndex !== -1) {
categories[previousCategoryIndex] = {
...categories[previousCategoryIndex],
nextId: category.nextId,
};
}
if (prevNextCategoryIndex !== -1) {
categories[prevNextCategoryIndex] = {
...categories[prevNextCategoryIndex],
nextId: category.id,
};
}
return sortCategoriesByNextId(categories);
});
},
[setCategories],
);
const moveCategory = useCallback(
(currentIndex: number, newIndex: number) => {
setCategories((prevCategories: CategoryWithLinks[]) =>
arrayMove(prevCategories, currentIndex, newIndex),
);
},
[setCategories],
);
const [_, drop] = useDrop({
accept: 'category',
hover: (dragItem: CategoryDragItem) => {
if (ref.current && dragItem.categoryId !== category.id) {
moveCategory(dragItem.index, index);
dragItem.index = index;
}
},
drop: (item) => {
const category = categories.find((c) => c.id === item.categoryId);
const nextCategory = categories[item.index + 1];
if (category.nextId === null && nextCategory?.id === undefined) return;
if (category.nextId !== nextCategory?.id) {
sendMoveCategoryRequest(category, nextCategory?.id ?? null);
}
},
});
const [{ opacity }, drag] = useDrag({
type: 'category',
item: () => ({ index, categoryId: category.id }),
collect: (monitor: any) => ({
opacity: monitor.isDragging() ? 0.1 : 1,
}),
end: (dragItem: CategoryDragItem, monitor) => {
const didDrop = monitor.didDrop();
if (!didDrop) {
moveCategory(dragItem.index, index);
}
},
});
useEffect(() => { useEffect(() => {
if (category.id === categoryActive.id) { if (category.id === activeCategory.id) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}, [category.id, categoryActive.id]); }, [category.id, activeCategory.id]);
drag(drop(ref));
return ( return (
<motion.li <motion.li
initial={{ opacity: 0, scale: 0 }} initial={{ scale: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ scale: 1 }}
transition={{ transition={{
type: 'spring', type: 'spring',
stiffness: 260, stiffness: 260,
@@ -40,18 +136,19 @@ export default function CategoryItem({
delay: index * 0.02, delay: index * 0.02,
duration: 200, duration: 200,
}} }}
className={className} className={clsx(
ref={ref} styles['item'],
onClick={onClick} category.id === activeCategory.id && styles['active'],
)}
style={{ style={{
display: 'flex',
alignItems: 'center',
gap: '.25em',
transition: 'none', transition: 'none',
opacity,
}} }}
onClick={() => setActiveCategory(category)}
title={category.name} title={category.name}
ref={ref}
> >
{category.id === categoryActive.id ? ( {category.id === activeCategory.id ? (
<AiFillFolderOpen size={24} /> <AiFillFolderOpen size={24} />
) : ( ) : (
<AiOutlineFolder size={24} /> <AiOutlineFolder size={24} />

View File

@@ -18,8 +18,10 @@
.item { .item {
border-bottom: 2px solid transparent !important; border-bottom: 2px solid transparent !important;
display: flex; display: flex;
gap: 0.25em;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
transition: 0.15s box-shadow;
&.active { &.active {
color: $blue; color: $blue;
@@ -82,6 +84,7 @@
} }
} }
} }
&:hover .menu-item { &:hover .menu-item {
display: flex; display: flex;
} }

View File

@@ -1,11 +1,13 @@
import LinkTag from 'next/link';
import LinkFavicon from 'components/Links/LinkFavicon'; import LinkFavicon from 'components/Links/LinkFavicon';
import { Link } from 'types'; import LinkTag from 'next/link';
import { LinkWithCategory } from 'types';
import styles from './favorites.module.scss'; import styles from './favorites.module.scss';
export default function FavoriteItem({ link }: { link: Link }): JSX.Element { export default function FavoriteItem({
link,
}: Readonly<{
link: LinkWithCategory;
}>): JSX.Element {
const { name, url, category } = link; const { name, url, category } = link;
return ( return (
<li className={styles['item']}> <li className={styles['item']}>

View File

@@ -1,11 +1,12 @@
import clsx from 'clsx';
import useFavorites from 'hooks/useFavorites';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { Link } from 'types';
import FavoriteItem from './FavoriteItem'; import FavoriteItem from './FavoriteItem';
import styles from './favorites.module.scss'; import styles from './favorites.module.scss';
import clsx from 'clsx';
export default function Favorites({ favorites }: { favorites: Link[] }) { export default function Favorites() {
const { t } = useTranslation(); const { t } = useTranslation();
const { favorites } = useFavorites();
return ( return (
favorites.length !== 0 && ( favorites.length !== 0 && (

View File

@@ -1,22 +1,18 @@
import ButtonLink from 'components/ButtonLink'; import ButtonLink from 'components/ButtonLink';
import SearchModal from 'components/SearchModal/SearchModal';
import PATHS from 'constants/paths'; import PATHS from 'constants/paths';
import useActiveCategory from 'hooks/useActiveCategory';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { SideMenuProps } from './SideMenu';
import styles from './sidemenu.module.scss'; import styles from './sidemenu.module.scss';
export default function NavigationLinks({ export default function NavigationLinks() {
categoryActive,
openSearchModal,
}: {
categoryActive: SideMenuProps['categoryActive'];
openSearchModal: SideMenuProps['openSearchModal'];
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeCategory } = useActiveCategory();
return ( return (
<div className={styles['menu-controls']}> <div className={styles['menu-controls']}>
<div className={styles['action']}> <div className={styles['action']}>
<ButtonLink onClick={openSearchModal}>{t('common:search')}</ButtonLink> <SearchModal disableHotkeys>{t('common:search')}</SearchModal>
<kbd>S</kbd> <kbd>S</kbd>
</div> </div>
<div className={styles['action']}> <div className={styles['action']}>
@@ -27,7 +23,7 @@ export default function NavigationLinks({
</div> </div>
<div className={styles['action']}> <div className={styles['action']}>
<ButtonLink <ButtonLink
href={`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`} href={`${PATHS.LINK.CREATE}?categoryId=${activeCategory.id}`}
> >
{t('common:link.create')} {t('common:link.create')}
</ButtonLink> </ButtonLink>

View File

@@ -1,77 +1,21 @@
import BlockWrapper from 'components/BlockWrapper/BlockWrapper'; import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
import * as Keys from 'constants/keys';
import { useHotkeys } from 'react-hotkeys-hook';
import { Category, Link } from 'types';
import Categories from './Categories/Categories'; import Categories from './Categories/Categories';
import Favorites from './Favorites/Favorites'; import Favorites from './Favorites/Favorites';
import NavigationLinks from './NavigationLinks'; import NavigationLinks from './NavigationLinks';
import UserCard from './UserCard/UserCard'; import UserCard from './UserCard/UserCard';
import styles from './sidemenu.module.scss'; import styles from './sidemenu.module.scss';
export interface SideMenuProps { export default function SideMenu() {
categories: Category[];
favorites: Link[];
handleSelectCategory: (category: Category) => void;
categoryActive: Category;
openSearchModal: () => void;
isModalShowing: boolean;
}
export default function SideMenu({
categories,
favorites,
handleSelectCategory,
categoryActive,
openSearchModal,
isModalShowing = false,
}: SideMenuProps) {
useHotkeys(
Keys.ARROW_UP,
() => {
const currentCategoryIndex = categories.findIndex(
({ id }) => id === categoryActive.id,
);
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
handleSelectCategory(categories[currentCategoryIndex - 1]);
},
{ enabled: !isModalShowing },
);
useHotkeys(
Keys.ARROW_DOWN,
() => {
const currentCategoryIndex = categories.findIndex(
({ id }) => id === categoryActive.id,
);
if (
currentCategoryIndex === -1 ||
currentCategoryIndex === categories.length - 1
)
return;
handleSelectCategory(categories[currentCategoryIndex + 1]);
},
{ enabled: !isModalShowing },
);
return ( return (
<div className={styles['side-menu']}> <div className={styles['side-menu']}>
<BlockWrapper> <BlockWrapper>
<Favorites favorites={favorites} /> <Favorites />
</BlockWrapper> </BlockWrapper>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}> <BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<Categories <Categories />
categories={categories}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
/>
</BlockWrapper> </BlockWrapper>
<BlockWrapper> <BlockWrapper>
<NavigationLinks <NavigationLinks />
categoryActive={categoryActive}
openSearchModal={openSearchModal}
/>
</BlockWrapper> </BlockWrapper>
<BlockWrapper> <BlockWrapper>
<UserCard /> <UserCard />

View File

@@ -1,9 +1,8 @@
import SettingsModal from 'components/Settings/SettingsModal';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Image from 'next/image'; import Image from 'next/image';
import { TFunctionParam } from 'types/i18next';
import styles from './user-card.module.scss'; import styles from './user-card.module.scss';
import SettingsModal from 'components/Settings/SettingsModal';
export default function UserCard() { export default function UserCard() {
const { data } = useSession({ required: true }); const { data } = useSession({ required: true });
@@ -11,7 +10,7 @@ export default function UserCard() {
const avatarLabel = t('common:avatar', { const avatarLabel = t('common:avatar', {
name: data.user.name, name: data.user.name,
} as TFunctionParam); });
return ( return (
<div className={styles['user-card-wrapper']}> <div className={styles['user-card-wrapper']}>
<div className={styles['user-card']}> <div className={styles['user-card']}>

View File

@@ -0,0 +1,18 @@
import { Dispatch, SetStateAction, createContext } from 'react';
import { CategoryWithLinks } from 'types/types';
type ActiveCategoryContextType = {
activeCategory: CategoryWithLinks | null;
setActiveCategory: Dispatch<SetStateAction<CategoryWithLinks>>;
};
const iActiveCategoryContextState = {
activeCategory: null,
setActiveCategory: (_: CategoryWithLinks) => {},
};
const ActiveCategoryContext = createContext<ActiveCategoryContextType>(
iActiveCategoryContextState,
);
export default ActiveCategoryContext;

View File

@@ -0,0 +1,18 @@
import { Dispatch, SetStateAction, createContext } from 'react';
import { CategoryWithLinks } from 'types/types';
type CategoriesContextType = {
categories: CategoryWithLinks[];
setCategories: Dispatch<SetStateAction<CategoryWithLinks[]>>;
};
const iCategoriesContextState = {
categories: [] as CategoryWithLinks[],
setCategories: (_: CategoryWithLinks[]) => {},
};
const CategoriesContext = createContext<CategoriesContextType>(
iCategoriesContextState,
);
export default CategoriesContext;

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import ActiveCategoryContext from 'contexts/activeCategoryContext';
import { useContext } from 'react';
export default function useActiveCategory() {
return useContext(ActiveCategoryContext);
}

View File

@@ -0,0 +1,6 @@
import CategoriesContext from 'contexts/categoriesContext';
import { useContext } from 'react';
export default function useCategories() {
return useContext(CategoriesContext);
}

View File

@@ -0,0 +1,6 @@
import FavoritesContext from 'contexts/favoritesContext';
import { useContext } from 'react';
export default function useFavorites() {
return useContext(FavoritesContext);
}

View File

@@ -0,0 +1,6 @@
import GlobalHotkeysContext from 'contexts/globalHotkeysContext';
import { useContext } from 'react';
export default function useGlobalHotkeys() {
return useContext(GlobalHotkeysContext);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMediaMatches(query)); const [matches, setMatches] = useState<boolean>(false);
const handleMediaChange = () => setMatches(getMediaMatches(query)); const handleMediaChange = () => setMatches(getMediaMatches(query));

View File

@@ -3,8 +3,9 @@ import { number, object, string } from 'yup';
const CategoryBodySchema = object({ const CategoryBodySchema = object({
name: string() name: string()
.trim() .trim()
.required("Category name's required") .required('Category name is required')
.max(128, "Category name's too long"), .max(128, 'Category name is too long'),
nextId: number().required().nullable(),
}).typeError('Missing request Body'); }).typeError('Missing request Body');
const CategoryQuerySchema = object({ const CategoryQuerySchema = object({

View File

@@ -1,8 +1,9 @@
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { CategoryWithLinks } from 'types/types';
import prisma from 'utils/prisma'; import prisma from 'utils/prisma';
export default async function getUserCategories(user: User) { export default async function getUserCategories(user: User) {
return await prisma.category.findMany({ return (await prisma.category.findMany({
where: { where: {
authorId: user?.id, authorId: user?.id,
}, },
@@ -16,5 +17,5 @@ export default async function getUserCategories(user: User) {
}, },
}, },
}, },
}); })) as CategoryWithLinks[];
} }

View File

@@ -0,0 +1,30 @@
import { CategoryWithLinks } from 'types/types';
type Category = CategoryWithLinks;
export default function sortCategoriesByNextId(
categories: Category[],
): Category[] {
const sortedCategories: Category[] = [];
const visit = (category: Category) => {
// Check if the category has been visited
if (sortedCategories.includes(category)) {
return;
}
// Visit the next category recursively
const nextCategory = categories.find((c) => c.id === category.nextId);
if (nextCategory) {
visit(nextCategory);
}
// Add the current category to the sorted array
sortedCategories.push(category);
};
// Visit each category to build the sorted array
categories.forEach((category) => visit(category));
return sortedCategories.reverse();
}

View File

@@ -1,3 +1,4 @@
import { User } from '@prisma/client';
import { apiHandler } from 'lib/api/handler'; import { apiHandler } from 'lib/api/handler';
import { import {
CategoryBodySchema, CategoryBodySchema,
@@ -15,7 +16,8 @@ export default apiHandler({
async function editCategory({ req, res, user }) { async function editCategory({ req, res, user }) {
const { cid } = await CategoryQuerySchema.validate(req.query); const { cid } = await CategoryQuerySchema.validate(req.query);
const { name } = await CategoryBodySchema.validate(req.body); const { name, nextId } = await CategoryBodySchema.validate(req.body);
const userId = user.id as User['id'];
const category = await getUserCategory(user, cid); const category = await getUserCategory(user, cid);
if (!category) { if (!category) {
@@ -23,18 +25,79 @@ async function editCategory({ req, res, user }) {
} }
const isCategoryNameAlreadyUsed = await getUserCategoryByName(user, name); const isCategoryNameAlreadyUsed = await getUserCategoryByName(user, name);
if (isCategoryNameAlreadyUsed) { if (isCategoryNameAlreadyUsed.id !== cid) {
throw new Error('Category name already used'); throw new Error('Category name already used');
} }
if (category.name === name) { if (category.id === nextId) {
throw new Error('New category name must be different'); throw new Error('Category nextId cannot be equal to current category ID');
}
if (nextId !== null) {
const isCategoryIdExist = await prisma.category.findFirst({
where: {
authorId: userId,
id: nextId,
},
});
if (!isCategoryIdExist) {
throw new Error('Unable to find category ' + nextId);
}
}
if (category.nextId !== nextId) {
const [previousCategory, prevNextCategory] = await prisma.$transaction([
prisma.category.findFirst({
// Current previous category
where: {
authorId: userId,
nextId: category.id,
},
}),
prisma.category.findFirst({
// New previous category
where: {
authorId: userId,
nextId,
},
}),
]);
await prisma.$transaction(
[
previousCategory &&
prisma.category.update({
where: {
authorId: userId,
id: previousCategory.id,
},
data: {
nextId: category.nextId,
},
}),
prisma.category.update({
where: {
authorId: userId,
id: category.id,
},
data: {
nextId,
},
}),
prevNextCategory &&
prisma.category.update({
where: {
authorId: userId,
id: prevNextCategory.id,
},
data: {
nextId: category.id,
},
}),
].filter((a) => a !== null && a !== undefined),
);
} }
await prisma.category.update({
where: { id: cid },
data: { name },
});
return res.send({ return res.send({
success: 'Category successfully updated', success: 'Category successfully updated',
categoryId: category.id, categoryId: category.id,
@@ -56,6 +119,19 @@ async function deleteCategory({ req, res, user }) {
await prisma.category.delete({ await prisma.category.delete({
where: { id: cid }, where: { id: cid },
}); });
const { id: previousCategoryId } = await prisma.category.findFirst({
where: { nextId: category.id },
select: { id: true },
});
await prisma.category.update({
where: {
id: previousCategoryId,
},
data: {
nextId: category.nextId,
},
});
return res.send({ return res.send({
success: 'Category successfully deleted', success: 'Category successfully deleted',
categoryId: category.id, categoryId: category.id,

View File

@@ -25,9 +25,29 @@ async function createCategory({ req, res, user }) {
throw new Error('Category name already used'); throw new Error('Category name already used');
} }
const { id: lastCategoryId } = await prisma.category.findFirst({
where: {
authorId: user.id,
nextId: null,
},
select: {
id: true,
},
});
const categoryCreated = await prisma.category.create({ const categoryCreated = await prisma.category.create({
data: { name, authorId: user.id }, data: { name, authorId: user.id },
}); });
await prisma.category.update({
where: {
id: lastCategoryId,
},
data: {
nextId: categoryCreated.id,
},
});
return res.status(200).send({ return res.status(200).send({
success: 'Category successfully created', success: 'Category successfully created',
categoryId: categoryCreated.id, categoryId: categoryCreated.id,

View File

@@ -5,12 +5,12 @@ import PATHS from 'constants/paths';
import useAutoFocus from 'hooks/useAutoFocus'; import useAutoFocus from 'hooks/useAutoFocus';
import { getServerSideTranslation } from 'i18n'; import { getServerSideTranslation } from 'i18n';
import getUserCategoriesCount from 'lib/category/getUserCategoriesCount'; import getUserCategoriesCount from 'lib/category/getUserCategoriesCount';
import { makeRequest } from 'lib/request';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
import { makeRequest } from 'lib/request';
export default function PageCreateCategory({ export default function PageCreateCategory({
categoriesCount, categoriesCount,
@@ -40,7 +40,7 @@ export default function PageCreateCategory({
makeRequest({ makeRequest({
url: PATHS.API.CATEGORY, url: PATHS.API.CATEGORY,
method: 'POST', method: 'POST',
body: { name }, body: { name, nextId: null },
}) })
.then((data) => .then((data) =>
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`), router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`),

View File

@@ -5,15 +5,19 @@ import PATHS from 'constants/paths';
import useAutoFocus from 'hooks/useAutoFocus'; import useAutoFocus from 'hooks/useAutoFocus';
import { getServerSideTranslation } from 'i18n'; import { getServerSideTranslation } from 'i18n';
import getUserCategory from 'lib/category/getUserCategory'; import getUserCategory from 'lib/category/getUserCategory';
import { makeRequest } from 'lib/request';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { Category } from 'types'; import { CategoryWithLinks } from 'types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
import { makeRequest } from 'lib/request';
export default function PageEditCategory({ category }: { category: Category }) { export default function PageEditCategory({
category,
}: Readonly<{
category: CategoryWithLinks;
}>) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const autoFocusRef = useAutoFocus(); const autoFocusRef = useAutoFocus();

View File

@@ -5,18 +5,18 @@ import TextBox from 'components/TextBox';
import PATHS from 'constants/paths'; import PATHS from 'constants/paths';
import { getServerSideTranslation } from 'i18n'; import { getServerSideTranslation } from 'i18n';
import getUserCategory from 'lib/category/getUserCategory'; import getUserCategory from 'lib/category/getUserCategory';
import { makeRequest } from 'lib/request';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useEffect, useMemo, useState } from 'react'; import { FormEvent, useEffect, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { Category } from 'types'; import { CategoryWithLinks } from 'types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
import { makeRequest } from 'lib/request';
export default function PageRemoveCategory({ export default function PageRemoveCategory({
category, category,
}: Readonly<{ }: Readonly<{
category: Category; category: CategoryWithLinks;
}>) { }>) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();

View File

@@ -1,192 +1,94 @@
import BlockWrapper from 'components/BlockWrapper/BlockWrapper'; import clsx from 'clsx';
import ButtonLink from 'components/ButtonLink';
import Links from 'components/Links/Links'; import Links from 'components/Links/Links';
import Modal from 'components/Modal/Modal';
import PageTransition from 'components/PageTransition'; import PageTransition from 'components/PageTransition';
import SearchModal from 'components/SearchModal/SearchModal';
import Categories from 'components/SideMenu/Categories/Categories';
import SideMenu from 'components/SideMenu/SideMenu'; import SideMenu from 'components/SideMenu/SideMenu';
import UserCard from 'components/SideMenu/UserCard/UserCard'; import UserCard from 'components/SideMenu/UserCard/UserCard';
import * as Keys from 'constants/keys'; import * as Keys from 'constants/keys';
import PATHS from 'constants/paths'; import PATHS from 'constants/paths';
import { AnimatePresence } from 'framer-motion'; import ActiveCategoryContext from 'contexts/activeCategoryContext';
import CategoriesContext from 'contexts/categoriesContext';
import FavoritesContext from 'contexts/favoritesContext';
import GlobalHotkeysContext from 'contexts/globalHotkeysContext';
import { useMediaQuery } from 'hooks/useMediaQuery'; import { useMediaQuery } from 'hooks/useMediaQuery';
import useModal from 'hooks/useModal';
import { getServerSideTranslation } from 'i18n'; import { getServerSideTranslation } from 'i18n';
import getUserCategories from 'lib/category/getUserCategories'; import getUserCategories from 'lib/category/getUserCategories';
import { useTranslation } from 'next-i18next'; import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { Category, Link, SearchItem } from 'types'; import { CategoryWithLinks, LinkWithCategory } from 'types/types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
import clsx from 'clsx';
interface HomePageProps { interface HomePageProps {
categories: Category[]; categories: CategoryWithLinks[];
currentCategory: Category | undefined; activeCategory: CategoryWithLinks | undefined;
} }
export default function HomePage(props: HomePageProps) { export default function HomePage(props: Readonly<HomePageProps>) {
const router = useRouter(); const router = useRouter();
const searchModal = useModal();
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const mobileModal = useModal();
const [categories, setCategories] = useState<Category[]>(props.categories); const [globalHotkeysEnable, setGlobalHotkeysEnabled] =
const [categoryActive, setCategoryActive] = useState<Category | null>( useState<boolean>(true);
props.currentCategory || categories?.[0], const [categories, setCategories] = useState<CategoryWithLinks[]>(
props.categories,
); );
const [activeCategory, setActiveCategory] =
useState<CategoryWithLinks | null>(props.activeCategory || categories?.[0]);
const favorites = useMemo<Link[]>( const handleChangeCategory = (category: CategoryWithLinks) => {
setActiveCategory(category);
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
};
const favorites = useMemo<LinkWithCategory[]>(
() => () =>
categories.reduce((acc, category) => { categories.reduce((acc, category) => {
category.links.forEach((link) => category.links.forEach((link) =>
link.favorite ? acc.push(link) : null, link.favorite ? acc.push(link) : null,
); );
return acc; return acc;
}, [] as Link[]), }, [] as LinkWithCategory[]),
[categories], [categories],
); );
const searchItemBuilder = (
item: Category | Link,
type: SearchItem['type'],
): SearchItem => ({
id: item.id,
name: item.name,
url:
type === 'link'
? (item as Link).url
: `${PATHS.HOME}?categoryId=${item.id}`,
type,
category: type === 'link' ? (item as Link).category : undefined,
});
const itemsSearch = useMemo<SearchItem[]>(() => {
return categories.reduce((acc, category) => {
const categoryItem = searchItemBuilder(category, 'category');
const items: SearchItem[] = category.links.map((link) =>
searchItemBuilder(link, 'link'),
);
return [...acc, ...items, categoryItem];
}, [] as SearchItem[]);
}, [categories]);
// TODO: refactor
const toggleFavorite = useCallback(
(linkId: Link['id']) => {
let linkIndex = 0;
const categoryIndex = categories.findIndex(({ links }) => {
const lIndex = links.findIndex((l) => l.id === linkId);
if (lIndex !== -1) {
linkIndex = lIndex;
}
return lIndex !== -1;
});
const link = categories[categoryIndex].links[linkIndex];
const categoriesCopy = [...categories];
categoriesCopy[categoryIndex].links[linkIndex] = {
...link,
favorite: !link.favorite,
};
setCategories(categoriesCopy);
if (categories[categoryIndex].id === categoryActive.id) {
setCategoryActive(categories[categoryIndex]);
}
},
[categories, categoryActive.id],
);
const handleSelectCategory = (category: Category) => {
setCategoryActive(category);
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
mobileModal.close();
};
const areHotkeysEnabled = { enabled: !searchModal.isShowing };
useHotkeys(
Keys.OPEN_SEARCH_KEY,
(event) => {
event.preventDefault();
searchModal.open();
},
areHotkeysEnabled,
);
useHotkeys(Keys.CLOSE_SEARCH_KEY, searchModal.close, {
enabled: searchModal.isShowing,
enableOnFormTags: ['INPUT'],
});
useHotkeys( useHotkeys(
Keys.OPEN_CREATE_LINK_KEY, Keys.OPEN_CREATE_LINK_KEY,
() => { () => {
router.push(`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`); router.push(`${PATHS.LINK.CREATE}?categoryId=${activeCategory.id}`);
}, },
areHotkeysEnabled, { enabled: globalHotkeysEnable },
); );
useHotkeys( useHotkeys(
Keys.OPEN_CREATE_CATEGORY_KEY, Keys.OPEN_CREATE_CATEGORY_KEY,
() => { () => {
router.push('/category/create'); router.push(PATHS.CATEGORY.CREATE);
}, },
areHotkeysEnabled, { enabled: globalHotkeysEnable },
); );
return ( return (
<PageTransition <PageTransition
className={clsx('App', 'flex-row')} className={clsx('App', 'flex-row')}
style={{ flexDirection: 'row' }}
hideLangageSelector hideLangageSelector
> >
{isMobile ? ( <CategoriesContext.Provider value={{ categories, setCategories }}>
<UserCard /> <ActiveCategoryContext.Provider
) : ( value={{ activeCategory, setActiveCategory: handleChangeCategory }}
<SideMenu >
categories={categories} <FavoritesContext.Provider value={{ favorites }}>
favorites={favorites} <GlobalHotkeysContext.Provider
handleSelectCategory={handleSelectCategory} value={{
categoryActive={categoryActive} globalHotkeysEnabled: globalHotkeysEnable,
openSearchModal={searchModal.open} setGlobalHotkeysEnabled,
isModalShowing={searchModal.isShowing} }}
/> >
)} {isMobile ? <UserCard /> : <SideMenu />}
<Links <Links isMobile={isMobile} />
category={categoryActive} </GlobalHotkeysContext.Provider>
toggleFavorite={toggleFavorite} </FavoritesContext.Provider>
isMobile={isMobile} </ActiveCategoryContext.Provider>
openMobileModal={mobileModal.open} </CategoriesContext.Provider>
openSearchModal={searchModal.open}
/>
<AnimatePresence>
{searchModal.isShowing && (
<SearchModal
close={searchModal.close}
categories={categories}
items={itemsSearch}
handleSelectCategory={handleSelectCategory}
noHeader={!isMobile}
/>
)}
{mobileModal.isShowing && (
<Modal close={mobileModal.close}>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<ButtonLink href={PATHS.CATEGORY.CREATE}>
{t('common:category.create')}
</ButtonLink>
<Categories
categories={categories}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
/>
</BlockWrapper>
</Modal>
)}
</AnimatePresence>
</PageTransition> </PageTransition>
); );
} }
@@ -204,15 +106,17 @@ export const getServerSideProps = withAuthentication(
}; };
} }
const currentCategory = categories.find( const activeCategory = categories.find(
({ id }) => id === Number(queryCategoryId), ({ id }) => id === Number(queryCategoryId),
); );
return { return {
props: { props: {
session, session,
categories: JSON.parse(JSON.stringify(categories)), categories: JSON.parse(
currentCategory: currentCategory JSON.stringify(sortCategoriesByNextId(categories)),
? JSON.parse(JSON.stringify(currentCategory)) ),
activeCategory: activeCategory
? JSON.parse(JSON.stringify(activeCategory))
: null, : null,
...(await getServerSideTranslation(locale, ['home'])), ...(await getServerSideTranslation(locale, ['home'])),
}, },

View File

@@ -13,25 +13,25 @@ import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { Category, Link } from 'types'; import { CategoryWithLinks, LinkWithCategory } from 'types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
export default function PageCreateLink({ export default function PageCreateLink({
categories, categories,
}: Readonly<{ }: Readonly<{
categories: Category[]; categories: CategoryWithLinks[];
}>) { }>) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const autoFocusRef = useAutoFocus(); const autoFocusRef = useAutoFocus();
const categoryIdQuery = router.query?.categoryId as string; const categoryIdQuery = router.query?.categoryId as string;
const [name, setName] = useState<Link['name']>(''); const [name, setName] = useState<LinkWithCategory['name']>('');
const [url, setUrl] = useState<Link['url']>(''); const [url, setUrl] = useState<LinkWithCategory['url']>('');
const [favorite, setFavorite] = useState<Link['favorite']>(false); const [favorite, setFavorite] = useState<LinkWithCategory['favorite']>(false);
const [categoryId, setCategoryId] = useState<Link['category']['id']>( const [categoryId, setCategoryId] = useState<
Number(categoryIdQuery) || categories?.[0].id || null, LinkWithCategory['category']['id']
); >(Number(categoryIdQuery) || categories?.[0].id || null);
const [error, setError] = useState<string>(null); const [error, setError] = useState<string>(null);
const [submitted, setSubmitted] = useState<boolean>(false); const [submitted, setSubmitted] = useState<boolean>(false);

View File

@@ -14,15 +14,15 @@ import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { Category, Link } from 'types'; import { CategoryWithLinks, LinkWithCategory } from 'types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
export default function PageEditLink({ export default function PageEditLink({
link, link,
categories, categories,
}: Readonly<{ }: Readonly<{
link: Link; link: LinkWithCategory;
categories: Category[]; categories: CategoryWithLinks[];
}>) { }>) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();

View File

@@ -10,10 +10,12 @@ import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss'; import styles from 'styles/form.module.scss';
import { Link } from 'types'; import { LinkWithCategory } from 'types';
import { withAuthentication } from 'utils/session'; import { withAuthentication } from 'utils/session';
export default function PageRemoveLink({ link }: Readonly<{ link: Link }>) { export default function PageRemoveLink({
link,
}: Readonly<{ link: LinkWithCategory }>) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();

View File

@@ -3,7 +3,6 @@ import LangSelector from 'components/LangSelector';
import MessageManager from 'components/MessageManager/MessageManager'; import MessageManager from 'components/MessageManager/MessageManager';
import PageTransition from 'components/PageTransition'; import PageTransition from 'components/PageTransition';
import PATHS from 'constants/paths'; import PATHS from 'constants/paths';
import { getServerSideTranslation } from '../i18n';
import getUser from 'lib/user/getUser'; import getUser from 'lib/user/getUser';
import { Provider } from 'next-auth/providers'; import { Provider } from 'next-auth/providers';
import { getProviders, signIn } from 'next-auth/react'; import { getProviders, signIn } from 'next-auth/react';
@@ -13,13 +12,13 @@ import Image from 'next/image';
import { FcGoogle } from 'react-icons/fc'; import { FcGoogle } from 'react-icons/fc';
import styles from 'styles/login.module.scss'; import styles from 'styles/login.module.scss';
import { getSession } from 'utils/session'; import { getSession } from 'utils/session';
import { TFunctionParam } from '../types/i18next'; import { getServerSideTranslation } from '../i18n';
interface SignInProps { interface SignInProps {
providers: Provider[]; providers: Provider[];
} }
export default function SignIn({ providers }: SignInProps) { export default function SignIn({ providers }: Readonly<SignInProps>) {
const { t } = useTranslation('login'); const { t } = useTranslation('login');
return ( return (
@@ -47,7 +46,7 @@ export default function SignIn({ providers }: SignInProps) {
key={id} key={id}
> >
<FcGoogle size={'1.5em'} />{' '} <FcGoogle size={'1.5em'} />{' '}
{t('login:continue-with', { provider: name } as TFunctionParam)} {t('login:continue-with', { provider: name })}
</ButtonLink> </ButtonLink>
))} ))}
</div> </div>

View File

@@ -1,12 +1,11 @@
import LinkTag from 'next/link';
import PageTransition from 'components/PageTransition';
import styles from 'styles/legal-pages.module.scss';
import clsx from 'clsx'; import clsx from 'clsx';
import Navbar from 'components/Navbar/Navbar'; import Navbar from 'components/Navbar/Navbar';
import { getServerSideTranslation } from '../i18n'; import PageTransition from 'components/PageTransition';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
import { DefaultSeo } from 'next-seo'; import { DefaultSeo } from 'next-seo';
import LinkTag from 'next/link';
import styles from 'styles/legal-pages.module.scss';
import { getServerSideTranslation } from '../i18n';
export default function Privacy() { export default function Privacy() {
const { t } = useTranslation('privacy'); const { t } = useTranslation('privacy');
@@ -17,9 +16,7 @@ export default function Privacy() {
<Navbar /> <Navbar />
<main> <main>
<h1>{t('privacy:title')}</h1> <h1>{t('privacy:title')}</h1>
<p> <p>{t('privacy:edited_at', { date: '19/11/2023' })}</p>
{t('privacy:edited_at', { date: '19/11/2023' } as TFunctionParam)}
</p>
<p>{t('privacy:welcome')}</p> <p>{t('privacy:welcome')}</p>
<h2>{t('privacy:collect.title')}</h2> <h2>{t('privacy:collect.title')}</h2>
@@ -29,11 +26,9 @@ export default function Privacy() {
<h3>{t('privacy:collect.user.title')}</h3> <h3>{t('privacy:collect.user.title')}</h3>
<p>{t('privacy:collect.user.description')}</p> <p>{t('privacy:collect.user.description')}</p>
<ul> <ul>
{( {t('privacy:collect.user.fields', {
t('privacy:collect.user.fields', { returnObjects: true,
returnObjects: true, }).map((field) => (
} as TFunctionParam) as Array<string>
).map((field) => (
<li key={field}>{field}</li> <li key={field}>{field}</li>
))} ))}
</ul> </ul>

View File

@@ -1,13 +1,12 @@
import PageTransition from 'components/PageTransition';
import styles from 'styles/legal-pages.module.scss';
import clsx from 'clsx'; import clsx from 'clsx';
import LinkTag from 'next/link';
import Navbar from 'components/Navbar/Navbar'; import Navbar from 'components/Navbar/Navbar';
import PageTransition from 'components/PageTransition';
import PATHS from 'constants/paths';
import { getServerSideTranslation } from 'i18n'; import { getServerSideTranslation } from 'i18n';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
import PATHS from 'constants/paths';
import { DefaultSeo } from 'next-seo'; import { DefaultSeo } from 'next-seo';
import LinkTag from 'next/link';
import styles from 'styles/legal-pages.module.scss';
export default function Terms() { export default function Terms() {
const { t } = useTranslation('terms'); const { t } = useTranslation('terms');
@@ -17,7 +16,7 @@ export default function Terms() {
<Navbar /> <Navbar />
<main> <main>
<h1>{t('terms:title')}</h1> <h1>{t('terms:title')}</h1>
<p>{t('terms:edited_at', { date: '19/11/2023' } as TFunctionParam)}</p> <p>{t('terms:edited_at', { date: '19/11/2023' })}</p>
<p>{t('terms:welcome')}</p> <p>{t('terms:welcome')}</p>
<h2>{t('terms:accept.title')}</h2> <h2>{t('terms:accept.title')}</h2>

View File

@@ -13,10 +13,7 @@ import resources from '../i18n/resources';
declare module 'i18next' { declare module 'i18next' {
interface CustomTypeOptions { interface CustomTypeOptions {
defaultNS: 'common'; defaultNS: 'common';
resources: typeof resources; resources: (typeof resources)['en'];
returnNull: false; returnNull: false;
} }
} }
// Ugly hack because of the above declaration, I cant use "t" function params
type TFunctionParam = undefined;

40
src/types/types.d.ts vendored
View File

@@ -1,44 +1,20 @@
import { User } from '@prisma/client'; import { Category, User } from '@prisma/client';
// TODO: extend @prisma/client type with Link[] instead of export type CategoryWithLinks = Category & {
// recreate interface (same for Link)
export interface Category {
id: number;
name: string;
links: Link[];
authorId: User['id'];
author: User; author: User;
links: LinkWithCategory[];
createdAt: Date; };
updatedAt: Date; export type LinkWithCategory = LinkWithCategory & {
}
export interface Link {
id: number;
name: string;
url: string;
category: {
id: number;
name: string;
};
authorId: User['id'];
author: User; author: User;
favorite: boolean; category: CategoryWithLinks;
};
createdAt: Date;
updatedAt: Date;
}
export interface SearchItem { export interface SearchItem {
id: number; id: number;
name: string; name: string;
url: string; url: string;
type: 'category' | 'link'; type: 'category' | 'link';
category?: undefined | Link['category']; category?: undefined | LinkWithCategory['category'];
} }
export interface Favicon { export interface Favicon {

View File

@@ -18,3 +18,22 @@ export function groupItemBy(array: any[], property: string) {
return hash; 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;
}