mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 15:05:35 +00:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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-*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS mylinks;
|
CREATE DATABASE IF NOT EXISTS mylinks;
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE * TO mluser;
|
||||||
@@ -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
72
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `category` ADD COLUMN `nextId` INTEGER NULL;
|
||||||
|
|
||||||
10
prisma/queries/set_default_next_id.sql
Normal file
10
prisma/queries/set_default_next_id.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/components/Links/LinksFooter.tsx
Normal file
41
src/components/Links/LinksFooter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/MobileCategoriesModal.tsx
Normal file
42
src/components/MobileCategoriesModal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']}>
|
||||||
|
|||||||
@@ -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'])}>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']}>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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']}>
|
||||||
|
|||||||
18
src/contexts/activeCategoryContext.ts
Normal file
18
src/contexts/activeCategoryContext.ts
Normal 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;
|
||||||
18
src/contexts/categoriesContext.ts
Normal file
18
src/contexts/categoriesContext.ts
Normal 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;
|
||||||
16
src/contexts/favoritesContext.ts
Normal file
16
src/contexts/favoritesContext.ts
Normal 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;
|
||||||
17
src/contexts/globalHotkeysContext.ts
Normal file
17
src/contexts/globalHotkeysContext.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
type GlobalHotkeysContext = {
|
||||||
|
globalHotkeysEnabled: boolean;
|
||||||
|
setGlobalHotkeysEnabled: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iGlobalHotkeysContextState = {
|
||||||
|
globalHotkeysEnabled: true,
|
||||||
|
setGlobalHotkeysEnabled: (_: boolean) => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const GlobalHotkeysContext = createContext<GlobalHotkeysContext>(
|
||||||
|
iGlobalHotkeysContextState,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GlobalHotkeysContext;
|
||||||
6
src/hooks/useActiveCategory.tsx
Normal file
6
src/hooks/useActiveCategory.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import ActiveCategoryContext from 'contexts/activeCategoryContext';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export default function useActiveCategory() {
|
||||||
|
return useContext(ActiveCategoryContext);
|
||||||
|
}
|
||||||
6
src/hooks/useCategories.tsx
Normal file
6
src/hooks/useCategories.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import CategoriesContext from 'contexts/categoriesContext';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export default function useCategories() {
|
||||||
|
return useContext(CategoriesContext);
|
||||||
|
}
|
||||||
6
src/hooks/useFavorites.tsx
Normal file
6
src/hooks/useFavorites.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import FavoritesContext from 'contexts/favoritesContext';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export default function useFavorites() {
|
||||||
|
return useContext(FavoritesContext);
|
||||||
|
}
|
||||||
6
src/hooks/useGlobalHotkeys.tsx
Normal file
6
src/hooks/useGlobalHotkeys.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import GlobalHotkeysContext from 'contexts/globalHotkeysContext';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export default function useGlobalHotkeys() {
|
||||||
|
return useContext(GlobalHotkeysContext);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/lib/category/sortCategoriesByNextId.ts
Normal file
30
src/lib/category/sortCategoriesByNextId.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'])),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
5
src/types/i18next.d.ts
vendored
5
src/types/i18next.d.ts
vendored
@@ -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
40
src/types/types.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user