From ad682faa9e636645a95cadd213eb768fffc9165a Mon Sep 17 00:00:00 2001 From: Sonny <24420064+Sonny93@users.noreply.github.com> Date: Thu, 14 Dec 2023 00:15:58 +0100 Subject: [PATCH] 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 --- .gitignore | 4 +- dev.docker-compose.yml | 18 +- docker-config/mysql-dev-init.sql | 4 +- example.env | 7 +- package-lock.json | 72 ++++++ package.json | 2 + .../migration.sql | 3 + prisma/queries/set_default_next_id.sql | 10 + prisma/schema.prisma | 2 + src/components/Links/LinkItem.tsx | 45 +++- src/components/Links/Links.tsx | 93 ++------ src/components/Links/LinksFooter.tsx | 41 ++++ src/components/MobileCategoriesModal.tsx | 42 ++++ src/components/Navbar/Navbar.tsx | 9 +- src/components/Profile/Profile.tsx | 5 +- src/components/QuickActions/CreateItem.tsx | 4 +- src/components/QuickActions/EditItem.tsx | 6 +- src/components/QuickActions/RemoveItem.tsx | 8 +- src/components/QuickActions/Search.tsx | 18 -- src/components/SearchModal/SearchListItem.tsx | 15 +- src/components/SearchModal/SearchModal.tsx | 211 ++++++++++++------ src/components/Settings/SettingsModal.tsx | 33 ++- .../SideMenu/Categories/Categories.tsx | 90 ++++++-- .../SideMenu/Categories/CategoryItem.tsx | 143 ++++++++++-- .../Categories/categories.module.scss | 3 + .../SideMenu/Favorites/FavoriteItem.tsx | 12 +- .../SideMenu/Favorites/Favorites.tsx | 7 +- src/components/SideMenu/NavigationLinks.tsx | 16 +- src/components/SideMenu/SideMenu.tsx | 64 +----- src/components/SideMenu/UserCard/UserCard.tsx | 5 +- src/contexts/activeCategoryContext.ts | 18 ++ src/contexts/categoriesContext.ts | 18 ++ src/contexts/favoritesContext.ts | 16 ++ src/contexts/globalHotkeysContext.ts | 17 ++ src/hooks/useActiveCategory.tsx | 6 + src/hooks/useCategories.tsx | 6 + src/hooks/useFavorites.tsx | 6 + src/hooks/useGlobalHotkeys.tsx | 6 + src/hooks/useMediaQuery.tsx | 2 +- src/lib/category/categoryValidationSchema.ts | 5 +- src/lib/category/getUserCategories.ts | 5 +- src/lib/category/sortCategoriesByNextId.ts | 30 +++ src/pages/api/category/[cid].ts | 92 +++++++- src/pages/api/category/index.ts | 20 ++ src/pages/category/create.tsx | 4 +- src/pages/category/edit/[cid].tsx | 10 +- src/pages/category/remove/[cid].tsx | 6 +- src/pages/index.tsx | 198 +++++----------- src/pages/link/create.tsx | 16 +- src/pages/link/edit/[lid].tsx | 6 +- src/pages/link/remove/[lid].tsx | 6 +- src/pages/login.tsx | 7 +- src/pages/privacy.tsx | 21 +- src/pages/terms.tsx | 11 +- src/types/i18next.d.ts | 5 +- src/types/types.d.ts | 40 +--- src/utils/array.ts | 19 ++ 57 files changed, 1016 insertions(+), 572 deletions(-) create mode 100644 prisma/migrations/20231210024357_add_next_category_id/migration.sql create mode 100644 prisma/queries/set_default_next_id.sql create mode 100644 src/components/Links/LinksFooter.tsx create mode 100644 src/components/MobileCategoriesModal.tsx delete mode 100644 src/components/QuickActions/Search.tsx create mode 100644 src/contexts/activeCategoryContext.ts create mode 100644 src/contexts/categoriesContext.ts create mode 100644 src/contexts/favoritesContext.ts create mode 100644 src/contexts/globalHotkeysContext.ts create mode 100644 src/hooks/useActiveCategory.tsx create mode 100644 src/hooks/useCategories.tsx create mode 100644 src/hooks/useFavorites.tsx create mode 100644 src/hooks/useGlobalHotkeys.tsx create mode 100644 src/lib/category/sortCategoriesByNextId.ts diff --git a/.gitignore b/.gitignore index 173140c..aa5405b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,5 @@ public/sitemap* public/robots.txt # pwa static files -/public/sw.js -/public/workbox-*.js +/public/sw* +/public/workbox-* diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index 6dfef59..e26ab4b 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -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 diff --git a/docker-config/mysql-dev-init.sql b/docker-config/mysql-dev-init.sql index 56fbff1..902751b 100644 --- a/docker-config/mysql-dev-init.sql +++ b/docker-config/mysql-dev-init.sql @@ -1 +1,3 @@ -CREATE DATABASE IF NOT EXISTS mylinks; \ No newline at end of file +CREATE DATABASE IF NOT EXISTS mylinks; + +GRANT ALL PRIVILEGES ON DATABASE * TO mluser; \ No newline at end of file diff --git a/example.env b/example.env index a25ad28..f13115c 100644 --- a/example.env +++ b/example.env @@ -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="" diff --git a/package-lock.json b/package-lock.json index 730e073..bb43095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index af0f16a..7615a04 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20231210024357_add_next_category_id/migration.sql b/prisma/migrations/20231210024357_add_next_category_id/migration.sql new file mode 100644 index 0000000..7bcba0a --- /dev/null +++ b/prisma/migrations/20231210024357_add_next_category_id/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `category` ADD COLUMN `nextId` INTEGER NULL; + diff --git a/prisma/queries/set_default_next_id.sql b/prisma/queries/set_default_next_id.sql new file mode 100644 index 0000000..ca6eb80 --- /dev/null +++ b/prisma/queries/set_default_next_id.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b29484..350c520 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,8 @@ model Category { author User @relation(fields: [authorId], references: [id]) authorId Int + nextId Int? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/Links/LinkItem.tsx b/src/components/Links/LinkItem.tsx index 0776a3f..a9cf5ea 100644 --- a/src/components/Links/LinkItem.tsx +++ b/src/components/Links/LinkItem.tsx @@ -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 = ''; diff --git a/src/components/Links/Links.tsx b/src/components/Links/Links.tsx index 11ce7e2..65d3489 100644 --- a/src/components/Links/Links.tsx +++ b/src/components/Links/Links.tsx @@ -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) { + const { t } = useTranslation('home'); + const { activeCategory } = useActiveCategory(); + + if (activeCategory === null) { return (

