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
|
||||
|
||||
# pwa static files
|
||||
/public/sw.js
|
||||
/public/workbox-*.js
|
||||
/public/sw*
|
||||
/public/workbox-*
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
my-links-dev-db:
|
||||
container_name: my-links-dev-db
|
||||
mariadb:
|
||||
container_name: mariadb
|
||||
image: mysql:latest
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '3306:3306'
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:5
|
||||
container_name: phpmyadmin
|
||||
restart: always
|
||||
environment:
|
||||
- PMA_HOST=mariadb
|
||||
- PMA_PORT=3306
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '8080:80'
|
||||
depends_on:
|
||||
- mariadb
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
CREATE DATABASE IF NOT EXISTS mylinks;
|
||||
|
||||
GRANT ALL PRIVILEGES ON DATABASE * TO mluser;
|
||||
@@ -1,6 +1,6 @@
|
||||
MYSQL_USER="my-user"
|
||||
MYSQL_PASSWORD="my-user_passwd"
|
||||
MYSQL_ROOT_PASSWORD="root_passwd"
|
||||
MYSQL_USER="root"
|
||||
MYSQL_PASSWORD="root"
|
||||
MYSQL_ROOT_PASSWORD="root"
|
||||
MYSQL_DATABASE="mylinks"
|
||||
|
||||
# Or if you need external Database
|
||||
@@ -9,7 +9,6 @@ MYSQL_DATABASE="mylinks"
|
||||
# DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${DATABASE_IP}:${DATABASE_PORT}/${MYSQL_DATABASE}"
|
||||
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
|
||||
NEXTAUTH_SECRET=""
|
||||
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -23,6 +23,8 @@
|
||||
"node-html-parser": "^6.1.11",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-i18next": "^13.5.0",
|
||||
@@ -2323,6 +2325,21 @@
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz",
|
||||
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@@ -4413,6 +4430,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^5.0.1",
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"redux": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@@ -7883,6 +7910,43 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
"dnd-core": "^16.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"dependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
@@ -8022,6 +8086,14 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"node-html-parser": "^6.1.11",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-i18next": "^13.5.0",
|
||||
|
||||
@@ -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])
|
||||
authorId Int
|
||||
|
||||
nextId Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
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 FavoriteItem from 'components/QuickActions/FavoriteItem';
|
||||
import RemoveItem from 'components/QuickActions/RemoveItem';
|
||||
import PATHS from 'constants/paths';
|
||||
import { motion } from 'framer-motion';
|
||||
import useCategories from 'hooks/useCategories';
|
||||
import { makeRequest } from 'lib/request';
|
||||
import LinkTag from 'next/link';
|
||||
import { useCallback } from 'react';
|
||||
import { AiFillStar } from 'react-icons/ai';
|
||||
import { LinkWithCategory } from 'types';
|
||||
import LinkFavicon from './LinkFavicon';
|
||||
import styles from './links.module.scss';
|
||||
import { makeRequest } from 'lib/request';
|
||||
|
||||
export default function LinkItem({
|
||||
link,
|
||||
toggleFavorite,
|
||||
index,
|
||||
}: {
|
||||
link: Link;
|
||||
toggleFavorite: (linkId: Link['id']) => void;
|
||||
link: LinkWithCategory;
|
||||
index: number;
|
||||
}) {
|
||||
const { id, name, url, favorite } = link;
|
||||
const { categories, setCategories } = useCategories();
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
(linkId: LinkWithCategory['id']) => {
|
||||
let linkIndex = 0;
|
||||
const categoryIndex = categories.findIndex(({ links }) => {
|
||||
const lIndex = links.findIndex((l) => l.id === linkId);
|
||||
if (lIndex !== -1) {
|
||||
linkIndex = lIndex;
|
||||
}
|
||||
return lIndex !== -1;
|
||||
});
|
||||
|
||||
const link = categories[categoryIndex].links[linkIndex];
|
||||
const categoriesCopy = [...categories];
|
||||
categoriesCopy[categoryIndex].links[linkIndex] = {
|
||||
...link,
|
||||
favorite: !link.favorite,
|
||||
};
|
||||
|
||||
setCategories(categoriesCopy);
|
||||
},
|
||||
[categories, setCategories],
|
||||
);
|
||||
|
||||
const onFavorite = () => {
|
||||
makeRequest({
|
||||
url: `${PATHS.API.LINK}/${link.id}`,
|
||||
@@ -77,7 +102,7 @@ export default function LinkItem({
|
||||
);
|
||||
}
|
||||
|
||||
function LinkItemURL({ url }: { url: Link['url'] }) {
|
||||
function LinkItemURL({ url }: { url: LinkWithCategory['url'] }) {
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(url);
|
||||
let text = '';
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import ButtonLink from 'components/ButtonLink';
|
||||
import clsx from 'clsx';
|
||||
import MobileCategoriesModal from 'components/MobileCategoriesModal';
|
||||
import CreateItem from 'components/QuickActions/CreateItem';
|
||||
import EditItem from 'components/QuickActions/EditItem';
|
||||
import RemoveItem from 'components/QuickActions/RemoveItem';
|
||||
import QuickActionSearch from 'components/QuickActions/Search';
|
||||
import SearchModal from 'components/SearchModal/SearchModal';
|
||||
import { motion } from 'framer-motion';
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LinkTag from 'next/link';
|
||||
import { RxHamburgerMenu } from 'react-icons/rx';
|
||||
import { Category, Link } from 'types';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import { BiSearchAlt } from 'react-icons/bi';
|
||||
import quickActionStyles from '../QuickActions/quickactions.module.scss';
|
||||
import LinkItem from './LinkItem';
|
||||
import LinksFooter from './LinksFooter';
|
||||
import styles from './links.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import PATHS from 'constants/paths';
|
||||
|
||||
export default function Links({
|
||||
category,
|
||||
toggleFavorite,
|
||||
isMobile,
|
||||
openMobileModal,
|
||||
openSearchModal,
|
||||
}: {
|
||||
category: Category;
|
||||
toggleFavorite: (linkId: Link['id']) => void;
|
||||
interface LinksProps {
|
||||
isMobile: boolean;
|
||||
openMobileModal: () => void;
|
||||
openSearchModal: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('home');
|
||||
}
|
||||
|
||||
if (category === null) {
|
||||
export default function Links({ isMobile }: Readonly<LinksProps>) {
|
||||
const { t } = useTranslation('home');
|
||||
const { activeCategory } = useActiveCategory();
|
||||
|
||||
if (activeCategory === null) {
|
||||
return (
|
||||
<div className={styles['no-category']}>
|
||||
<p>{t('home:select-category')}</p>
|
||||
@@ -38,23 +31,11 @@ export default function Links({
|
||||
);
|
||||
}
|
||||
|
||||
const { id, name, links } = category;
|
||||
const { id, name, links } = activeCategory;
|
||||
return (
|
||||
<div className={styles['links-wrapper']}>
|
||||
<h2 className={styles['category-header']}>
|
||||
{isMobile && (
|
||||
<ButtonLink
|
||||
style={{
|
||||
display: 'flex',
|
||||
}}
|
||||
onClick={openMobileModal}
|
||||
>
|
||||
<RxHamburgerMenu
|
||||
size={'1.5em'}
|
||||
style={{ marginRight: '.5em' }}
|
||||
/>
|
||||
</ButtonLink>
|
||||
)}
|
||||
{isMobile && <MobileCategoriesModal />}
|
||||
<span className={styles['category-name']}>
|
||||
{name}
|
||||
{links.length > 0 && (
|
||||
@@ -62,10 +43,12 @@ export default function Links({
|
||||
)}
|
||||
</span>
|
||||
<span className={styles['category-controls']}>
|
||||
<QuickActionSearch openSearchModal={openSearchModal} />
|
||||
<SearchModal childClassname={quickActionStyles['action']}>
|
||||
<BiSearchAlt />
|
||||
</SearchModal>
|
||||
<CreateItem
|
||||
type='link'
|
||||
categoryId={category.id}
|
||||
categoryId={id}
|
||||
/>
|
||||
<EditItem
|
||||
type='category'
|
||||
@@ -82,7 +65,6 @@ export default function Links({
|
||||
{links.map((link, index) => (
|
||||
<LinkItem
|
||||
link={link}
|
||||
toggleFavorite={toggleFavorite}
|
||||
index={index}
|
||||
key={link.id}
|
||||
/>
|
||||
@@ -91,7 +73,7 @@ export default function Links({
|
||||
) : (
|
||||
<div className={styles['no-link']}>
|
||||
<motion.p
|
||||
key={Math.random()}
|
||||
key={id}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
@@ -101,7 +83,7 @@ export default function Links({
|
||||
duration: 0.01,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('home:no-link', { name } as TFunctionParam, {
|
||||
__html: t('home:no-link', { name } as any, {
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
}}
|
||||
@@ -111,36 +93,7 @@ export default function Links({
|
||||
</LinkTag>
|
||||
</div>
|
||||
)}
|
||||
<footer className={styles['footer']}>
|
||||
<div className='top'>
|
||||
<LinkTag href={PATHS.PRIVACY}>{t('common:privacy')}</LinkTag>
|
||||
{' • '}
|
||||
<LinkTag href={PATHS.TERMS}>{t('common:terms')}</LinkTag>
|
||||
</div>
|
||||
<div className='bottom'>
|
||||
{t('home:footer.made_by')}{' '}
|
||||
<LinkTag
|
||||
href={PATHS.AUTHOR}
|
||||
target='_blank'
|
||||
>
|
||||
Sonny
|
||||
</LinkTag>
|
||||
{' • '}
|
||||
<LinkTag
|
||||
href={PATHS.REPO_GITHUB}
|
||||
target='_blank'
|
||||
>
|
||||
Github
|
||||
</LinkTag>
|
||||
{' • '}
|
||||
<LinkTag
|
||||
href={PATHS.EXTENSION}
|
||||
target='_blank'
|
||||
>
|
||||
Extension
|
||||
</LinkTag>
|
||||
</div>
|
||||
</footer>
|
||||
<LinksFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 styles from './navbar.module.scss';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import LinkTag from 'next/link';
|
||||
import RoundedImage from '../RoundedImage/RoundedImage';
|
||||
import styles from './navbar.module.scss';
|
||||
|
||||
export default function Navbar() {
|
||||
const { data, status } = useSession();
|
||||
@@ -12,7 +11,7 @@ export default function Navbar() {
|
||||
|
||||
const avatarLabel = t('common:avatar', {
|
||||
name: data?.user?.name,
|
||||
} as TFunctionParam);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className={styles['navbar']}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import RoundedImage from '../RoundedImage/RoundedImage';
|
||||
import styles from './profile.module.scss';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function Profile() {
|
||||
const { data } = useSession();
|
||||
@@ -11,7 +10,7 @@ export default function Profile() {
|
||||
|
||||
const avatarLabel = t('common:avatar', {
|
||||
name: data?.user?.name,
|
||||
} as TFunctionParam);
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className={clsx('reset', styles['profile'])}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LinkTag from 'next/link';
|
||||
import { IoAddOutline } from 'react-icons/io5';
|
||||
import { Category } from 'types';
|
||||
import { CategoryWithLinks } from 'types';
|
||||
import styles from './quickactions.module.scss';
|
||||
|
||||
export default function CreateItem({
|
||||
@@ -10,7 +10,7 @@ export default function CreateItem({
|
||||
onClick,
|
||||
}: {
|
||||
type: 'category' | 'link';
|
||||
categoryId?: Category['id'];
|
||||
categoryId?: CategoryWithLinks['id'];
|
||||
onClick?: (event: any) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import LinkTag from 'next/link';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LinkTag from 'next/link';
|
||||
import { AiOutlineEdit } from 'react-icons/ai';
|
||||
import { Category, Link } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory } from 'types';
|
||||
import styles from './quickactions.module.scss';
|
||||
|
||||
export default function EditItem({
|
||||
@@ -11,7 +11,7 @@ export default function EditItem({
|
||||
className = '',
|
||||
}: {
|
||||
type: 'category' | 'link';
|
||||
id: Link['id'] | Category['id'];
|
||||
id: LinkWithCategory['id'] | CategoryWithLinks['id'];
|
||||
onClick?: (event: any) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LinkTag from 'next/link';
|
||||
import { CgTrashEmpty } from 'react-icons/cg';
|
||||
import { Category, Link } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory } from 'types';
|
||||
import styles from './quickactions.module.scss';
|
||||
|
||||
export default function RemoveItem({
|
||||
type,
|
||||
id,
|
||||
onClick,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
type: 'category' | 'link';
|
||||
id: Link['id'] | Category['id'];
|
||||
id: LinkWithCategory['id'] | CategoryWithLinks['id'];
|
||||
onClick?: (event: any) => void;
|
||||
}) {
|
||||
}>) {
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 { SearchItem } from 'types';
|
||||
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import useCategories from 'hooks/useCategories';
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
import styles from './search.module.scss';
|
||||
|
||||
@@ -18,6 +20,8 @@ export default function SearchListItem({
|
||||
}) {
|
||||
const id = useId();
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const { categories } = useCategories();
|
||||
const { setActiveCategory } = useActiveCategory();
|
||||
|
||||
const { name, type, url } = item;
|
||||
|
||||
@@ -27,6 +31,15 @@ export default function SearchListItem({
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (item.type === 'category') {
|
||||
event.preventDefault();
|
||||
const category = categories.find((c) => c.id === item.id);
|
||||
setActiveCategory(category);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
@@ -40,7 +53,7 @@ export default function SearchListItem({
|
||||
href={url}
|
||||
target='_blank'
|
||||
rel='no-referrer'
|
||||
onClick={closeModal}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{type === 'link' ? (
|
||||
<LinkFavicon
|
||||
|
||||
@@ -1,31 +1,97 @@
|
||||
import ButtonLink from 'components/ButtonLink';
|
||||
import Modal from 'components/Modal/Modal';
|
||||
import TextBox from 'components/TextBox';
|
||||
import * as Keys from 'constants/keys';
|
||||
import PATHS from 'constants/paths';
|
||||
import { GOOGLE_SEARCH_URL } from 'constants/search-urls';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import useAutoFocus from 'hooks/useAutoFocus';
|
||||
import useCategories from 'hooks/useCategories';
|
||||
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import useModal from 'hooks/useModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
FormEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { BsSearch } from 'react-icons/bs';
|
||||
import { Category, SearchItem } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory, SearchItem } from 'types';
|
||||
import LabelSearchWithGoogle from './LabelSearchWithGoogle';
|
||||
import SearchList from './SearchList';
|
||||
import styles from './search.module.scss';
|
||||
|
||||
export default function SearchModal({
|
||||
close,
|
||||
handleSelectCategory,
|
||||
categories,
|
||||
items,
|
||||
noHeader = true,
|
||||
children,
|
||||
childClassname = '',
|
||||
disableHotkeys = false,
|
||||
}: {
|
||||
close: () => void;
|
||||
handleSelectCategory: (category: Category) => void;
|
||||
categories: Category[];
|
||||
items: SearchItem[];
|
||||
noHeader?: boolean;
|
||||
children: ReactNode;
|
||||
childClassname?: string;
|
||||
disableHotkeys?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const autoFocusRef = useAutoFocus();
|
||||
const searchModal = useModal();
|
||||
|
||||
const { categories } = useCategories();
|
||||
const { setActiveCategory } = useActiveCategory();
|
||||
const { globalHotkeysEnabled, setGlobalHotkeysEnabled } = useGlobalHotkeys();
|
||||
|
||||
useEffect(
|
||||
() => setGlobalHotkeysEnabled(!searchModal.isShowing),
|
||||
[searchModal.isShowing, setGlobalHotkeysEnabled],
|
||||
);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
searchModal.close();
|
||||
setSearch('');
|
||||
}, [searchModal]);
|
||||
|
||||
useHotkeys(
|
||||
Keys.OPEN_SEARCH_KEY,
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
searchModal.open();
|
||||
},
|
||||
{ enabled: !disableHotkeys && globalHotkeysEnabled },
|
||||
);
|
||||
useHotkeys(Keys.CLOSE_SEARCH_KEY, handleCloseModal, {
|
||||
enabled: searchModal.isShowing,
|
||||
enableOnFormTags: ['INPUT'],
|
||||
});
|
||||
|
||||
const searchItemBuilder = (
|
||||
item: CategoryWithLinks | LinkWithCategory,
|
||||
type: SearchItem['type'],
|
||||
): SearchItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url:
|
||||
type === 'link'
|
||||
? (item as LinkWithCategory).url
|
||||
: `${PATHS.HOME}?categoryId=${item.id}`,
|
||||
type,
|
||||
category: type === 'link' ? (item as LinkWithCategory).category : undefined,
|
||||
});
|
||||
|
||||
const itemsSearch = useMemo<SearchItem[]>(() => {
|
||||
return categories.reduce((acc, category) => {
|
||||
const categoryItem = searchItemBuilder(category, 'category');
|
||||
const items: SearchItem[] = category.links.map((link) =>
|
||||
searchItemBuilder(link, 'link'),
|
||||
);
|
||||
return [...acc, ...items, categoryItem];
|
||||
}, [] as SearchItem[]);
|
||||
}, [categories]);
|
||||
|
||||
const [canSearchLink, setCanSearchLink] = useLocalStorage(
|
||||
'search-link',
|
||||
@@ -37,7 +103,9 @@ export default function SearchModal({
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [selectedItem, setSelectedItem] = useState<SearchItem>(items[0]);
|
||||
const [selectedItem, setSelectedItem] = useState<SearchItem | null>(
|
||||
itemsSearch[0],
|
||||
);
|
||||
|
||||
const canSubmit = useMemo<boolean>(() => search.length > 0, [search]);
|
||||
|
||||
@@ -46,7 +114,7 @@ export default function SearchModal({
|
||||
() =>
|
||||
search.length === 0
|
||||
? []
|
||||
: items.filter(
|
||||
: itemsSearch.filter(
|
||||
(item) =>
|
||||
((item.type === 'category' && canSearchCategory) ||
|
||||
(item.type === 'link' && canSearchLink)) &&
|
||||
@@ -54,14 +122,9 @@ export default function SearchModal({
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase().trim()),
|
||||
),
|
||||
[canSearchCategory, canSearchLink, items, search],
|
||||
[canSearchCategory, canSearchLink, itemsSearch, search],
|
||||
);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setSearch('');
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(value: string) => setSearch(value),
|
||||
[],
|
||||
@@ -74,77 +137,91 @@ export default function SearchModal({
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
resetForm();
|
||||
handleCloseModal();
|
||||
|
||||
if (itemsCompletion.length === 0) {
|
||||
return window.open(GOOGLE_SEARCH_URL + encodeURI(search.trim()));
|
||||
}
|
||||
|
||||
if (!selectedItem) return;
|
||||
|
||||
const category = categories.find((c) => c.id === selectedItem.id);
|
||||
if (selectedItem.type === 'category' && category) {
|
||||
return handleSelectCategory(category);
|
||||
return setActiveCategory(category);
|
||||
}
|
||||
|
||||
window.open(selectedItem.url);
|
||||
},
|
||||
[
|
||||
categories,
|
||||
handleSelectCategory,
|
||||
handleCloseModal,
|
||||
itemsCompletion.length,
|
||||
resetForm,
|
||||
search,
|
||||
selectedItem,
|
||||
setActiveCategory,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
close={close}
|
||||
noHeader={noHeader}
|
||||
padding={'0'}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={styles['search-form']}
|
||||
<>
|
||||
<ButtonLink
|
||||
className={childClassname}
|
||||
onClick={searchModal.open}
|
||||
>
|
||||
<div className={styles['search-input-wrapper']}>
|
||||
<label htmlFor='search'>
|
||||
<BsSearch size={24} />
|
||||
</label>
|
||||
<TextBox
|
||||
name='search'
|
||||
onChangeCallback={handleSearchInputChange}
|
||||
value={search}
|
||||
placeholder={t('common:search')}
|
||||
innerRef={autoFocusRef}
|
||||
fieldClass={styles['search-input-field']}
|
||||
inputClass={'reset'}
|
||||
/>
|
||||
</div>
|
||||
<SearchFilter
|
||||
canSearchLink={canSearchLink}
|
||||
setCanSearchLink={handleCanSearchLink}
|
||||
canSearchCategory={canSearchCategory}
|
||||
setCanSearchCategory={handleCanSearchCategory}
|
||||
/>
|
||||
{search.length > 0 && (
|
||||
<SearchList
|
||||
items={itemsCompletion}
|
||||
selectedItem={selectedItem}
|
||||
setSelectedItem={setSelectedItem}
|
||||
noItem={<LabelSearchWithGoogle />}
|
||||
closeModal={close}
|
||||
/>
|
||||
{children}
|
||||
</ButtonLink>
|
||||
<AnimatePresence>
|
||||
{searchModal.isShowing && (
|
||||
<Modal
|
||||
close={handleCloseModal}
|
||||
noHeader={noHeader}
|
||||
padding={'0'}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={styles['search-form']}
|
||||
>
|
||||
<div className={styles['search-input-wrapper']}>
|
||||
<label htmlFor='search'>
|
||||
<BsSearch size={24} />
|
||||
</label>
|
||||
<TextBox
|
||||
name='search'
|
||||
onChangeCallback={handleSearchInputChange}
|
||||
value={search}
|
||||
placeholder={t('common:search')}
|
||||
innerRef={autoFocusRef}
|
||||
fieldClass={styles['search-input-field']}
|
||||
inputClass={'reset'}
|
||||
/>
|
||||
</div>
|
||||
<SearchFilter
|
||||
canSearchLink={canSearchLink}
|
||||
setCanSearchLink={handleCanSearchLink}
|
||||
canSearchCategory={canSearchCategory}
|
||||
setCanSearchCategory={handleCanSearchCategory}
|
||||
/>
|
||||
{search.length > 0 && (
|
||||
<SearchList
|
||||
items={itemsCompletion}
|
||||
selectedItem={selectedItem}
|
||||
setSelectedItem={setSelectedItem}
|
||||
noItem={<LabelSearchWithGoogle />}
|
||||
closeModal={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!canSubmit}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
{t('common:confirm')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!canSubmit}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
{t('common:confirm')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import useModal from 'hooks/useModal';
|
||||
import Modal from '../Modal/Modal';
|
||||
import * as Keys from 'constants/keys';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { IoLogOutOutline, IoSettingsOutline } from 'react-icons/io5';
|
||||
import LangSelector from '../LangSelector';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import { TfiWorld } from 'react-icons/tfi';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import PATHS from 'constants/paths';
|
||||
import styles from './settings-modal.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import * as Keys from 'constants/keys';
|
||||
import PATHS from 'constants/paths';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
|
||||
import useModal from 'hooks/useModal';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
import { IoLogOutOutline, IoSettingsOutline } from 'react-icons/io5';
|
||||
import { TfiWorld } from 'react-icons/tfi';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import LangSelector from '../LangSelector';
|
||||
import Modal from '../Modal/Modal';
|
||||
import Profile from '../Profile/Profile';
|
||||
import styles from './settings-modal.module.scss';
|
||||
|
||||
export default function SettingsModal() {
|
||||
const { t } = useTranslation('common');
|
||||
const modal = useModal();
|
||||
const { setGlobalHotkeysEnabled } = useGlobalHotkeys();
|
||||
|
||||
useEffect(
|
||||
() => setGlobalHotkeysEnabled(!modal.isShowing),
|
||||
[modal.isShowing, setGlobalHotkeysEnabled],
|
||||
);
|
||||
|
||||
useHotkeys(Keys.CLOSE_SEARCH_KEY, modal.close, {
|
||||
enabled: modal.isShowing,
|
||||
enableOnFormTags: ['INPUT'],
|
||||
|
||||
@@ -1,43 +1,85 @@
|
||||
import clsx from 'clsx';
|
||||
import * as Keys from 'constants/keys';
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import useCategories from 'hooks/useCategories';
|
||||
import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { Category } from 'types';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import CategoryItem from './CategoryItem';
|
||||
import styles from './categories.module.scss';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CategoriesProps {
|
||||
categories: Category[];
|
||||
categoryActive: Category;
|
||||
handleSelectCategory: (category: Category) => void;
|
||||
}
|
||||
|
||||
export default function Categories({
|
||||
categories,
|
||||
categoryActive,
|
||||
handleSelectCategory,
|
||||
}: CategoriesProps) {
|
||||
export default function Categories() {
|
||||
const { t } = useTranslation();
|
||||
const { categories } = useCategories();
|
||||
const { activeCategory, setActiveCategory } = useActiveCategory();
|
||||
const { globalHotkeysEnabled } = useGlobalHotkeys();
|
||||
|
||||
const linksCount = useMemo(
|
||||
() => categories.reduce((acc, current) => (acc += current.links.length), 0),
|
||||
[categories],
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_UP,
|
||||
() => {
|
||||
const currentCategoryIndex = categories.findIndex(
|
||||
({ id }) => id === activeCategory.id,
|
||||
);
|
||||
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
|
||||
|
||||
setActiveCategory(categories[currentCategoryIndex - 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_DOWN,
|
||||
() => {
|
||||
const currentCategoryIndex = categories.findIndex(
|
||||
({ id }) => id === activeCategory.id,
|
||||
);
|
||||
if (
|
||||
currentCategoryIndex === -1 ||
|
||||
currentCategoryIndex === categories.length - 1
|
||||
)
|
||||
return;
|
||||
|
||||
setActiveCategory(categories[currentCategoryIndex + 1]);
|
||||
},
|
||||
{ enabled: globalHotkeysEnabled },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles['categories']}>
|
||||
<h4>
|
||||
{t('common:category.categories')} • {linksCount}
|
||||
</h4>
|
||||
<ul className={clsx(styles['items'], 'reset')}>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryItem
|
||||
category={category}
|
||||
categoryActive={categoryActive}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
key={category.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ListCategories />
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListCategories() {
|
||||
const [, drop] = useDrop(() => ({ accept: 'category' }));
|
||||
const { categories } = useCategories();
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={clsx(styles['items'], 'reset')}
|
||||
ref={drop}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryItem
|
||||
category={category}
|
||||
key={category.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,134 @@
|
||||
import clsx from 'clsx';
|
||||
import PATHS from 'constants/paths';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import useCategories from 'hooks/useCategories';
|
||||
import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
|
||||
import { makeRequest } from 'lib/request';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
|
||||
import { Category } from 'types';
|
||||
import { CategoryWithLinks } from 'types';
|
||||
import { arrayMove } from 'utils/array';
|
||||
import styles from './categories.module.scss';
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: Category;
|
||||
categoryActive: Category;
|
||||
handleSelectCategory: (category: Category) => void;
|
||||
category: CategoryWithLinks;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type CategoryDragItem = {
|
||||
categoryId: CategoryWithLinks['id'];
|
||||
index: number;
|
||||
};
|
||||
|
||||
export default function CategoryItem({
|
||||
category,
|
||||
categoryActive,
|
||||
handleSelectCategory,
|
||||
index,
|
||||
}: CategoryItemProps): JSX.Element {
|
||||
}: Readonly<CategoryItemProps>): JSX.Element {
|
||||
const { activeCategory, setActiveCategory } = useActiveCategory();
|
||||
const { categories, setCategories } = useCategories();
|
||||
|
||||
const ref = useRef<HTMLLIElement>();
|
||||
const className = `${styles['item']} ${
|
||||
category.id === categoryActive.id ? styles['active'] : ''
|
||||
}`;
|
||||
const onClick = () => handleSelectCategory(category);
|
||||
|
||||
const sendMoveCategoryRequest = useCallback(
|
||||
async (category: CategoryWithLinks, nextId?: number) => {
|
||||
if (category.id === nextId) return;
|
||||
|
||||
await makeRequest({
|
||||
url: `${PATHS.API.CATEGORY}/${category.id}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: category.name,
|
||||
nextId,
|
||||
},
|
||||
});
|
||||
setCategories((prevCategories) => {
|
||||
const categories = [...prevCategories];
|
||||
const categoryIndex = categories.findIndex((c) => c.id === category.id);
|
||||
|
||||
const previousCategoryIndex = categories.findIndex(
|
||||
(c) => c.nextId === category.id,
|
||||
);
|
||||
const prevNextCategoryIndex = categories.findIndex(
|
||||
(c) => c.nextId === nextId,
|
||||
);
|
||||
|
||||
categories[categoryIndex] = {
|
||||
...categories[categoryIndex],
|
||||
nextId,
|
||||
};
|
||||
if (previousCategoryIndex !== -1) {
|
||||
categories[previousCategoryIndex] = {
|
||||
...categories[previousCategoryIndex],
|
||||
nextId: category.nextId,
|
||||
};
|
||||
}
|
||||
if (prevNextCategoryIndex !== -1) {
|
||||
categories[prevNextCategoryIndex] = {
|
||||
...categories[prevNextCategoryIndex],
|
||||
nextId: category.id,
|
||||
};
|
||||
}
|
||||
|
||||
return sortCategoriesByNextId(categories);
|
||||
});
|
||||
},
|
||||
[setCategories],
|
||||
);
|
||||
const moveCategory = useCallback(
|
||||
(currentIndex: number, newIndex: number) => {
|
||||
setCategories((prevCategories: CategoryWithLinks[]) =>
|
||||
arrayMove(prevCategories, currentIndex, newIndex),
|
||||
);
|
||||
},
|
||||
[setCategories],
|
||||
);
|
||||
|
||||
const [_, drop] = useDrop({
|
||||
accept: 'category',
|
||||
hover: (dragItem: CategoryDragItem) => {
|
||||
if (ref.current && dragItem.categoryId !== category.id) {
|
||||
moveCategory(dragItem.index, index);
|
||||
dragItem.index = index;
|
||||
}
|
||||
},
|
||||
drop: (item) => {
|
||||
const category = categories.find((c) => c.id === item.categoryId);
|
||||
const nextCategory = categories[item.index + 1];
|
||||
if (category.nextId === null && nextCategory?.id === undefined) return;
|
||||
if (category.nextId !== nextCategory?.id) {
|
||||
sendMoveCategoryRequest(category, nextCategory?.id ?? null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [{ opacity }, drag] = useDrag({
|
||||
type: 'category',
|
||||
item: () => ({ index, categoryId: category.id }),
|
||||
collect: (monitor: any) => ({
|
||||
opacity: monitor.isDragging() ? 0.1 : 1,
|
||||
}),
|
||||
end: (dragItem: CategoryDragItem, monitor) => {
|
||||
const didDrop = monitor.didDrop();
|
||||
if (!didDrop) {
|
||||
moveCategory(dragItem.index, index);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (category.id === categoryActive.id) {
|
||||
if (category.id === activeCategory.id) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [category.id, categoryActive.id]);
|
||||
}, [category.id, activeCategory.id]);
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 260,
|
||||
@@ -40,18 +136,19 @@ export default function CategoryItem({
|
||||
delay: index * 0.02,
|
||||
duration: 200,
|
||||
}}
|
||||
className={className}
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
styles['item'],
|
||||
category.id === activeCategory.id && styles['active'],
|
||||
)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '.25em',
|
||||
transition: 'none',
|
||||
opacity,
|
||||
}}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
title={category.name}
|
||||
ref={ref}
|
||||
>
|
||||
{category.id === categoryActive.id ? (
|
||||
{category.id === activeCategory.id ? (
|
||||
<AiFillFolderOpen size={24} />
|
||||
) : (
|
||||
<AiOutlineFolder size={24} />
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
.item {
|
||||
border-bottom: 2px solid transparent !important;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: 0.15s box-shadow;
|
||||
|
||||
&.active {
|
||||
color: $blue;
|
||||
@@ -82,6 +84,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .menu-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import LinkTag from 'next/link';
|
||||
|
||||
import LinkFavicon from 'components/Links/LinkFavicon';
|
||||
import { Link } from 'types';
|
||||
|
||||
import LinkTag from 'next/link';
|
||||
import { LinkWithCategory } from 'types';
|
||||
import styles from './favorites.module.scss';
|
||||
|
||||
export default function FavoriteItem({ link }: { link: Link }): JSX.Element {
|
||||
export default function FavoriteItem({
|
||||
link,
|
||||
}: Readonly<{
|
||||
link: LinkWithCategory;
|
||||
}>): JSX.Element {
|
||||
const { name, url, category } = link;
|
||||
return (
|
||||
<li className={styles['item']}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import useFavorites from 'hooks/useFavorites';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Link } from 'types';
|
||||
import FavoriteItem from './FavoriteItem';
|
||||
import styles from './favorites.module.scss';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function Favorites({ favorites }: { favorites: Link[] }) {
|
||||
export default function Favorites() {
|
||||
const { t } = useTranslation();
|
||||
const { favorites } = useFavorites();
|
||||
|
||||
return (
|
||||
favorites.length !== 0 && (
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import ButtonLink from 'components/ButtonLink';
|
||||
import SearchModal from 'components/SearchModal/SearchModal';
|
||||
import PATHS from 'constants/paths';
|
||||
import useActiveCategory from 'hooks/useActiveCategory';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { SideMenuProps } from './SideMenu';
|
||||
import styles from './sidemenu.module.scss';
|
||||
|
||||
export default function NavigationLinks({
|
||||
categoryActive,
|
||||
openSearchModal,
|
||||
}: {
|
||||
categoryActive: SideMenuProps['categoryActive'];
|
||||
openSearchModal: SideMenuProps['openSearchModal'];
|
||||
}) {
|
||||
export default function NavigationLinks() {
|
||||
const { t } = useTranslation();
|
||||
const { activeCategory } = useActiveCategory();
|
||||
|
||||
return (
|
||||
<div className={styles['menu-controls']}>
|
||||
<div className={styles['action']}>
|
||||
<ButtonLink onClick={openSearchModal}>{t('common:search')}</ButtonLink>
|
||||
<SearchModal disableHotkeys>{t('common:search')}</SearchModal>
|
||||
<kbd>S</kbd>
|
||||
</div>
|
||||
<div className={styles['action']}>
|
||||
@@ -27,7 +23,7 @@ export default function NavigationLinks({
|
||||
</div>
|
||||
<div className={styles['action']}>
|
||||
<ButtonLink
|
||||
href={`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`}
|
||||
href={`${PATHS.LINK.CREATE}?categoryId=${activeCategory.id}`}
|
||||
>
|
||||
{t('common:link.create')}
|
||||
</ButtonLink>
|
||||
|
||||
@@ -1,77 +1,21 @@
|
||||
import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
|
||||
import * as Keys from 'constants/keys';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Category, Link } from 'types';
|
||||
import Categories from './Categories/Categories';
|
||||
import Favorites from './Favorites/Favorites';
|
||||
import NavigationLinks from './NavigationLinks';
|
||||
import UserCard from './UserCard/UserCard';
|
||||
import styles from './sidemenu.module.scss';
|
||||
|
||||
export interface SideMenuProps {
|
||||
categories: Category[];
|
||||
favorites: Link[];
|
||||
handleSelectCategory: (category: Category) => void;
|
||||
categoryActive: Category;
|
||||
openSearchModal: () => void;
|
||||
isModalShowing: boolean;
|
||||
}
|
||||
|
||||
export default function SideMenu({
|
||||
categories,
|
||||
favorites,
|
||||
handleSelectCategory,
|
||||
categoryActive,
|
||||
openSearchModal,
|
||||
isModalShowing = false,
|
||||
}: SideMenuProps) {
|
||||
useHotkeys(
|
||||
Keys.ARROW_UP,
|
||||
() => {
|
||||
const currentCategoryIndex = categories.findIndex(
|
||||
({ id }) => id === categoryActive.id,
|
||||
);
|
||||
if (currentCategoryIndex === -1 || currentCategoryIndex === 0) return;
|
||||
|
||||
handleSelectCategory(categories[currentCategoryIndex - 1]);
|
||||
},
|
||||
{ enabled: !isModalShowing },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
Keys.ARROW_DOWN,
|
||||
() => {
|
||||
const currentCategoryIndex = categories.findIndex(
|
||||
({ id }) => id === categoryActive.id,
|
||||
);
|
||||
if (
|
||||
currentCategoryIndex === -1 ||
|
||||
currentCategoryIndex === categories.length - 1
|
||||
)
|
||||
return;
|
||||
|
||||
handleSelectCategory(categories[currentCategoryIndex + 1]);
|
||||
},
|
||||
{ enabled: !isModalShowing },
|
||||
);
|
||||
|
||||
export default function SideMenu() {
|
||||
return (
|
||||
<div className={styles['side-menu']}>
|
||||
<BlockWrapper>
|
||||
<Favorites favorites={favorites} />
|
||||
<Favorites />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
|
||||
<Categories
|
||||
categories={categories}
|
||||
categoryActive={categoryActive}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
<Categories />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<NavigationLinks
|
||||
categoryActive={categoryActive}
|
||||
openSearchModal={openSearchModal}
|
||||
/>
|
||||
<NavigationLinks />
|
||||
</BlockWrapper>
|
||||
<BlockWrapper>
|
||||
<UserCard />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import SettingsModal from 'components/Settings/SettingsModal';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Image from 'next/image';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import styles from './user-card.module.scss';
|
||||
import SettingsModal from 'components/Settings/SettingsModal';
|
||||
|
||||
export default function UserCard() {
|
||||
const { data } = useSession({ required: true });
|
||||
@@ -11,7 +10,7 @@ export default function UserCard() {
|
||||
|
||||
const avatarLabel = t('common:avatar', {
|
||||
name: data.user.name,
|
||||
} as TFunctionParam);
|
||||
});
|
||||
return (
|
||||
<div className={styles['user-card-wrapper']}>
|
||||
<div className={styles['user-card']}>
|
||||
|
||||
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';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(getMediaMatches(query));
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
const handleMediaChange = () => setMatches(getMediaMatches(query));
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { number, object, string } from 'yup';
|
||||
const CategoryBodySchema = object({
|
||||
name: string()
|
||||
.trim()
|
||||
.required("Category name's required")
|
||||
.max(128, "Category name's too long"),
|
||||
.required('Category name is required')
|
||||
.max(128, 'Category name is too long'),
|
||||
nextId: number().required().nullable(),
|
||||
}).typeError('Missing request Body');
|
||||
|
||||
const CategoryQuerySchema = object({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { CategoryWithLinks } from 'types/types';
|
||||
import prisma from 'utils/prisma';
|
||||
|
||||
export default async function getUserCategories(user: User) {
|
||||
return await prisma.category.findMany({
|
||||
return (await prisma.category.findMany({
|
||||
where: {
|
||||
authorId: user?.id,
|
||||
},
|
||||
@@ -16,5 +17,5 @@ export default async function getUserCategories(user: User) {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})) as CategoryWithLinks[];
|
||||
}
|
||||
|
||||
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 {
|
||||
CategoryBodySchema,
|
||||
@@ -15,7 +16,8 @@ export default apiHandler({
|
||||
|
||||
async function editCategory({ req, res, user }) {
|
||||
const { cid } = await CategoryQuerySchema.validate(req.query);
|
||||
const { name } = await CategoryBodySchema.validate(req.body);
|
||||
const { name, nextId } = await CategoryBodySchema.validate(req.body);
|
||||
const userId = user.id as User['id'];
|
||||
|
||||
const category = await getUserCategory(user, cid);
|
||||
if (!category) {
|
||||
@@ -23,18 +25,79 @@ async function editCategory({ req, res, user }) {
|
||||
}
|
||||
|
||||
const isCategoryNameAlreadyUsed = await getUserCategoryByName(user, name);
|
||||
if (isCategoryNameAlreadyUsed) {
|
||||
if (isCategoryNameAlreadyUsed.id !== cid) {
|
||||
throw new Error('Category name already used');
|
||||
}
|
||||
|
||||
if (category.name === name) {
|
||||
throw new Error('New category name must be different');
|
||||
if (category.id === nextId) {
|
||||
throw new Error('Category nextId cannot be equal to current category ID');
|
||||
}
|
||||
|
||||
if (nextId !== null) {
|
||||
const isCategoryIdExist = await prisma.category.findFirst({
|
||||
where: {
|
||||
authorId: userId,
|
||||
id: nextId,
|
||||
},
|
||||
});
|
||||
if (!isCategoryIdExist) {
|
||||
throw new Error('Unable to find category ' + nextId);
|
||||
}
|
||||
}
|
||||
|
||||
if (category.nextId !== nextId) {
|
||||
const [previousCategory, prevNextCategory] = await prisma.$transaction([
|
||||
prisma.category.findFirst({
|
||||
// Current previous category
|
||||
where: {
|
||||
authorId: userId,
|
||||
nextId: category.id,
|
||||
},
|
||||
}),
|
||||
prisma.category.findFirst({
|
||||
// New previous category
|
||||
where: {
|
||||
authorId: userId,
|
||||
nextId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
[
|
||||
previousCategory &&
|
||||
prisma.category.update({
|
||||
where: {
|
||||
authorId: userId,
|
||||
id: previousCategory.id,
|
||||
},
|
||||
data: {
|
||||
nextId: category.nextId,
|
||||
},
|
||||
}),
|
||||
prisma.category.update({
|
||||
where: {
|
||||
authorId: userId,
|
||||
id: category.id,
|
||||
},
|
||||
data: {
|
||||
nextId,
|
||||
},
|
||||
}),
|
||||
prevNextCategory &&
|
||||
prisma.category.update({
|
||||
where: {
|
||||
authorId: userId,
|
||||
id: prevNextCategory.id,
|
||||
},
|
||||
data: {
|
||||
nextId: category.id,
|
||||
},
|
||||
}),
|
||||
].filter((a) => a !== null && a !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.category.update({
|
||||
where: { id: cid },
|
||||
data: { name },
|
||||
});
|
||||
return res.send({
|
||||
success: 'Category successfully updated',
|
||||
categoryId: category.id,
|
||||
@@ -56,6 +119,19 @@ async function deleteCategory({ req, res, user }) {
|
||||
await prisma.category.delete({
|
||||
where: { id: cid },
|
||||
});
|
||||
|
||||
const { id: previousCategoryId } = await prisma.category.findFirst({
|
||||
where: { nextId: category.id },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.category.update({
|
||||
where: {
|
||||
id: previousCategoryId,
|
||||
},
|
||||
data: {
|
||||
nextId: category.nextId,
|
||||
},
|
||||
});
|
||||
return res.send({
|
||||
success: 'Category successfully deleted',
|
||||
categoryId: category.id,
|
||||
|
||||
@@ -25,9 +25,29 @@ async function createCategory({ req, res, user }) {
|
||||
throw new Error('Category name already used');
|
||||
}
|
||||
|
||||
const { id: lastCategoryId } = await prisma.category.findFirst({
|
||||
where: {
|
||||
authorId: user.id,
|
||||
nextId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const categoryCreated = await prisma.category.create({
|
||||
data: { name, authorId: user.id },
|
||||
});
|
||||
|
||||
await prisma.category.update({
|
||||
where: {
|
||||
id: lastCategoryId,
|
||||
},
|
||||
data: {
|
||||
nextId: categoryCreated.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
success: 'Category successfully created',
|
||||
categoryId: categoryCreated.id,
|
||||
|
||||
@@ -5,12 +5,12 @@ import PATHS from 'constants/paths';
|
||||
import useAutoFocus from 'hooks/useAutoFocus';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUserCategoriesCount from 'lib/category/getUserCategoriesCount';
|
||||
import { makeRequest } from 'lib/request';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
import { makeRequest } from 'lib/request';
|
||||
|
||||
export default function PageCreateCategory({
|
||||
categoriesCount,
|
||||
@@ -40,7 +40,7 @@ export default function PageCreateCategory({
|
||||
makeRequest({
|
||||
url: PATHS.API.CATEGORY,
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
body: { name, nextId: null },
|
||||
})
|
||||
.then((data) =>
|
||||
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`),
|
||||
|
||||
@@ -5,15 +5,19 @@ import PATHS from 'constants/paths';
|
||||
import useAutoFocus from 'hooks/useAutoFocus';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUserCategory from 'lib/category/getUserCategory';
|
||||
import { makeRequest } from 'lib/request';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { Category } from 'types';
|
||||
import { CategoryWithLinks } from 'types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
import { makeRequest } from 'lib/request';
|
||||
|
||||
export default function PageEditCategory({ category }: { category: Category }) {
|
||||
export default function PageEditCategory({
|
||||
category,
|
||||
}: Readonly<{
|
||||
category: CategoryWithLinks;
|
||||
}>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const autoFocusRef = useAutoFocus();
|
||||
|
||||
@@ -5,18 +5,18 @@ import TextBox from 'components/TextBox';
|
||||
import PATHS from 'constants/paths';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUserCategory from 'lib/category/getUserCategory';
|
||||
import { makeRequest } from 'lib/request';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { Category } from 'types';
|
||||
import { CategoryWithLinks } from 'types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
import { makeRequest } from 'lib/request';
|
||||
|
||||
export default function PageRemoveCategory({
|
||||
category,
|
||||
}: Readonly<{
|
||||
category: Category;
|
||||
category: CategoryWithLinks;
|
||||
}>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,192 +1,94 @@
|
||||
import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
|
||||
import ButtonLink from 'components/ButtonLink';
|
||||
import clsx from 'clsx';
|
||||
import Links from 'components/Links/Links';
|
||||
import Modal from 'components/Modal/Modal';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import SearchModal from 'components/SearchModal/SearchModal';
|
||||
import Categories from 'components/SideMenu/Categories/Categories';
|
||||
import SideMenu from 'components/SideMenu/SideMenu';
|
||||
import UserCard from 'components/SideMenu/UserCard/UserCard';
|
||||
import * as Keys from 'constants/keys';
|
||||
import PATHS from 'constants/paths';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import ActiveCategoryContext from 'contexts/activeCategoryContext';
|
||||
import CategoriesContext from 'contexts/categoriesContext';
|
||||
import FavoritesContext from 'contexts/favoritesContext';
|
||||
import GlobalHotkeysContext from 'contexts/globalHotkeysContext';
|
||||
import { useMediaQuery } from 'hooks/useMediaQuery';
|
||||
import useModal from 'hooks/useModal';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUserCategories from 'lib/category/getUserCategories';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Category, Link, SearchItem } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory } from 'types/types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HomePageProps {
|
||||
categories: Category[];
|
||||
currentCategory: Category | undefined;
|
||||
categories: CategoryWithLinks[];
|
||||
activeCategory: CategoryWithLinks | undefined;
|
||||
}
|
||||
|
||||
export default function HomePage(props: HomePageProps) {
|
||||
export default function HomePage(props: Readonly<HomePageProps>) {
|
||||
const router = useRouter();
|
||||
const searchModal = useModal();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const mobileModal = useModal();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>(props.categories);
|
||||
const [categoryActive, setCategoryActive] = useState<Category | null>(
|
||||
props.currentCategory || categories?.[0],
|
||||
const [globalHotkeysEnable, setGlobalHotkeysEnabled] =
|
||||
useState<boolean>(true);
|
||||
const [categories, setCategories] = useState<CategoryWithLinks[]>(
|
||||
props.categories,
|
||||
);
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<CategoryWithLinks | null>(props.activeCategory || categories?.[0]);
|
||||
|
||||
const favorites = useMemo<Link[]>(
|
||||
const handleChangeCategory = (category: CategoryWithLinks) => {
|
||||
setActiveCategory(category);
|
||||
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
|
||||
};
|
||||
|
||||
const favorites = useMemo<LinkWithCategory[]>(
|
||||
() =>
|
||||
categories.reduce((acc, category) => {
|
||||
category.links.forEach((link) =>
|
||||
link.favorite ? acc.push(link) : null,
|
||||
);
|
||||
return acc;
|
||||
}, [] as Link[]),
|
||||
}, [] as LinkWithCategory[]),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const searchItemBuilder = (
|
||||
item: Category | Link,
|
||||
type: SearchItem['type'],
|
||||
): SearchItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url:
|
||||
type === 'link'
|
||||
? (item as Link).url
|
||||
: `${PATHS.HOME}?categoryId=${item.id}`,
|
||||
type,
|
||||
category: type === 'link' ? (item as Link).category : undefined,
|
||||
});
|
||||
|
||||
const itemsSearch = useMemo<SearchItem[]>(() => {
|
||||
return categories.reduce((acc, category) => {
|
||||
const categoryItem = searchItemBuilder(category, 'category');
|
||||
const items: SearchItem[] = category.links.map((link) =>
|
||||
searchItemBuilder(link, 'link'),
|
||||
);
|
||||
return [...acc, ...items, categoryItem];
|
||||
}, [] as SearchItem[]);
|
||||
}, [categories]);
|
||||
|
||||
// TODO: refactor
|
||||
const toggleFavorite = useCallback(
|
||||
(linkId: Link['id']) => {
|
||||
let linkIndex = 0;
|
||||
const categoryIndex = categories.findIndex(({ links }) => {
|
||||
const lIndex = links.findIndex((l) => l.id === linkId);
|
||||
if (lIndex !== -1) {
|
||||
linkIndex = lIndex;
|
||||
}
|
||||
return lIndex !== -1;
|
||||
});
|
||||
|
||||
const link = categories[categoryIndex].links[linkIndex];
|
||||
const categoriesCopy = [...categories];
|
||||
categoriesCopy[categoryIndex].links[linkIndex] = {
|
||||
...link,
|
||||
favorite: !link.favorite,
|
||||
};
|
||||
|
||||
setCategories(categoriesCopy);
|
||||
if (categories[categoryIndex].id === categoryActive.id) {
|
||||
setCategoryActive(categories[categoryIndex]);
|
||||
}
|
||||
},
|
||||
[categories, categoryActive.id],
|
||||
);
|
||||
|
||||
const handleSelectCategory = (category: Category) => {
|
||||
setCategoryActive(category);
|
||||
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
|
||||
mobileModal.close();
|
||||
};
|
||||
|
||||
const areHotkeysEnabled = { enabled: !searchModal.isShowing };
|
||||
useHotkeys(
|
||||
Keys.OPEN_SEARCH_KEY,
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
searchModal.open();
|
||||
},
|
||||
areHotkeysEnabled,
|
||||
);
|
||||
useHotkeys(Keys.CLOSE_SEARCH_KEY, searchModal.close, {
|
||||
enabled: searchModal.isShowing,
|
||||
enableOnFormTags: ['INPUT'],
|
||||
});
|
||||
|
||||
useHotkeys(
|
||||
Keys.OPEN_CREATE_LINK_KEY,
|
||||
() => {
|
||||
router.push(`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`);
|
||||
router.push(`${PATHS.LINK.CREATE}?categoryId=${activeCategory.id}`);
|
||||
},
|
||||
areHotkeysEnabled,
|
||||
{ enabled: globalHotkeysEnable },
|
||||
);
|
||||
useHotkeys(
|
||||
Keys.OPEN_CREATE_CATEGORY_KEY,
|
||||
() => {
|
||||
router.push('/category/create');
|
||||
router.push(PATHS.CATEGORY.CREATE);
|
||||
},
|
||||
areHotkeysEnabled,
|
||||
{ enabled: globalHotkeysEnable },
|
||||
);
|
||||
|
||||
return (
|
||||
<PageTransition
|
||||
className={clsx('App', 'flex-row')}
|
||||
style={{ flexDirection: 'row' }}
|
||||
hideLangageSelector
|
||||
>
|
||||
{isMobile ? (
|
||||
<UserCard />
|
||||
) : (
|
||||
<SideMenu
|
||||
categories={categories}
|
||||
favorites={favorites}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
categoryActive={categoryActive}
|
||||
openSearchModal={searchModal.open}
|
||||
isModalShowing={searchModal.isShowing}
|
||||
/>
|
||||
)}
|
||||
<Links
|
||||
category={categoryActive}
|
||||
toggleFavorite={toggleFavorite}
|
||||
isMobile={isMobile}
|
||||
openMobileModal={mobileModal.open}
|
||||
openSearchModal={searchModal.open}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{searchModal.isShowing && (
|
||||
<SearchModal
|
||||
close={searchModal.close}
|
||||
categories={categories}
|
||||
items={itemsSearch}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
noHeader={!isMobile}
|
||||
/>
|
||||
)}
|
||||
{mobileModal.isShowing && (
|
||||
<Modal close={mobileModal.close}>
|
||||
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
|
||||
<ButtonLink href={PATHS.CATEGORY.CREATE}>
|
||||
{t('common:category.create')}
|
||||
</ButtonLink>
|
||||
<Categories
|
||||
categories={categories}
|
||||
categoryActive={categoryActive}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
</BlockWrapper>
|
||||
</Modal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<CategoriesContext.Provider value={{ categories, setCategories }}>
|
||||
<ActiveCategoryContext.Provider
|
||||
value={{ activeCategory, setActiveCategory: handleChangeCategory }}
|
||||
>
|
||||
<FavoritesContext.Provider value={{ favorites }}>
|
||||
<GlobalHotkeysContext.Provider
|
||||
value={{
|
||||
globalHotkeysEnabled: globalHotkeysEnable,
|
||||
setGlobalHotkeysEnabled,
|
||||
}}
|
||||
>
|
||||
{isMobile ? <UserCard /> : <SideMenu />}
|
||||
<Links isMobile={isMobile} />
|
||||
</GlobalHotkeysContext.Provider>
|
||||
</FavoritesContext.Provider>
|
||||
</ActiveCategoryContext.Provider>
|
||||
</CategoriesContext.Provider>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -204,15 +106,17 @@ export const getServerSideProps = withAuthentication(
|
||||
};
|
||||
}
|
||||
|
||||
const currentCategory = categories.find(
|
||||
const activeCategory = categories.find(
|
||||
({ id }) => id === Number(queryCategoryId),
|
||||
);
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
categories: JSON.parse(JSON.stringify(categories)),
|
||||
currentCategory: currentCategory
|
||||
? JSON.parse(JSON.stringify(currentCategory))
|
||||
categories: JSON.parse(
|
||||
JSON.stringify(sortCategoriesByNextId(categories)),
|
||||
),
|
||||
activeCategory: activeCategory
|
||||
? JSON.parse(JSON.stringify(activeCategory))
|
||||
: null,
|
||||
...(await getServerSideTranslation(locale, ['home'])),
|
||||
},
|
||||
|
||||
@@ -13,25 +13,25 @@ import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { Category, Link } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory } from 'types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
|
||||
export default function PageCreateLink({
|
||||
categories,
|
||||
}: Readonly<{
|
||||
categories: Category[];
|
||||
categories: CategoryWithLinks[];
|
||||
}>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const autoFocusRef = useAutoFocus();
|
||||
const categoryIdQuery = router.query?.categoryId as string;
|
||||
|
||||
const [name, setName] = useState<Link['name']>('');
|
||||
const [url, setUrl] = useState<Link['url']>('');
|
||||
const [favorite, setFavorite] = useState<Link['favorite']>(false);
|
||||
const [categoryId, setCategoryId] = useState<Link['category']['id']>(
|
||||
Number(categoryIdQuery) || categories?.[0].id || null,
|
||||
);
|
||||
const [name, setName] = useState<LinkWithCategory['name']>('');
|
||||
const [url, setUrl] = useState<LinkWithCategory['url']>('');
|
||||
const [favorite, setFavorite] = useState<LinkWithCategory['favorite']>(false);
|
||||
const [categoryId, setCategoryId] = useState<
|
||||
LinkWithCategory['category']['id']
|
||||
>(Number(categoryIdQuery) || categories?.[0].id || null);
|
||||
|
||||
const [error, setError] = useState<string>(null);
|
||||
const [submitted, setSubmitted] = useState<boolean>(false);
|
||||
|
||||
@@ -14,15 +14,15 @@ import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { Category, Link } from 'types';
|
||||
import { CategoryWithLinks, LinkWithCategory } from 'types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
|
||||
export default function PageEditLink({
|
||||
link,
|
||||
categories,
|
||||
}: Readonly<{
|
||||
link: Link;
|
||||
categories: Category[];
|
||||
link: LinkWithCategory;
|
||||
categories: CategoryWithLinks[];
|
||||
}>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -10,10 +10,12 @@ import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import styles from 'styles/form.module.scss';
|
||||
import { Link } from 'types';
|
||||
import { LinkWithCategory } from 'types';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
|
||||
export default function PageRemoveLink({ link }: Readonly<{ link: Link }>) {
|
||||
export default function PageRemoveLink({
|
||||
link,
|
||||
}: Readonly<{ link: LinkWithCategory }>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import LangSelector from 'components/LangSelector';
|
||||
import MessageManager from 'components/MessageManager/MessageManager';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import PATHS from 'constants/paths';
|
||||
import { getServerSideTranslation } from '../i18n';
|
||||
import getUser from 'lib/user/getUser';
|
||||
import { Provider } from 'next-auth/providers';
|
||||
import { getProviders, signIn } from 'next-auth/react';
|
||||
@@ -13,13 +12,13 @@ import Image from 'next/image';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import styles from 'styles/login.module.scss';
|
||||
import { getSession } from 'utils/session';
|
||||
import { TFunctionParam } from '../types/i18next';
|
||||
import { getServerSideTranslation } from '../i18n';
|
||||
|
||||
interface SignInProps {
|
||||
providers: Provider[];
|
||||
}
|
||||
|
||||
export default function SignIn({ providers }: SignInProps) {
|
||||
export default function SignIn({ providers }: Readonly<SignInProps>) {
|
||||
const { t } = useTranslation('login');
|
||||
|
||||
return (
|
||||
@@ -47,7 +46,7 @@ export default function SignIn({ providers }: SignInProps) {
|
||||
key={id}
|
||||
>
|
||||
<FcGoogle size={'1.5em'} />{' '}
|
||||
{t('login:continue-with', { provider: name } as TFunctionParam)}
|
||||
{t('login:continue-with', { provider: name })}
|
||||
</ButtonLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import LinkTag from 'next/link';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import styles from 'styles/legal-pages.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import Navbar from 'components/Navbar/Navbar';
|
||||
import { getServerSideTranslation } from '../i18n';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import { DefaultSeo } from 'next-seo';
|
||||
import LinkTag from 'next/link';
|
||||
import styles from 'styles/legal-pages.module.scss';
|
||||
import { getServerSideTranslation } from '../i18n';
|
||||
|
||||
export default function Privacy() {
|
||||
const { t } = useTranslation('privacy');
|
||||
@@ -17,9 +16,7 @@ export default function Privacy() {
|
||||
<Navbar />
|
||||
<main>
|
||||
<h1>{t('privacy:title')}</h1>
|
||||
<p>
|
||||
{t('privacy:edited_at', { date: '19/11/2023' } as TFunctionParam)}
|
||||
</p>
|
||||
<p>{t('privacy:edited_at', { date: '19/11/2023' })}</p>
|
||||
<p>{t('privacy:welcome')}</p>
|
||||
|
||||
<h2>{t('privacy:collect.title')}</h2>
|
||||
@@ -29,11 +26,9 @@ export default function Privacy() {
|
||||
<h3>{t('privacy:collect.user.title')}</h3>
|
||||
<p>{t('privacy:collect.user.description')}</p>
|
||||
<ul>
|
||||
{(
|
||||
t('privacy:collect.user.fields', {
|
||||
returnObjects: true,
|
||||
} as TFunctionParam) as Array<string>
|
||||
).map((field) => (
|
||||
{t('privacy:collect.user.fields', {
|
||||
returnObjects: true,
|
||||
}).map((field) => (
|
||||
<li key={field}>{field}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import styles from 'styles/legal-pages.module.scss';
|
||||
import clsx from 'clsx';
|
||||
import LinkTag from 'next/link';
|
||||
import Navbar from 'components/Navbar/Navbar';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import PATHS from 'constants/paths';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { TFunctionParam } from 'types/i18next';
|
||||
import PATHS from 'constants/paths';
|
||||
import { DefaultSeo } from 'next-seo';
|
||||
import LinkTag from 'next/link';
|
||||
import styles from 'styles/legal-pages.module.scss';
|
||||
|
||||
export default function Terms() {
|
||||
const { t } = useTranslation('terms');
|
||||
@@ -17,7 +16,7 @@ export default function Terms() {
|
||||
<Navbar />
|
||||
<main>
|
||||
<h1>{t('terms:title')}</h1>
|
||||
<p>{t('terms:edited_at', { date: '19/11/2023' } as TFunctionParam)}</p>
|
||||
<p>{t('terms:edited_at', { date: '19/11/2023' })}</p>
|
||||
<p>{t('terms:welcome')}</p>
|
||||
|
||||
<h2>{t('terms:accept.title')}</h2>
|
||||
|
||||
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' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common';
|
||||
resources: typeof resources;
|
||||
resources: (typeof resources)['en'];
|
||||
returnNull: false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ugly hack because of the above declaration, I cant use "t" function params
|
||||
type TFunctionParam = undefined;
|
||||
|
||||
40
src/types/types.d.ts
vendored
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
|
||||
// recreate interface (same for Link)
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
links: Link[];
|
||||
authorId: User['id'];
|
||||
export type CategoryWithLinks = Category & {
|
||||
author: User;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id: number;
|
||||
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
category: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
authorId: User['id'];
|
||||
links: LinkWithCategory[];
|
||||
};
|
||||
export type LinkWithCategory = LinkWithCategory & {
|
||||
author: User;
|
||||
favorite: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
category: CategoryWithLinks;
|
||||
};
|
||||
|
||||
export interface SearchItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'category' | 'link';
|
||||
category?: undefined | Link['category'];
|
||||
category?: undefined | LinkWithCategory['category'];
|
||||
}
|
||||
|
||||
export interface Favicon {
|
||||
|
||||
@@ -18,3 +18,22 @@ export function groupItemBy(array: any[], property: string) {
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Thanks S/O
|
||||
export function arrayMove<T>(
|
||||
arr: T[],
|
||||
previousIndex: number,
|
||||
nextIndex: number,
|
||||
): T[] {
|
||||
const arrayCopy = [...arr];
|
||||
const [removedElement] = arrayCopy.splice(previousIndex, 1);
|
||||
|
||||
if (nextIndex >= arr.length) {
|
||||
// Pad the array with undefined elements if needed
|
||||
const padding = nextIndex - arr.length + 1;
|
||||
arrayCopy.push(...new Array(padding).fill(undefined));
|
||||
}
|
||||
|
||||
arrayCopy.splice(nextIndex, 0, removedElement);
|
||||
return arrayCopy;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user