mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 22:53:25 +00:00
migration vers typescript + refonte total de l'app
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": "next",
|
||||||
|
"rules": {
|
||||||
|
"react/no-unescaped-entities": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { signIn, signOut } from "next-auth/react"
|
|
||||||
|
|
||||||
import ModalAddCategory from './ModalAddCategory';
|
|
||||||
|
|
||||||
import styles from '../../styles/categories.module.scss';
|
|
||||||
|
|
||||||
export default function Categories({
|
|
||||||
categories,
|
|
||||||
favorites,
|
|
||||||
handleSelectCategory,
|
|
||||||
categoryActive,
|
|
||||||
session
|
|
||||||
}) {
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
return (<div className={styles['categories-wrapper']}>
|
|
||||||
<div className={`${styles['block-wrapper']} ${styles['favorites']}`}>
|
|
||||||
<h4>Favoris</h4>
|
|
||||||
<ul className={styles['items']}>
|
|
||||||
{favorites.map((link, key) => {
|
|
||||||
const { name, url, categoryName } = link;
|
|
||||||
return <li key={key} className={styles['item']}>
|
|
||||||
<a href={url} target={'_blank'} rel={'noreferrer'}>
|
|
||||||
{name} <span className={styles['category']}>- {categoryName}</span>
|
|
||||||
</a>
|
|
||||||
</li>;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles['block-wrapper']} ${styles['categories']}`}>
|
|
||||||
<h4>Catégories</h4>
|
|
||||||
<ul className={styles['items']}>
|
|
||||||
{categories.map(({ id, name }, key) => {
|
|
||||||
const className = `${styles['item']} ${id === categoryActive ? styles['active'] : ''}`;
|
|
||||||
const onClick = () => handleSelectCategory(id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={key} className={className} onClick={onClick}>
|
|
||||||
{name}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{session !== null ?
|
|
||||||
<button onClick={() => setModalOpen((state) => !state)}>
|
|
||||||
Ajouter une catégorie
|
|
||||||
</button> :
|
|
||||||
<div className={`${styles['block-wrapper']} ${styles['controls']}`} onClick={signIn}>
|
|
||||||
<button>Se connecter</button>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
<ModalAddCategory
|
|
||||||
categories={categories}
|
|
||||||
isOpen={modalOpen}
|
|
||||||
closeModal={() => setModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
98
components/Categories/Categories.tsx
Normal file
98
components/Categories/Categories.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { signOut } from "next-auth/react"
|
||||||
|
import { Session } from 'next-auth';
|
||||||
|
import LinkTag from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import styles from '../../styles/home/categories.module.scss';
|
||||||
|
import { Category, Link } from '../../types';
|
||||||
|
|
||||||
|
interface CategoryProps {
|
||||||
|
categories: Category[];
|
||||||
|
favorites: Link[];
|
||||||
|
handleSelectCategory: (category: Category) => void;
|
||||||
|
categoryActive: Category;
|
||||||
|
session: Session;
|
||||||
|
}
|
||||||
|
export default function Categories({
|
||||||
|
categories,
|
||||||
|
favorites,
|
||||||
|
handleSelectCategory,
|
||||||
|
categoryActive,
|
||||||
|
session
|
||||||
|
}: CategoryProps) {
|
||||||
|
return (<div className={styles['categories-wrapper']}>
|
||||||
|
<div className={`${styles['block-wrapper']} ${styles['favorites']}`}>
|
||||||
|
<h4>Favoris</h4>
|
||||||
|
<ul className={styles['items']}>
|
||||||
|
{favorites.map((link, key) => (
|
||||||
|
<LinkFavorite
|
||||||
|
link={link}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles['block-wrapper']} ${styles['categories']}`}>
|
||||||
|
<h4>Catégories</h4>
|
||||||
|
<ul className={styles['items']}>
|
||||||
|
{categories.map((category, key) => (
|
||||||
|
<CategoryItem
|
||||||
|
category={category}
|
||||||
|
categoryActive={categoryActive}
|
||||||
|
handleSelectCategory={handleSelectCategory}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={styles['controls']}>
|
||||||
|
<LinkTag href={'/category/create'}>
|
||||||
|
<a>Créer categorie</a>
|
||||||
|
</LinkTag>
|
||||||
|
<LinkTag href={'/link/create'}>
|
||||||
|
<a>Créer lien</a>
|
||||||
|
</LinkTag>
|
||||||
|
</div>
|
||||||
|
<div className={styles['user-card-wrapper']}>
|
||||||
|
<div className={styles['user-card']}>
|
||||||
|
<Image
|
||||||
|
src={session.user.image}
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
alt={`${session.user.name}'s avatar`}
|
||||||
|
/>
|
||||||
|
{session.user.name}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => signOut()} className={styles['disconnect-btn']}>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkFavorite({ link }: { link: Link; }): JSX.Element {
|
||||||
|
const { name, url, category } = link;
|
||||||
|
return (
|
||||||
|
<li className={styles['item']}>
|
||||||
|
<a href={url} target={'_blank'} rel={'noreferrer'}>
|
||||||
|
{name} <span className={styles['category']}>- {category.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryItemProps {
|
||||||
|
category: Category;
|
||||||
|
categoryActive: Category;
|
||||||
|
handleSelectCategory: (category: Category) => void;
|
||||||
|
}
|
||||||
|
function CategoryItem({ category, categoryActive, handleSelectCategory }: CategoryItemProps): JSX.Element {
|
||||||
|
const className = `${styles['item']} ${category.id === categoryActive.id ? styles['active'] : ''}`;
|
||||||
|
const onClick = () => handleSelectCategory(category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={className} onClick={onClick}>
|
||||||
|
{category.name}<span className={styles['links-count']}> — {category.links.length}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import styles from '../../styles/links.module.scss';
|
|
||||||
|
|
||||||
export default function Link({ link }) {
|
|
||||||
return (
|
|
||||||
<li className={styles['link']}>
|
|
||||||
<a href={link?.url} target={'_blank'} rel={'noreferrer'}>
|
|
||||||
<span className={styles['link-name']}>{link?.name} <span className={styles['link-category']}>— {link?.categoryName}</span></span> <span className={styles['link-url']}>{link?.url}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
|
||||||
import { useInView } from 'react-intersection-observer';
|
|
||||||
|
|
||||||
import Link from './Link';
|
|
||||||
|
|
||||||
import styles from '../../styles/links.module.scss';
|
|
||||||
|
|
||||||
export default function LinkBlock({ category, setCategoryActive, refCategoryActive }) {
|
|
||||||
const { ref, inView } = useInView({ threshold: .5 });
|
|
||||||
const { id, name, links, ref: refCategory } = category;
|
|
||||||
|
|
||||||
useEffect(() => inView ? setCategoryActive(id) : null, [id, inView, setCategoryActive]);
|
|
||||||
|
|
||||||
const setRefs = useCallback((node) => {
|
|
||||||
refCategory.current = node;
|
|
||||||
refCategoryActive.current = node;
|
|
||||||
ref(node);
|
|
||||||
}, [ref, refCategoryActive, refCategory]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles['link-block']} ref={setRefs}>
|
|
||||||
<h2>{name}</h2>
|
|
||||||
<ul className={styles['links']}>
|
|
||||||
{links.map((link, key2) => (
|
|
||||||
<Link key={key2} link={link} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import LinkBlock from './LinkBlock';
|
|
||||||
|
|
||||||
import styles from '../../styles/links.module.scss';
|
|
||||||
|
|
||||||
export default function Links({ categories, setCategoryActive, refCategoryActive }) {
|
|
||||||
return (<div className={styles['links-wrapper']}>
|
|
||||||
{categories.map((category, key) => (
|
|
||||||
<LinkBlock
|
|
||||||
key={key}
|
|
||||||
category={category}
|
|
||||||
setCategoryActive={setCategoryActive}
|
|
||||||
refCategoryActive={refCategoryActive}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
56
components/Links/Links.tsx
Normal file
56
components/Links/Links.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import styles from '../../styles/home/links.module.scss';
|
||||||
|
import { Category, Link } from '../../types';
|
||||||
|
|
||||||
|
export default function Links({ category }: { category: Category; }) {
|
||||||
|
const { name, links } = category;
|
||||||
|
|
||||||
|
return (<div className={styles['links-wrapper']}>
|
||||||
|
<h2>{name}<span className={styles['links-count']}> — {links.length}</span></h2>
|
||||||
|
<ul className={styles['links']}>
|
||||||
|
{links.map((link, key) => (
|
||||||
|
<LinkItem key={key} link={link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkItem({ link }: { link: Link; }) {
|
||||||
|
const { name, url, category } = link;
|
||||||
|
const { origin, pathname, search } = new URL(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={styles['link']}>
|
||||||
|
<a href={url} target={'_blank'} rel={'noreferrer'}>
|
||||||
|
<span className={styles['link-name']}>
|
||||||
|
{name}<span className={styles['link-category']}> — {category.name}</span>
|
||||||
|
</span>
|
||||||
|
<LinkItemURL
|
||||||
|
origin={origin}
|
||||||
|
pathname={pathname}
|
||||||
|
search={search}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkItemURL({ origin, pathname, search }) {
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
if (pathname !== '/') {
|
||||||
|
text += pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search !== '') {
|
||||||
|
if (text === '') {
|
||||||
|
text += '/';
|
||||||
|
}
|
||||||
|
text += search;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles['link-url']}>
|
||||||
|
{origin}<span className={styles['url-pathname']}>{text}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
components/input.tsx
Normal file
57
components/input.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { MutableRefObject, useState } from "react";
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
labelComponent?: JSX.Element;
|
||||||
|
type?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
innerRef?: MutableRefObject<any>;
|
||||||
|
placeholder?: string;
|
||||||
|
fieldClass?: string;
|
||||||
|
value?: string;
|
||||||
|
onChangeCallback: ({ target }, value) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
labelComponent,
|
||||||
|
type = 'text',
|
||||||
|
multiple = false,
|
||||||
|
innerRef = null,
|
||||||
|
placeholder = 'Type something...',
|
||||||
|
fieldClass = '',
|
||||||
|
value,
|
||||||
|
onChangeCallback
|
||||||
|
}: InputProps): JSX.Element {
|
||||||
|
const [inputValue, setInputValue] = useState<string>(value);
|
||||||
|
|
||||||
|
function onChange({ target }) {
|
||||||
|
setInputValue(target.value);
|
||||||
|
onChangeCallback({ target }, target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<div className={`input-field ${fieldClass}`}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={name} title={`${name} field`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!!labelComponent && (
|
||||||
|
<label htmlFor={name} title={`${name} field`}>
|
||||||
|
{labelComponent}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
onChange={onChange}
|
||||||
|
value={inputValue}
|
||||||
|
multiple={multiple}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={innerRef}
|
||||||
|
/>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
52
components/selector.tsx
Normal file
52
components/selector.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { MutableRefObject, useState } from "react";
|
||||||
|
|
||||||
|
interface SelectorProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
labelComponent?: JSX.Element;
|
||||||
|
innerRef?: MutableRefObject<any>;
|
||||||
|
fieldClass?: string;
|
||||||
|
value?: string | number;
|
||||||
|
onChangeCallback: ({ target }, value) => void;
|
||||||
|
children?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Selector({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
labelComponent,
|
||||||
|
innerRef = null,
|
||||||
|
fieldClass = '',
|
||||||
|
value,
|
||||||
|
onChangeCallback,
|
||||||
|
children
|
||||||
|
}: SelectorProps): JSX.Element {
|
||||||
|
const [inputValue, setInputValue] = useState<string | number>(value);
|
||||||
|
|
||||||
|
function onChange({ target }) {
|
||||||
|
setInputValue(target.value);
|
||||||
|
onChangeCallback({ target }, target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<div className={`input-field ${fieldClass}`}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={name} title={`${name} field`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!!labelComponent && (
|
||||||
|
<label htmlFor={name} title={`${name} field`}>
|
||||||
|
{labelComponent}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
onChange={onChange}
|
||||||
|
value={inputValue}
|
||||||
|
ref={innerRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
7
example.env.local
Normal file
7
example.env.local
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
|
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
domains: ['lh3.googleusercontent.com']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5920
package-lock.json
generated
5920
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -8,19 +8,24 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "3.6.0",
|
"@prisma/client": "^3.13.0",
|
||||||
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"next": "12.0.7",
|
"next": "^12.1.5",
|
||||||
"next-auth": "^4.0.6",
|
"next-auth": "^4.0.6",
|
||||||
"react": "17.0.2",
|
"nprogress": "^0.2.0",
|
||||||
"react-dom": "17.0.2",
|
"react": "^18.1.0",
|
||||||
"react-intersection-observer": "^8.33.1",
|
"react-dom": "^18.1.0",
|
||||||
"react-modal": "^3.14.4",
|
"react-modal": "^3.14.4",
|
||||||
|
"react-redux": "^8.0.1",
|
||||||
"sass": "^1.46.0"
|
"sass": "^1.46.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^17.0.29",
|
||||||
|
"@types/nprogress": "^0.2.0",
|
||||||
|
"@types/react": "^18.0.8",
|
||||||
"eslint": "7",
|
"eslint": "7",
|
||||||
"eslint-config-next": "12.0.7",
|
"eslint-config-next": "12.0.7",
|
||||||
"prisma": "3.6.0"
|
"prisma": "^3.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import '../styles/globals.scss';
|
|
||||||
import { SessionProvider } from 'next-auth/react';
|
|
||||||
|
|
||||||
function MyApp({
|
|
||||||
Component,
|
|
||||||
pageProps: { session, ...pageProps }
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SessionProvider session={session}>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MyApp;
|
|
||||||
34
pages/_app.tsx
Normal file
34
pages/_app.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
import nProgress from "nprogress";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import '../styles/globals.scss';
|
||||||
|
import "nprogress/nprogress.css"
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
function MyApp({
|
||||||
|
Component,
|
||||||
|
pageProps: { session, ...pageProps }
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => { // Chargement pages
|
||||||
|
router.events.on('routeChangeStart', nProgress.start);
|
||||||
|
router.events.on('routeChangeComplete', nProgress.done);
|
||||||
|
router.events.on('routeChangeError', nProgress.done);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeStart', nProgress.start);
|
||||||
|
router.events.off('routeChangeComplete', nProgress.done);
|
||||||
|
router.events.off('routeChangeError', nProgress.done);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp;
|
||||||
17
pages/_document.tsx
Normal file
17
pages/_document.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Html, Head, Main, NextScript } from 'next/document';
|
||||||
|
|
||||||
|
const Document = () => (
|
||||||
|
<Html lang='fr'>
|
||||||
|
<Head />
|
||||||
|
<title>Superpipo</title>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
Vous devez activer JavaScript pour utiliser ce site
|
||||||
|
</noscript>
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Document;
|
||||||
@@ -1,48 +1,30 @@
|
|||||||
import NextAuth from 'next-auth'
|
import NextAuth from 'next-auth';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
import bcrypt from 'bcrypt';
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default NextAuth({
|
export default NextAuth({
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
GoogleProvider({
|
||||||
name: 'Credentials',
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
credentials: {
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
username: { label: 'Email', type: 'text', placeholder: 'user@example.com' },
|
authorization: {
|
||||||
password: { label: 'Mot de passe', type: 'password', placeholder: '********' }
|
params: {
|
||||||
},
|
prompt: "consent",
|
||||||
async authorize(credentials, req) {
|
access_type: "offline",
|
||||||
return { email: 'user@example.com' };
|
response_type: "code"
|
||||||
|
|
||||||
const email = credentials?.email;
|
|
||||||
const password = credentials?.password;
|
|
||||||
|
|
||||||
if (!email || !password)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await prisma.user.findUnique({
|
|
||||||
where: { email }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Impossible de récupérer l'utilisateur avec les identifiants : ${credentials}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
|
||||||
if (!passwordMatch)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
email: user.email
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
})
|
callbacks: {
|
||||||
|
async signIn({ account, profile }) {
|
||||||
|
if (account.provider === "google" && profile.email !== '') {
|
||||||
|
if (profile.email_verified && profile.email.endsWith("@gmail.com")) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return "/signin?error=" + encodeURI('Une erreur s\'est produite lors de l\'authentification');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
45
pages/category/create.tsx
Normal file
45
pages/category/create.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import Input from '../../components/input';
|
||||||
|
import styles from '../../styles/create.module.scss';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
export default function CreateCategory() {
|
||||||
|
const { data: session, status } = useSession({ required: true });
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (<p>Chargement de la session en cours</p>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log('On peut envoyer la requête pour créer une catégorie');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`App ${styles['create-app']}`}>
|
||||||
|
<Head>
|
||||||
|
<title>Superpipo — Créer une categorie</title>
|
||||||
|
</Head>
|
||||||
|
<h2>Créer une categorie</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
name='name'
|
||||||
|
label='Nom de la catégorie'
|
||||||
|
onChangeCallback={({ target }, value) => setName(value)}
|
||||||
|
value={name}
|
||||||
|
fieldClass={styles['input-field']}
|
||||||
|
/>
|
||||||
|
<button type='submit' disabled={name.length < 1}>
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<Link href='/'>
|
||||||
|
<a>← Revenir à l'accueil</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { createRef, useRef, useState } from 'react';
|
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
|
|
||||||
import Categories from '../components/Categories/Categories';
|
|
||||||
import Links from '../components/Links/Links';
|
|
||||||
|
|
||||||
import styles from '../styles/Home.module.scss';
|
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default function Home({ categories, favorites }) {
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const [categoryActive, setCategoryActive] = useState(categories?.[0]?.id);
|
|
||||||
const refCategoryActive = useRef();
|
|
||||||
|
|
||||||
const handleSelectCategory = (id) => {
|
|
||||||
if (!refCategoryActive?.current) return;
|
|
||||||
|
|
||||||
const { ref } = categories.find(c => c.id === id);
|
|
||||||
ref?.current?.scrollIntoView({
|
|
||||||
block: 'end',
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(session, session?.user);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.App}>
|
|
||||||
<Categories
|
|
||||||
categories={categories}
|
|
||||||
favorites={favorites}
|
|
||||||
handleSelectCategory={handleSelectCategory}
|
|
||||||
categoryActive={categoryActive}
|
|
||||||
session={session}
|
|
||||||
/>
|
|
||||||
<Links
|
|
||||||
categories={categories}
|
|
||||||
setCategoryActive={setCategoryActive}
|
|
||||||
refCategoryActive={refCategoryActive}
|
|
||||||
session={session}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStaticProps(context) {
|
|
||||||
const categories = await prisma.category.findMany({
|
|
||||||
include: {
|
|
||||||
links: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const favorites = [];
|
|
||||||
categories.map((category) => {
|
|
||||||
category['ref'] = createRef();
|
|
||||||
category['links'] = category.links.map((link) => {
|
|
||||||
if (link.favorite)
|
|
||||||
favorites.push(link);
|
|
||||||
|
|
||||||
link['categoryName'] = category.name;
|
|
||||||
return link;
|
|
||||||
});
|
|
||||||
|
|
||||||
return category;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
categories: JSON.parse(JSON.stringify(categories)),
|
|
||||||
favorites: JSON.parse(JSON.stringify(favorites))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
93
pages/index.tsx
Normal file
93
pages/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { createRef, useRef, useState } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import Head from 'next/head'
|
||||||
|
|
||||||
|
import Categories from '../components/Categories/Categories';
|
||||||
|
import Links from '../components/Links/Links';
|
||||||
|
|
||||||
|
import { Category, Link } from '../types';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
import { store } from '../redux';
|
||||||
|
|
||||||
|
interface HomeProps {
|
||||||
|
categories: Category[];
|
||||||
|
favorites: Link[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ categories, favorites }: HomeProps) {
|
||||||
|
const { data: session, status } = useSession({ required: true });
|
||||||
|
const [categoryActive, setCategoryActive] = useState<Category | null>(categories?.[0]);
|
||||||
|
|
||||||
|
const handleSelectCategory = (category: Category) => setCategoryActive(category);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (<p>Chargement de la session en cours</p>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<Head>
|
||||||
|
<title>Superpipo</title>
|
||||||
|
</Head>
|
||||||
|
<div className='App'>
|
||||||
|
<Categories
|
||||||
|
categories={categories}
|
||||||
|
favorites={favorites}
|
||||||
|
handleSelectCategory={handleSelectCategory}
|
||||||
|
categoryActive={categoryActive}
|
||||||
|
session={session}
|
||||||
|
/>
|
||||||
|
<Links category={categoryActive} />
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const categoriesDB = await prisma.category.findMany({ include: { links: true } });
|
||||||
|
|
||||||
|
const favorites = [] as Link[];
|
||||||
|
const categories = categoriesDB.map((categoryDB) => {
|
||||||
|
const category = BuildCategory(categoryDB);
|
||||||
|
category.links.map((link) => link.favorite ? favorites.push(link) : null);
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
categories: JSON.parse(JSON.stringify(categories)),
|
||||||
|
favorites: JSON.parse(JSON.stringify(favorites)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BuildCategory({ id, name, order, links = [], createdAt, updatedAt }): Category {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
links: links.map((link) => BuildLink(link, { categoryId: id, categoryName: name })),
|
||||||
|
order,
|
||||||
|
createdAt,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BuildLink({ id, name, url, order, favorite, createdAt, updatedAt }, { categoryId, categoryName }): Link {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
category: {
|
||||||
|
id: categoryId,
|
||||||
|
name: categoryName
|
||||||
|
},
|
||||||
|
order,
|
||||||
|
favorite,
|
||||||
|
createdAt,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
94
pages/link/create.tsx
Normal file
94
pages/link/create.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import Input from '../../components/input';
|
||||||
|
import styles from '../../styles/create.module.scss';
|
||||||
|
|
||||||
|
import { Category } from '../../types';
|
||||||
|
import { BuildCategory } from '..';
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import Selector from '../../components/selector';
|
||||||
|
import Head from 'next/head';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default function CreateLink({ categories }: { categories: Category[]; }) {
|
||||||
|
const { status } = useSession({ required: true });
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
|
const [categoryId, setCategoryId] = useState<number | null>(categories?.[0].id || null);
|
||||||
|
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const regex = new RegExp('https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,}');
|
||||||
|
if (name !== '' && url.match(regex) && categoryId !== null) {
|
||||||
|
setCanSubmit(false);
|
||||||
|
} else {
|
||||||
|
setCanSubmit(true);
|
||||||
|
}
|
||||||
|
}, [name, url, categoryId]);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (<p>Chargement de la session en cours</p>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log('On peut envoyer la requête pour créer un lien');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<Head>
|
||||||
|
<title>Superpipo — Créer un lien</title>
|
||||||
|
</Head>
|
||||||
|
<div className={`App ${styles['create-app']}`}>
|
||||||
|
<h2>Créer un lien</h2>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
name='name'
|
||||||
|
label='Label'
|
||||||
|
onChangeCallback={({ target }, value) => setName(value)}
|
||||||
|
value={name}
|
||||||
|
fieldClass={styles['input-field']}
|
||||||
|
placeholder='Label du lien'
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name='url'
|
||||||
|
label='URL'
|
||||||
|
onChangeCallback={({ target }, value) => setUrl(value)}
|
||||||
|
value={name}
|
||||||
|
fieldClass={styles['input-field']}
|
||||||
|
placeholder='URL du lien'
|
||||||
|
/>
|
||||||
|
<Selector
|
||||||
|
name='category'
|
||||||
|
label='Catégorie'
|
||||||
|
value={categoryId}
|
||||||
|
onChangeCallback={({ target }, value) => setCategoryId(value)}
|
||||||
|
>
|
||||||
|
{categories.map((category, key) => (
|
||||||
|
<option key={key} value={category.id}>{category.name}</option>
|
||||||
|
))}
|
||||||
|
</Selector>
|
||||||
|
<button type='submit' disabled={canSubmit}>
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<Link href='/'>
|
||||||
|
<a>← Revenir à l'accueil</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const categoriesDB = await prisma.category.findMany();
|
||||||
|
const categories = categoriesDB.map((categoryDB) => BuildCategory(categoryDB));
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
categories: JSON.parse(JSON.stringify(categories))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
redux.ts
Normal file
89
redux.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { configureStore, createSlice } from "@reduxjs/toolkit";
|
||||||
|
import { Category, Link } from "./types";
|
||||||
|
|
||||||
|
const categoriesSlice = createSlice({
|
||||||
|
name: 'categories',
|
||||||
|
initialState: [] as Category[],
|
||||||
|
reducers: {
|
||||||
|
addCategory: (state: Category[], { payload }: { payload: Category }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
categories.push(payload);
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
editCategory: (state: Category[], { payload }: { payload: Category }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
const categoryIndex = categories.findIndex(category => category.id === payload.id);
|
||||||
|
if (categoryIndex === -1) {
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[categoryIndex] = payload;
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
removeCategory: (state: Category[], { payload }: { payload: Category }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
const categoryIndex = categories.findIndex(category => category.id === payload.id);
|
||||||
|
if (categoryIndex === -1) {
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.slice(categoryIndex, 1);
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
|
||||||
|
addLink: (state: Category[], { payload }: { payload: Link }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
const categoryIndex = state.findIndex(link => link.id === payload.id);
|
||||||
|
if (categoryIndex === -1) {
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[categoryIndex].links.push(payload);
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
editLink: (state: Category[], { payload }: { payload: Link }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
const categoryIndex = state.findIndex(link => link.id === payload.id);
|
||||||
|
if (categoryIndex === -1) {
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkIndex = categories[categoryIndex].links.findIndex(link => link.id === payload.id);
|
||||||
|
if (linkIndex === -1) {
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[categoryIndex].links[linkIndex] = payload;
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
removeLink: (state: Category[], { payload }: { payload: Link }) => {
|
||||||
|
const categories = [...state];
|
||||||
|
const categoryIndex = state.findIndex(link => link.id === payload.id);
|
||||||
|
if (categoryIndex === -1) {
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkIndex = categories[categoryIndex].links.findIndex(link => link.id === payload.id);
|
||||||
|
if (linkIndex === -1) {
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[categoryIndex].links.splice(linkIndex, 1);
|
||||||
|
return categories;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
categories: categoriesSlice.reducer
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
addCategory,
|
||||||
|
addLink,
|
||||||
|
editLink,
|
||||||
|
removeLink
|
||||||
|
} = categoriesSlice.actions;
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.App {
|
|
||||||
height: 100%;
|
|
||||||
width: 1280px;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
.App {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
styles/create.module.scss
Normal file
27
styles/create.module.scss
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.create-app {
|
||||||
|
width: 680px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
color: #3f88c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
& form {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .input-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.create-app {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: #111;
|
color: #111;
|
||||||
|
background-color: #f0eef6;
|
||||||
font-family: "Poppins", sans-serif;
|
font-family: "Poppins", sans-serif;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -27,6 +28,28 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.App {
|
||||||
|
height: 100%;
|
||||||
|
width: 1280px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: fit-content;
|
||||||
|
color: #3f88c5;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: 0.15s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: #3d7bab;
|
||||||
|
border-bottom: 1px solid #3d7bab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -61,9 +84,61 @@ button {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid #3f88c5;
|
border: 1px solid #3f88c5;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: .15s;
|
transition: 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:disabled {
|
||||||
transform: scale(1.01);
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
box-shadow: #105b97 0 0 3px 1px;
|
||||||
|
background: #105b97;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not(.nostyle) {
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-bottom: 3px solid #dadce0;
|
||||||
|
transition: 0.15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-bottom: 3px solid #3f88c5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
font-style: italic;
|
||||||
|
color: #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
& label,
|
||||||
|
& input,
|
||||||
|
& select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select:not(.nostyle) {
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-bottom: 3px solid #dadce0;
|
||||||
|
transition: 0.15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-bottom: 3px solid #3f88c5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.App {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@
|
|||||||
& .block-wrapper {
|
& .block-wrapper {
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 15px;
|
|
||||||
|
|
||||||
& h4 {
|
& h4 {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: .85em;
|
font-size: 0.85em;
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
@@ -26,13 +25,22 @@
|
|||||||
& .items .item {
|
& .items .item {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 5px 10px;
|
background-color: #fff;
|
||||||
|
padding: 7px 12px;
|
||||||
border: 1px solid #dadce0;
|
border: 1px solid #dadce0;
|
||||||
border-bottom: 2px solid #dadce0;
|
border-bottom: 2px solid #dadce0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-bottom: 5px;
|
transition: 0.15s;
|
||||||
transition: .15s;
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .links-count {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: #3f88c5;
|
background: #3f88c5;
|
||||||
@@ -41,7 +49,7 @@
|
|||||||
|
|
||||||
&:hover:not(.active) {
|
&:hover:not(.active) {
|
||||||
color: #3f88c5;
|
color: #3f88c5;
|
||||||
background: #eee;
|
background: #f0eef6;
|
||||||
border-bottom: 2px solid #3f88c5;
|
border-bottom: 2px solid #3f88c5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +57,8 @@
|
|||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
& .block-wrapper.favorites {
|
& .block-wrapper.favorites {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
& .items .item a {
|
& .items .item a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -56,7 +66,7 @@
|
|||||||
|
|
||||||
& .items .item .category {
|
& .items .item .category {
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
font-size: .85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
& .items {
|
& .items {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
@@ -77,16 +87,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
& .block-wrapper.controls {
|
.controls {
|
||||||
margin-bottom: 0;
|
margin: 10px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
// User Card
|
||||||
height: 500px;
|
& .user-card-wrapper {
|
||||||
width: 500px;
|
position: relative;
|
||||||
}
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
|
|
||||||
|
& .user-card {
|
||||||
|
border-right: 1px solid #dadce0;
|
||||||
|
padding: 7px 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .disconnect-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
color: #fff;
|
||||||
|
background-color: red;
|
||||||
|
border: 1px solid red;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .disconnect-btn {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid darkred;
|
||||||
|
box-shadow: red 0 0 3px 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
styles/home/links.module.scss
Normal file
72
styles/home/links.module.scss
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
.links-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
color: #3f88c5;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
& .links-count {
|
||||||
|
color: #bbb;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .links {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .links .link {
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
color: #3f88c5;
|
||||||
|
background-color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-bottom: 2px solid #dadce0;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: 0.1s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-color: #3f88c5;
|
||||||
|
background: #f0eef6;
|
||||||
|
|
||||||
|
& .url-pathname {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .link-name .link-category {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .link-url {
|
||||||
|
width: 100%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 0.8em;
|
||||||
|
|
||||||
|
& .url-pathname {
|
||||||
|
opacity: 0;
|
||||||
|
transition: 0.1s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
.links-wrapper {
|
|
||||||
height: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
flex: 1;
|
|
||||||
overflow-x: auto;
|
|
||||||
scroll-snap-type: y mandatory;
|
|
||||||
|
|
||||||
& .link-block {
|
|
||||||
min-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
scroll-snap-align: center;
|
|
||||||
|
|
||||||
& h2 {
|
|
||||||
color: #3f88c5;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .links {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .links .link {
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& > a {
|
|
||||||
color: #3f88c5;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border: 1px solid #dadce0;
|
|
||||||
border-bottom: 2px solid #dadce0;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: .05s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-bottom-color: #3f88c5;
|
|
||||||
background: #eee;
|
|
||||||
margin: 0 5px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .link-name .link-category {
|
|
||||||
color: #bbb;
|
|
||||||
font-size: .9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .link-url {
|
|
||||||
width: 100%;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #bbb;
|
|
||||||
font-size: .8em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
28
types.d.ts
vendored
Normal file
28
types.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
links: Link[];
|
||||||
|
order: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
category: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
order: number;
|
||||||
|
favorite: boolean;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user