{t('home:select-category')}

@@ -38,23 +31,11 @@ export default function Links({ ); } - const { id, name, links } = category; + const { id, name, links } = activeCategory; return (

- {isMobile && ( - - - - )} + {isMobile && } {name} {links.length > 0 && ( @@ -62,10 +43,12 @@ export default function Links({ )} - + + + ( @@ -91,7 +73,7 @@ export default function Links({ ) : (
)} -
-
- {t('common:privacy')} - {' • '} - {t('common:terms')} -
-
- {t('home:footer.made_by')}{' '} - - Sonny - - {' • '} - - Github - - {' • '} - - Extension - -
-
+

); } diff --git a/src/components/Links/LinksFooter.tsx b/src/components/Links/LinksFooter.tsx new file mode 100644 index 0000000..9a16e4e --- /dev/null +++ b/src/components/Links/LinksFooter.tsx @@ -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 ( +
+
+ {t('common:privacy')} + {' • '} + {t('common:terms')} +
+
+ {t('home:footer.made_by')}{' '} + + Sonny + + {' • '} + + Github + + {' • '} + + Extension + +
+
+ ); +} diff --git a/src/components/MobileCategoriesModal.tsx b/src/components/MobileCategoriesModal.tsx new file mode 100644 index 0000000..62d198a --- /dev/null +++ b/src/components/MobileCategoriesModal.tsx @@ -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 ( + <> + + + + + {mobileModal.isShowing && ( + + + + {t('common:category.create')} + + + + + )} + + + ); +} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index d8b861a..5696475 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -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 (