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
# pwa static files
/public/sw.js
/public/workbox-*.js
/public/sw*
/public/workbox-*

View File

@@ -1,11 +1,25 @@
version: '3.8'
services:
my-links-dev-db:
container_name: my-links-dev-db
mariadb:
container_name: mariadb
image: mysql:latest
restart: always
env_file:
- .env
ports:
- '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_PASSWORD="my-user_passwd"
MYSQL_ROOT_PASSWORD="root_passwd"
MYSQL_USER="root"
MYSQL_PASSWORD="root"
MYSQL_ROOT_PASSWORD="root"
MYSQL_DATABASE="mylinks"
# 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}"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXTAUTH_SECRET=""

72
package-lock.json generated
View File

@@ -23,6 +23,8 @@
"node-html-parser": "^6.1.11",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1",
"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",
"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": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -4413,6 +4430,16 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -7883,6 +7910,43 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -8022,6 +8086,14 @@
"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": {
"version": "1.0.4",
"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",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1",
"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])
authorId Int
nextId Int?
createdAt DateTime @default(now())
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 FavoriteItem from 'components/QuickActions/FavoriteItem';
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 styles from './links.module.scss';
import { makeRequest } from 'lib/request';
export default function LinkItem({
link,
toggleFavorite,
index,
}: {
link: Link;
toggleFavorite: (linkId: Link['id']) => void;
link: LinkWithCategory;
index: number;
}) {
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 = () => {
makeRequest({
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 {
const { origin, pathname, search } = new URL(url);
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 EditItem from 'components/QuickActions/EditItem';
import RemoveItem from 'components/QuickActions/RemoveItem';
import QuickActionSearch from 'components/QuickActions/Search';
import SearchModal from 'components/SearchModal/SearchModal';
import { motion } from 'framer-motion';
import useActiveCategory from 'hooks/useActiveCategory';
import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link';
import { RxHamburgerMenu } from 'react-icons/rx';
import { Category, Link } from 'types';
import { TFunctionParam } from 'types/i18next';
import { BiSearchAlt } from 'react-icons/bi';
import quickActionStyles from '../QuickActions/quickactions.module.scss';
import LinkItem from './LinkItem';
import LinksFooter from './LinksFooter';
import styles from './links.module.scss';
import clsx from 'clsx';
import PATHS from 'constants/paths';
export default function Links({
category,
toggleFavorite,
isMobile,
openMobileModal,
openSearchModal,
}: {
category: Category;
toggleFavorite: (linkId: Link['id']) => void;
interface LinksProps {
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 (
<div className={styles['no-category']}>
<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 (
<div className={styles['links-wrapper']}>
<h2 className={styles['category-header']}>
{isMobile && (
<ButtonLink
style={{
display: 'flex',
}}
onClick={openMobileModal}
>
<RxHamburgerMenu
size={'1.5em'}
style={{ marginRight: '.5em' }}
/>
</ButtonLink>
)}
{isMobile && <MobileCategoriesModal />}
<span className={styles['category-name']}>
{name}
{links.length > 0 && (
@@ -62,10 +43,12 @@ export default function Links({
)}
</span>
<span className={styles['category-controls']}>
<QuickActionSearch openSearchModal={openSearchModal} />
<SearchModal childClassname={quickActionStyles['action']}>
<BiSearchAlt />
</SearchModal>
<CreateItem
type='link'
categoryId={category.id}
categoryId={id}
/>
<EditItem
type='category'
@@ -82,7 +65,6 @@ export default function Links({
{links.map((link, index) => (
<LinkItem
link={link}
toggleFavorite={toggleFavorite}
index={index}
key={link.id}
/>
@@ -91,7 +73,7 @@ export default function Links({
) : (
<div className={styles['no-link']}>
<motion.p
key={Math.random()}
key={id}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
@@ -101,7 +83,7 @@ export default function Links({
duration: 0.01,
}}
dangerouslySetInnerHTML={{
__html: t('home:no-link', { name } as TFunctionParam, {
__html: t('home:no-link', { name } as any, {
interpolation: { escapeValue: false },
}),
}}
@@ -111,36 +93,7 @@ export default function Links({
</LinkTag>
</div>
)}
<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>
<LinksFooter />
</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 styles from './navbar.module.scss';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
import LinkTag from 'next/link';
import RoundedImage from '../RoundedImage/RoundedImage';
import styles from './navbar.module.scss';
export default function Navbar() {
const { data, status } = useSession();
@@ -12,7 +11,7 @@ export default function Navbar() {
const avatarLabel = t('common:avatar', {
name: data?.user?.name,
} as TFunctionParam);
});
return (
<nav className={styles['navbar']}>

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link';
import { CgTrashEmpty } from 'react-icons/cg';
import { Category, Link } from 'types';
import { CategoryWithLinks, LinkWithCategory } from 'types';
import styles from './quickactions.module.scss';
export default function RemoveItem({
type,
id,
onClick,
}: {
}: Readonly<{
type: 'category' | 'link';
id: Link['id'] | Category['id'];
id: LinkWithCategory['id'] | CategoryWithLinks['id'];
onClick?: (event: any) => void;
}) {
}>) {
const { t } = useTranslation('home');
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 { SearchItem } from 'types';
import useActiveCategory from 'hooks/useActiveCategory';
import useCategories from 'hooks/useCategories';
import { useEffect, useId, useRef } from 'react';
import styles from './search.module.scss';
@@ -18,6 +20,8 @@ export default function SearchListItem({
}) {
const id = useId();
const ref = useRef<HTMLLIElement>(null);
const { categories } = useCategories();
const { setActiveCategory } = useActiveCategory();
const { name, type, url } = item;
@@ -27,6 +31,15 @@ export default function SearchListItem({
}
}, [selected]);
const handleClick = (event) => {
if (item.type === 'category') {
event.preventDefault();
const category = categories.find((c) => c.id === item.id);
setActiveCategory(category);
}
closeModal();
};
return (
<li
className={
@@ -40,7 +53,7 @@ export default function SearchListItem({
href={url}
target='_blank'
rel='no-referrer'
onClick={closeModal}
onClick={handleClick}
>
{type === 'link' ? (
<LinkFavicon

View File

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

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 * 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 { useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
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 styles from './settings-modal.module.scss';
export default function SettingsModal() {
const { t } = useTranslation('common');
const modal = useModal();
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
useEffect(
() => setGlobalHotkeysEnabled(!modal.isShowing),
[modal.isShowing, setGlobalHotkeysEnabled],
);
useHotkeys(Keys.CLOSE_SEARCH_KEY, modal.close, {
enabled: modal.isShowing,
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 { 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 styles from './categories.module.scss';
import clsx from 'clsx';
interface CategoriesProps {
categories: Category[];
categoryActive: Category;
handleSelectCategory: (category: Category) => void;
}
export default function Categories({
categories,
categoryActive,
handleSelectCategory,
}: CategoriesProps) {
export default function Categories() {
const { t } = useTranslation();
const { categories } = useCategories();
const { activeCategory, setActiveCategory } = useActiveCategory();
const { globalHotkeysEnabled } = useGlobalHotkeys();
const linksCount = useMemo(
() => categories.reduce((acc, current) => (acc += current.links.length), 0),
[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 (
<div className={styles['categories']}>
<h4>
{t('common:category.categories')} {linksCount}
</h4>
<ul className={clsx(styles['items'], 'reset')}>
{categories.map((category, index) => (
<CategoryItem
category={category}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
key={category.id}
index={index}
/>
))}
</ul>
<DndProvider backend={HTML5Backend}>
<ListCategories />
</DndProvider>
</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 { 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 { Category } from 'types';
import { CategoryWithLinks } from 'types';
import { arrayMove } from 'utils/array';
import styles from './categories.module.scss';
interface CategoryItemProps {
category: Category;
categoryActive: Category;
handleSelectCategory: (category: Category) => void;
category: CategoryWithLinks;
index: number;
}
type CategoryDragItem = {
categoryId: CategoryWithLinks['id'];
index: number;
};
export default function CategoryItem({
category,
categoryActive,
handleSelectCategory,
index,
}: CategoryItemProps): JSX.Element {
}: Readonly<CategoryItemProps>): JSX.Element {
const { activeCategory, setActiveCategory } = useActiveCategory();
const { categories, setCategories } = useCategories();
const ref = useRef<HTMLLIElement>();
const className = `${styles['item']} ${
category.id === categoryActive.id ? styles['active'] : ''
}`;
const onClick = () => handleSelectCategory(category);
const sendMoveCategoryRequest = useCallback(
async (category: CategoryWithLinks, nextId?: number) => {
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(() => {
if (category.id === categoryActive.id) {
if (category.id === activeCategory.id) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [category.id, categoryActive.id]);
}, [category.id, activeCategory.id]);
drag(drop(ref));
return (
<motion.li
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: 'spring',
stiffness: 260,
@@ -40,18 +136,19 @@ export default function CategoryItem({
delay: index * 0.02,
duration: 200,
}}
className={className}
ref={ref}
onClick={onClick}
className={clsx(
styles['item'],
category.id === activeCategory.id && styles['active'],
)}
style={{
display: 'flex',
alignItems: 'center',
gap: '.25em',
transition: 'none',
opacity,
}}
onClick={() => setActiveCategory(category)}
title={category.name}
ref={ref}
>
{category.id === categoryActive.id ? (
{category.id === activeCategory.id ? (
<AiFillFolderOpen size={24} />
) : (
<AiOutlineFolder size={24} />

View File

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

View File

@@ -1,11 +1,13 @@
import LinkTag from 'next/link';
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';
export default function FavoriteItem({ link }: { link: Link }): JSX.Element {
export default function FavoriteItem({
link,
}: Readonly<{
link: LinkWithCategory;
}>): JSX.Element {
const { name, url, category } = link;
return (
<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 { Link } from 'types';
import FavoriteItem from './FavoriteItem';
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 { favorites } = useFavorites();
return (
favorites.length !== 0 && (

View File

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

View File

@@ -1,77 +1,21 @@
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 Favorites from './Favorites/Favorites';
import NavigationLinks from './NavigationLinks';
import UserCard from './UserCard/UserCard';
import styles from './sidemenu.module.scss';
export interface SideMenuProps {
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 },
);
export default function SideMenu() {
return (
<div className={styles['side-menu']}>
<BlockWrapper>
<Favorites favorites={favorites} />
<Favorites />
</BlockWrapper>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<Categories
categories={categories}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
/>
<Categories />
</BlockWrapper>
<BlockWrapper>
<NavigationLinks
categoryActive={categoryActive}
openSearchModal={openSearchModal}
/>
<NavigationLinks />
</BlockWrapper>
<BlockWrapper>
<UserCard />

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { User } from '@prisma/client';
import { CategoryWithLinks } from 'types/types';
import prisma from 'utils/prisma';
export default async function getUserCategories(user: User) {
return await prisma.category.findMany({
return (await prisma.category.findMany({
where: {
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 {
CategoryBodySchema,
@@ -15,7 +16,8 @@ export default apiHandler({
async function editCategory({ req, res, user }) {
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);
if (!category) {
@@ -23,18 +25,79 @@ async function editCategory({ req, res, user }) {
}
const isCategoryNameAlreadyUsed = await getUserCategoryByName(user, name);
if (isCategoryNameAlreadyUsed) {
if (isCategoryNameAlreadyUsed.id !== cid) {
throw new Error('Category name already used');
}
if (category.name === name) {
throw new Error('New category name must be different');
if (category.id === nextId) {
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({
success: 'Category successfully updated',
categoryId: category.id,
@@ -56,6 +119,19 @@ async function deleteCategory({ req, res, user }) {
await prisma.category.delete({
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({
success: 'Category successfully deleted',
categoryId: category.id,

View File

@@ -25,9 +25,29 @@ async function createCategory({ req, res, user }) {
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({
data: { name, authorId: user.id },
});
await prisma.category.update({
where: {
id: lastCategoryId,
},
data: {
nextId: categoryCreated.id,
},
});
return res.status(200).send({
success: 'Category successfully created',
categoryId: categoryCreated.id,

View File

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

View File

@@ -5,15 +5,19 @@ import PATHS from 'constants/paths';
import useAutoFocus from 'hooks/useAutoFocus';
import { getServerSideTranslation } from 'i18n';
import getUserCategory from 'lib/category/getUserCategory';
import { makeRequest } from 'lib/request';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss';
import { Category } from 'types';
import { CategoryWithLinks } from 'types';
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 router = useRouter();
const autoFocusRef = useAutoFocus();

View File

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

View File

@@ -1,192 +1,94 @@
import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
import ButtonLink from 'components/ButtonLink';
import clsx from 'clsx';
import Links from 'components/Links/Links';
import Modal from 'components/Modal/Modal';
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 UserCard from 'components/SideMenu/UserCard/UserCard';
import * as Keys from 'constants/keys';
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 useModal from 'hooks/useModal';
import { getServerSideTranslation } from 'i18n';
import getUserCategories from 'lib/category/getUserCategories';
import { useTranslation } from 'next-i18next';
import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
import { useRouter } from 'next/router';
import { useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Category, Link, SearchItem } from 'types';
import { CategoryWithLinks, LinkWithCategory } from 'types/types';
import { withAuthentication } from 'utils/session';
import clsx from 'clsx';
interface HomePageProps {
categories: Category[];
currentCategory: Category | undefined;
categories: CategoryWithLinks[];
activeCategory: CategoryWithLinks | undefined;
}
export default function HomePage(props: HomePageProps) {
export default function HomePage(props: Readonly<HomePageProps>) {
const router = useRouter();
const searchModal = useModal();
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const mobileModal = useModal();
const [categories, setCategories] = useState<Category[]>(props.categories);
const [categoryActive, setCategoryActive] = useState<Category | null>(
props.currentCategory || categories?.[0],
const [globalHotkeysEnable, setGlobalHotkeysEnabled] =
useState<boolean>(true);
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) => {
category.links.forEach((link) =>
link.favorite ? acc.push(link) : null,
);
return acc;
}, [] as Link[]),
}, [] as LinkWithCategory[]),
[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(
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(
Keys.OPEN_CREATE_CATEGORY_KEY,
() => {
router.push('/category/create');
router.push(PATHS.CATEGORY.CREATE);
},
areHotkeysEnabled,
{ enabled: globalHotkeysEnable },
);
return (
<PageTransition
className={clsx('App', 'flex-row')}
style={{ flexDirection: 'row' }}
hideLangageSelector
>
{isMobile ? (
<UserCard />
) : (
<SideMenu
categories={categories}
favorites={favorites}
handleSelectCategory={handleSelectCategory}
categoryActive={categoryActive}
openSearchModal={searchModal.open}
isModalShowing={searchModal.isShowing}
/>
)}
<Links
category={categoryActive}
toggleFavorite={toggleFavorite}
isMobile={isMobile}
openMobileModal={mobileModal.open}
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>
<CategoriesContext.Provider value={{ categories, setCategories }}>
<ActiveCategoryContext.Provider
value={{ activeCategory, setActiveCategory: handleChangeCategory }}
>
<FavoritesContext.Provider value={{ favorites }}>
<GlobalHotkeysContext.Provider
value={{
globalHotkeysEnabled: globalHotkeysEnable,
setGlobalHotkeysEnabled,
}}
>
{isMobile ? <UserCard /> : <SideMenu />}
<Links isMobile={isMobile} />
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCategoryContext.Provider>
</CategoriesContext.Provider>
</PageTransition>
);
}
@@ -204,15 +106,17 @@ export const getServerSideProps = withAuthentication(
};
}
const currentCategory = categories.find(
const activeCategory = categories.find(
({ id }) => id === Number(queryCategoryId),
);
return {
props: {
session,
categories: JSON.parse(JSON.stringify(categories)),
currentCategory: currentCategory
? JSON.parse(JSON.stringify(currentCategory))
categories: JSON.parse(
JSON.stringify(sortCategoriesByNextId(categories)),
),
activeCategory: activeCategory
? JSON.parse(JSON.stringify(activeCategory))
: null,
...(await getServerSideTranslation(locale, ['home'])),
},

View File

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

View File

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

View File

@@ -10,10 +10,12 @@ import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { FormEvent, useMemo, useState } from 'react';
import styles from 'styles/form.module.scss';
import { Link } from 'types';
import { LinkWithCategory } from 'types';
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 router = useRouter();

View File

@@ -3,7 +3,6 @@ import LangSelector from 'components/LangSelector';
import MessageManager from 'components/MessageManager/MessageManager';
import PageTransition from 'components/PageTransition';
import PATHS from 'constants/paths';
import { getServerSideTranslation } from '../i18n';
import getUser from 'lib/user/getUser';
import { Provider } from 'next-auth/providers';
import { getProviders, signIn } from 'next-auth/react';
@@ -13,13 +12,13 @@ import Image from 'next/image';
import { FcGoogle } from 'react-icons/fc';
import styles from 'styles/login.module.scss';
import { getSession } from 'utils/session';
import { TFunctionParam } from '../types/i18next';
import { getServerSideTranslation } from '../i18n';
interface SignInProps {
providers: Provider[];
}
export default function SignIn({ providers }: SignInProps) {
export default function SignIn({ providers }: Readonly<SignInProps>) {
const { t } = useTranslation('login');
return (
@@ -47,7 +46,7 @@ export default function SignIn({ providers }: SignInProps) {
key={id}
>
<FcGoogle size={'1.5em'} />{' '}
{t('login:continue-with', { provider: name } as TFunctionParam)}
{t('login:continue-with', { provider: name })}
</ButtonLink>
))}
</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 Navbar from 'components/Navbar/Navbar';
import { getServerSideTranslation } from '../i18n';
import PageTransition from 'components/PageTransition';
import { useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
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() {
const { t } = useTranslation('privacy');
@@ -17,9 +16,7 @@ export default function Privacy() {
<Navbar />
<main>
<h1>{t('privacy:title')}</h1>
<p>
{t('privacy:edited_at', { date: '19/11/2023' } as TFunctionParam)}
</p>
<p>{t('privacy:edited_at', { date: '19/11/2023' })}</p>
<p>{t('privacy:welcome')}</p>
<h2>{t('privacy:collect.title')}</h2>
@@ -29,11 +26,9 @@ export default function Privacy() {
<h3>{t('privacy:collect.user.title')}</h3>
<p>{t('privacy:collect.user.description')}</p>
<ul>
{(
t('privacy:collect.user.fields', {
returnObjects: true,
} as TFunctionParam) as Array<string>
).map((field) => (
{t('privacy:collect.user.fields', {
returnObjects: true,
}).map((field) => (
<li key={field}>{field}</li>
))}
</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 LinkTag from 'next/link';
import Navbar from 'components/Navbar/Navbar';
import PageTransition from 'components/PageTransition';
import PATHS from 'constants/paths';
import { getServerSideTranslation } from 'i18n';
import { Trans, useTranslation } from 'next-i18next';
import { TFunctionParam } from 'types/i18next';
import PATHS from 'constants/paths';
import { DefaultSeo } from 'next-seo';
import LinkTag from 'next/link';
import styles from 'styles/legal-pages.module.scss';
export default function Terms() {
const { t } = useTranslation('terms');
@@ -17,7 +16,7 @@ export default function Terms() {
<Navbar />
<main>
<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>
<h2>{t('terms:accept.title')}</h2>

View File

@@ -13,10 +13,7 @@ import resources from '../i18n/resources';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof resources;
resources: (typeof resources)['en'];
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
// recreate interface (same for Link)
export interface Category {
id: number;
name: string;
links: Link[];
authorId: User['id'];
export type CategoryWithLinks = Category & {
author: User;
createdAt: Date;
updatedAt: Date;
}
export interface Link {
id: number;
name: string;
url: string;
category: {
id: number;
name: string;
};
authorId: User['id'];
links: LinkWithCategory[];
};
export type LinkWithCategory = LinkWithCategory & {
author: User;
favorite: boolean;
createdAt: Date;
updatedAt: Date;
}
category: CategoryWithLinks;
};
export interface SearchItem {
id: number;
name: string;
url: string;
type: 'category' | 'link';
category?: undefined | Link['category'];
category?: undefined | LinkWithCategory['category'];
}
export interface Favicon {

View File

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