Beaucoup trop de chose

- Ajout création, édition, suppression catégories & liens
- Ajout auth google
- Ajout/modification style pour catégories & liens
- Ajout component générique pour bouton, inputs, checkbox & selector
- Gestion des messages d'erreur/succès/infos via component dédié
- Ajout component FormLayout pour les pages création, édition, suppression catégories & liens
- Page custom 404, 500 & signin
- Modification schéma DB
This commit is contained in:
Sonny
2022-05-06 19:35:12 +02:00
parent 988dc47ecd
commit aee3e6a820
48 changed files with 7164 additions and 1122 deletions

2
.env
View File

@@ -1 +1 @@
DATABASE_URL="mysql://hp_user:oxU9ExgAHXktIhQZ@79.143.186.18:3306/Superpipo"
DATABASE_URL="mysql://root:@localhost:3306/superpipo"

View File

@@ -0,0 +1,20 @@
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router';
export default function Auth({ children }) {
const router = useRouter();
const { status } = useSession({
required: true,
onUnauthenticated: () => router.push(`/signin?info=${encodeURI('Vous devez être connecté pour accéder à cette page')}`)
});
if (status === 'loading') {
return (
<div className='App' style={{ alignItems: 'center' }}>
<p style={{ height: 'fit-content' }}>Chargement de la session en cours</p>
</div>
);
}
return children;
}

View File

@@ -1,6 +1,11 @@
import LinkTag from 'next/link';
import styles from '../../styles/home/categories.module.scss';
import { Category } from '../../types';
import EditSVG from '../../public/icons/edit.svg';
import RemoveSVG from '../../public/icons/remove.svg';
interface CategoriesProps {
categories: Category[];
categoryActive: Category;
@@ -35,7 +40,28 @@ function CategoryItem({ category, categoryActive, handleSelectCategory }: Catego
return (
<li className={className} onClick={onClick}>
{category.name}<span className={styles['links-count']}> {category.links.length}</span>
<div className={styles['content']}>
<span className={styles['name']}>{category.name}</span>
<span className={styles['links-count']}> {category.links.length}</span>
</div>
<MenuOptions id={category.id} />
</li>
)
}
function MenuOptions({ id }: { id: number; }): JSX.Element {
return (
<div className={styles['menu-item']}>
<LinkTag href={`/category/edit/${id}`}>
<a className={styles['option-edit']}>
<EditSVG />
</a>
</LinkTag>
<LinkTag href={`/category/remove/${id}`}>
<a className={styles['option-remove']}>
<RemoveSVG />
</a>
</LinkTag>
</div>
)
}

View File

@@ -1,12 +1,13 @@
import { Session } from 'next-auth';
import LinkTag from 'next/link';
import styles from '../../styles/home/categories.module.scss';
import { Category, Link } from '../../types';
import Categories from './Categories';
import Favorites from './Favorites';
import UserCard from './UserCard';
import styles from '../../styles/home/categories.module.scss';
import { Category, Link } from '../../types';
interface SideMenuProps {
categories: Category[];
favorites: Link[];

View File

@@ -1,6 +1,7 @@
import { Session } from 'next-auth';
import { signOut } from 'next-auth/react';
import Image from 'next/image';
import styles from '../../styles/home/categories.module.scss';
export default function UserCard({ session }: { session: Session; }) {
@@ -15,7 +16,7 @@ export default function UserCard({ session }: { session: Session; }) {
/>
{session.user.name}
</div>
<button onClick={() => signOut()} className={styles['disconnect-btn']}>
<button onClick={() => signOut({ callbackUrl: '/signin' })} className={styles['disconnect-btn']}>
Se déconnecter
</button>
</div>

57
components/Checkbox.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { MutableRefObject, useState } from 'react';
interface SelectorProps {
name: string;
label?: string;
labelComponent?: JSX.Element;
disabled?: boolean;
innerRef?: MutableRefObject<any>;
placeholder?: string;
fieldClass?: string;
isChecked?: boolean;
onChangeCallback?: (value, { target }) => void;
}
export default function Selector({
name,
label,
labelComponent,
disabled = false,
innerRef = null,
fieldClass = '',
placeholder = 'Type something...',
isChecked,
onChangeCallback
}: SelectorProps): JSX.Element {
const [checkboxValue, setCheckboxValue] = useState<boolean>(isChecked);
function onChange({ target }) {
setCheckboxValue(!checkboxValue);
if (onChangeCallback) {
onChangeCallback(!checkboxValue, { target });
}
}
return (<div className={`checkbox-field ${fieldClass}`}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
{labelComponent && (
<label htmlFor={name} title={`${name} field`}>
{labelComponent}
</label>
)}
<input
type='checkbox'
id={name}
name={name}
onChange={onChange}
checked={checkboxValue}
placeholder={placeholder}
ref={innerRef}
disabled={disabled}
/>
</div>);
}

55
components/FormLayout.tsx Normal file
View File

@@ -0,0 +1,55 @@
import Head from 'next/head';
import Link from 'next/link';
import MessageManager from './MessageManager';
import styles from '../styles/create.module.scss';
interface FormProps {
title: string;
errorMessage?: string;
successMessage?: string;
infoMessage?: string;
canSubmit: boolean;
handleSubmit: (event) => void;
textBtnConfirm?: string;
classBtnConfirm?: string;
children: any;
}
export default function Form({
title,
errorMessage,
successMessage,
infoMessage,
canSubmit,
handleSubmit,
textBtnConfirm = 'Valider',
classBtnConfirm = '',
children
}: FormProps) {
return (<>
<Head>
<title>Superpipo {title}</title>
</Head>
<div className={`App ${styles['create-app']}`}>
<h2>{title}</h2>
<form onSubmit={handleSubmit}>
{children}
<button type='submit' className={classBtnConfirm} disabled={!canSubmit}>
{textBtnConfirm}
</button>
</form>
<Link href='/'>
<a> Revenir à l'accueil</a>
</Link>
<MessageManager
info={infoMessage}
error={errorMessage}
success={successMessage}
/>
</div>
</>)
}

View File

@@ -1,8 +1,12 @@
import LinkTag from 'next/link';
import styles from '../../styles/home/links.module.scss';
import { Category, Link } from '../../types';
import EditSVG from '../../public/icons/edit.svg';
import RemoveSVG from '../../public/icons/remove.svg';
import styles from '../../styles/home/links.module.scss';
export default function Links({ category }: { category: Category; }) {
if (category === null) {
return (<div className={styles['no-category']}>
@@ -25,7 +29,7 @@ export default function Links({ category }: { category: Category; }) {
return (<div className={styles['links-wrapper']}>
<h2>{name}<span className={styles['links-count']}> {links.length}</span></h2>
<ul className={styles['links']}>
<ul className={styles['links']} key={Math.random()}>
{links.map((link, key) => (
<LinkItem key={key} link={link} />
))}
@@ -34,26 +38,34 @@ export default function Links({ category }: { category: Category; }) {
}
function LinkItem({ link }: { link: Link; }) {
const { name, url, category } = link;
const { origin, pathname, search } = new URL(url);
const { id, name, url, category } = link;
return (
<li className={styles['link']} key={Math.random()}>
<li className={styles['link']} key={id}>
<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}
/>
<LinkItemURL url={url} />
</a>
<div className={styles['controls']}>
<LinkTag href={`/link/edit/${id}`}>
<a className={styles['edit']}>
<EditSVG />
</a>
</LinkTag>
<LinkTag href={`/link/remove/${id}`}>
<a className={styles['remove']}>
<RemoveSVG />
</a>
</LinkTag>
</div>
</li>
);
}
function LinkItemURL({ origin, pathname, search }) {
function LinkItemURL({ url }: { url: string; }) {
try {
const { origin, pathname, search } = new URL(url);
let text = '';
if (pathname !== '/') {
@@ -72,4 +84,12 @@ function LinkItemURL({ origin, pathname, search }) {
{origin}<span className={styles['url-pathname']}>{text}</span>
</span>
)
} catch (error) {
console.error('error', error);
return (
<span className={styles['link-url']}>
{url}
</span>
)
}
}

View File

@@ -0,0 +1,14 @@
import styles from '../styles/components/message-manager.module.scss';
interface MessageManagerProps {
error?: string;
success?: string;
info?: string;
}
export default function MessageManager({ error, success, info }: MessageManagerProps) {
return (<>
{info && (<div className={styles['info-msg']}>{info}</div>)}
{error && (<div className={styles['error-msg']}>{error}</div>)}
{success && (<div className={styles['success-msg']}>{success}</div>)}
</>);
}

View File

@@ -1,22 +1,24 @@
import { MutableRefObject, useState } from "react";
import { MutableRefObject, useState } from 'react';
interface InputProps {
name: string;
label?: string;
labelComponent?: JSX.Element;
disabled?: boolean;
type?: string;
multiple?: boolean;
innerRef?: MutableRefObject<any>;
placeholder?: string;
fieldClass?: string;
value?: string;
onChangeCallback: ({ target }, value) => void;
onChangeCallback?: (value) => void;
}
export default function Input({
export default function TextBox({
name,
label,
labelComponent,
disabled = false,
type = 'text',
multiple = false,
innerRef = null,
@@ -29,7 +31,9 @@ export default function Input({
function onChange({ target }) {
setInputValue(target.value);
onChangeCallback({ target }, target.value);
if (onChangeCallback) {
onChangeCallback(target.value);
}
}
return (<div className={`input-field ${fieldClass}`}>
@@ -38,7 +42,7 @@ export default function Input({
{label}
</label>
)}
{!!labelComponent && (
{labelComponent && (
<label htmlFor={name} title={`${name} field`}>
{labelComponent}
</label>
@@ -52,6 +56,7 @@ export default function Input({
multiple={multiple}
placeholder={placeholder}
ref={innerRef}
disabled={disabled}
/>
</div>);
}

View File

@@ -1,4 +1,7 @@
import { MutableRefObject, useState } from "react";
import { MutableRefObject, useEffect, useState } from 'react';
import Select, { OptionsOrGroups, GroupBase } from 'react-select';
type Option = { label: string | number; value: string | number; }
interface SelectorProps {
name: string;
@@ -6,9 +9,12 @@ interface SelectorProps {
labelComponent?: JSX.Element;
innerRef?: MutableRefObject<any>;
fieldClass?: string;
value?: string | number;
onChangeCallback: ({ target }, value) => void;
children?: any;
options: OptionsOrGroups<Option, GroupBase<Option>>;
value?: number | string;
onChangeCallback?: (value: number | string) => void;
disabled?: boolean;
}
export default function Selector({
@@ -18,14 +24,26 @@ export default function Selector({
innerRef = null,
fieldClass = '',
value,
options = [],
onChangeCallback,
children
disabled = false
}: SelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState<string | number>(value);
const [selectorValue, setSelectorValue] = useState<Option>();
function onChange({ target }) {
setInputValue(target.value);
onChangeCallback({ target }, target.value);
useEffect(() => {
if (options.length === 0) return;
const option = options.find((o: Option) => o.value === value) as Option;
if (option) {
setSelectorValue(option);
}
}, [options, value]);
function handleChange(selectedOption: Option) {
setSelectorValue(selectedOption);
if (onChangeCallback) {
onChangeCallback(selectedOption.value);
}
}
return (<div className={`input-field ${fieldClass}`}>
@@ -34,19 +52,17 @@ export default function Selector({
{label}
</label>
)}
{!!labelComponent && (
{labelComponent && (
<label htmlFor={name} title={`${name} field`}>
{labelComponent}
</label>
)}
<select
id={name}
name={name}
onChange={onChange}
value={inputValue}
<Select
value={selectorValue}
onChange={handleChange}
options={options}
ref={innerRef}
>
{children}
</select>
isDisabled={disabled}
/>
</div>);
}

View File

@@ -2,5 +2,13 @@ module.exports = {
reactStrictMode: true,
images: {
domains: ['lh3.googleusercontent.com']
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"]
});
return config;
}
}

6127
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,22 @@
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^5.0.3",
"@dnd-kit/sortable": "^6.0.1",
"@dnd-kit/utilities": "^3.1.0",
"@prisma/client": "^3.13.0",
"@reduxjs/toolkit": "^1.8.1",
"@svgr/webpack": "^6.2.1",
"axios": "^0.27.2",
"next": "^12.1.5",
"next-auth": "^4.0.6",
"next-connect": "^0.12.2",
"nprogress": "^0.2.0",
"react": "^18.1.0",
"react-confirm-alert": "^2.8.0",
"react-dom": "^18.1.0",
"react-redux": "^8.0.1",
"sass": "^1.46.0"
"react-select": "^5.3.1",
"sass": "^1.46.0",
"toastr": "^2.1.4"
},
"devDependencies": {
"@types/node": "^17.0.29",

14
pages/404.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Head from 'next/head';
import styles from '../styles/error-page.module.scss';
export default function Custom404() {
return (<>
<Head>
<title>Superpipo Page introuvable</title>
</Head>
<div className={styles['App']}>
<h1>404</h1>
<h2>Cette page est introuvable.</h2>
</div>
</>)
}

14
pages/500.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Head from 'next/head';
import styles from '../styles/error-page.module.scss';
export default function Custom500() {
return (<>
<Head>
<title>Superpipo Une erreur côté serveur est survenue</title>
</Head>
<div className={styles['App']}>
<h1>500</h1>
<h2>Une erreur côté serveur est survenue.</h2>
</div>
</>)
}

View File

@@ -1,10 +1,14 @@
import { useEffect } from 'react';
import { SessionProvider } from 'next-auth/react';
import nProgress from "nprogress";
import { useRouter } from "next/router";
import { useRouter } from 'next/router';
import nProgress from 'nprogress';
import 'nprogress/nprogress.css';
import AuthRequired from '../components/AuthRequired';
import '../styles/globals.scss';
import "nprogress/nprogress.css"
import { useEffect } from 'react';
function MyApp({
Component,
@@ -26,7 +30,13 @@ function MyApp({
return (
<SessionProvider session={session}>
{Component.authRequired ? (
<AuthRequired>
<Component {...pageProps} />
</AuthRequired>
) : (
<Component {...pageProps} />
)}
</SessionProvider>
);
}

View File

@@ -2,7 +2,12 @@ import { Html, Head, Main, NextScript } from 'next/document';
const Document = () => (
<Html lang='fr'>
<Head />
<Head>
<link
href='https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=Rubik:ital,wght@0,400;0,700;1,400;1,700&display=swap'
rel='stylesheet'
/>
</Head>
<title>Superpipo</title>
<body>
<noscript>

View File

@@ -1,30 +0,0 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code"
}
}
})
],
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;
}
}
});

View File

@@ -0,0 +1,60 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
prompt: 'consent',
access_type: 'offline',
response_type: 'code'
}
}
})
],
callbacks: {
async signIn({ account: accountParam, profile }) { // TODO: Auth
if (accountParam.provider !== 'google') {
return '/signin?error=' + encodeURI('Authentitifcation via Google requise');
}
const email = profile?.email;
if (email === '') {
return '/signin?error=' + encodeURI('Impossible de récupérer l\'email associé à ce compte Google');
}
const googleId = profile?.sub;
if (googleId === '') {
return '/signin?error=' + encodeURI('Impossible de récupérer l\'identifiant associé à ce compte Google');
}
try {
const account = await prisma.user.findFirst({
where: {
google_id: googleId,
email
}
});
if (!account) {
return '/signin?error=' + encodeURI('Vous n\'êtes pas autorisé à vous connecter avec ce compte Google');
} else {
return true;
}
} catch (error) {
console.error(error);
return '/signin?error=' + encodeURI('Une erreur est survenue lors de l\'authentification');
}
}
},
pages: {
signIn: '/signin',
error: '/signin'
}
});

View File

@@ -0,0 +1,39 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.post(async (req: NextApiRequest, res: NextApiResponse) => {
const name = req.body?.name as string;
if (!name) {
return res.status(400).send({ error: 'Nom de catégorie manquant' });
}
try {
const category = await prisma.category.findFirst({
where: { name }
});
if (category) {
return res.status(400).send({ error: 'Une catégorie avec ce nom existe déjà' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la création de la catégorie' });
}
try {
await prisma.category.create({
data: { name }
});
return res.status(200).send({ success: 'Catégorie créée avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la création de la catégorie' });
}
});
export default apiRoute;

View File

@@ -0,0 +1,45 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.put(async (req: NextApiRequest, res: NextApiResponse) => {
const { cid } = req.query;
let category;
try {
category = await prisma.category.findFirst({
where: { id: Number(cid) }
});
if (!category) {
return res.status(400).send({ error: 'Catégorie introuvable' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de l\'édition de la catégorie' });
}
const name = req.body?.name as string;
if (!name) {
return res.status(400).send({ error: 'Nom de la catégorie manquante' });
} else if (name === category.name) {
return res.status(400).send({ error: 'Le nom de la catégorie doit être différent du nom actuel' });
}
try {
await prisma.category.update({
where: { id: Number(cid) },
data: { name }
});
return res.status(200).send({ success: 'Catégorie mise à jour avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de l\'édition de la catégorie' });
}
});
export default apiRoute;

View File

@@ -0,0 +1,36 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.delete(async (req: NextApiRequest, res: NextApiResponse) => {
const { cid } = req.query;
try {
const category = await prisma.category.findFirst({
where: { id: Number(cid) }
});
if (!category) {
return res.status(400).send({ error: 'Categorie introuvable' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la suppression de la catégorie' });
}
try {
await prisma.category.delete({
where: { id: Number(cid) }
});
return res.status(200).send({ success: 'La catégorie a été supprimée avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la suppression de la catégorie' });
}
});
export default apiRoute;

68
pages/api/link/create.ts Normal file
View File

@@ -0,0 +1,68 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.post(async (req: NextApiRequest, res: NextApiResponse) => {
const name = req.body?.name as string;
const url = req.body?.url as string;
const favorite = Boolean(req.body?.favorite) || false;
const categoryId = Number(req.body?.categoryId);
if (!name) {
return res.status(400).send({ error: 'Nom du lien manquant' });
}
if (!url) {
return res.status(400).send({ error: 'URL du lien manquant' });
}
if (!categoryId) {
return res.status(400).send({ error: 'Catégorie du lien manquante' });
}
try {
const link = await prisma.link.findFirst({
where: { name }
});
if (link) {
return res.status(400).send({ error: 'Un lien avec ce nom existe déjà' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la création du lien' });
}
try {
const category = await prisma.category.findFirst({
where: { id: categoryId }
});
if (!category) {
return res.status(400).send({ error: 'Cette catégorie n\'existe pas' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la création du lien' });
}
try {
await prisma.link.create({
data: {
name,
url,
categoryId,
favorite
}
});
return res.status(200).send({ success: 'Lien créé avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la création du lien' });
}
});
export default apiRoute;

View File

@@ -0,0 +1,59 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.put(async (req: NextApiRequest, res: NextApiResponse) => { // TODO: Ajouter vérification -> l'utilisateur doit changer au moins un champ
const { lid } = req.query;
try {
const link = await prisma.link.findFirst({
where: { id: Number(lid) }
});
if (!link) {
return res.status(400).send({ error: 'Lien introuvable' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de l\'édition du lien' });
}
const name = req.body?.name as string;
const url = req.body?.url as string;
const favorite = Boolean(req.body?.favorite) || false;
const categoryId = Number(req.body?.categoryId);
if (!name) {
return res.status(400).send({ error: 'Nom du lien manquant' });
}
if (!url) {
return res.status(400).send({ error: 'URL du lien manquant' });
}
if (!categoryId) {
return res.status(400).send({ error: 'Catégorie du lien manquante' });
}
try {
await prisma.link.update({
where: { id: Number(lid) },
data: {
name,
url,
favorite,
categoryId
}
});
return res.status(200).send({ success: 'Lien mis à jour avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de l\'édition du lien' });
}
});
export default apiRoute;

View File

@@ -0,0 +1,36 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { apiRoute } from '../../../../utils/back';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
apiRoute.delete(async (req: NextApiRequest, res: NextApiResponse) => {
const { lid } = req.query;
try {
const link = await prisma.link.findFirst({
where: { id: Number(lid) }
});
if (!link) {
return res.status(400).send({ error: 'Lien introuvable' });
}
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la suppression du lien' });
}
try {
await prisma.link.delete({
where: { id: Number(lid) }
});
return res.status(200).send({ success: 'Le lien a été supprimé avec succès' });
} catch (error) {
console.error(error);
return res.status(400).send({ error: 'Une erreur est survenue lors de la suppression du lien' });
}
});
export default apiRoute;

View File

@@ -1,45 +1,65 @@
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import nProgress from 'nprogress';
import axios, { AxiosResponse } from 'axios';
import FormLayout from '../../components/FormLayout';
import TextBox from '../../components/TextBox';
import Input from '../../components/input';
import styles from '../../styles/create.module.scss';
import Head from 'next/head';
import { HandleAxiosError } from '../../utils/front';
import { useRouter } from 'next/router';
export default function CreateCategory() {
const { data: session, status } = useSession({ required: true });
function CreateCategory() {
const info = useRouter().query?.info as string;
const [name, setName] = useState<string>('');
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleSubmit = (event) => {
useEffect(() => setCanSubmit(name.length !== 0), [name]);
const handleSubmit = async (event) => {
event.preventDefault();
console.log('On peut envoyer la requête pour créer une catégorie');
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const payload = { name };
const { data }: AxiosResponse<any> = await axios.post('/api/category/create', payload);
setSuccess(data?.success || 'Categorie créée avec succès');
setCanSubmit(false);
} catch (error) {
setError(HandleAxiosError(error));
setCanSubmit(true);
} finally {
nProgress.done();
}
}
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
return (<>
<FormLayout
title='Créer une catégorie'
errorMessage={error}
successMessage={success}
infoMessage={info}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name='name'
label='Nom de la catégorie'
onChangeCallback={({ target }, value) => setName(value)}
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
placeholder='Nom...'
/>
<button type='submit' disabled={name.length < 1}>
Valider
</button>
</form>
<Link href='/'>
<a> Revenir à l'accueil</a>
</Link>
</div>
);
</FormLayout>
</>);
}
CreateCategory.authRequired = true;
export default CreateCategory;

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import nProgress from 'nprogress';
import axios, { AxiosResponse } from 'axios';
import { confirmAlert } from 'react-confirm-alert';
import 'react-confirm-alert/src/react-confirm-alert.css';
import FormLayout from '../../../components/FormLayout';
import TextBox from '../../../components/TextBox';
import styles from '../../../styles/create.module.scss';
import { Category } from '../../../types';
import { BuildCategory, HandleAxiosError } from '../../../utils/front';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function EditCategory({ category }: { category: Category; }) {
const [name, setName] = useState<string>(category.name);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (name !== category.name && name !== '') {
setCanSubmit(true);
} else {
setCanSubmit(false);
}
}, [category, name]);
const handleSubmit = async (event) => {
event.preventDefault();
confirmAlert({
message: `Confirmer l'édition de la catégorie "${category.name}"`,
buttons: [{
label: 'Yes',
onClick: async () => {
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const payload = { name };
const { data }: AxiosResponse<any> = await axios.put(`/api/category/edit/${category.id}`, payload);
setSuccess(data?.success || 'Catégorie modifiée avec succès');
} catch (error) {
setError(HandleAxiosError(error));
} finally {
setCanSubmit(true);
nProgress.done();
}
}
}, {
label: 'No',
onClick: () => { }
}]
});
}
return (<>
<FormLayout
title='Modifier une catégorie'
errorMessage={error}
successMessage={success}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name='name'
label='Nom'
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
placeholder={`Nom original : ${category.name}`}
/>
</FormLayout>
</>);
}
EditCategory.authRequired = true;
export default EditCategory;
export async function getServerSideProps({ query }) {
const { cid } = query;
const categoryDB = await prisma.category.findFirst({
where: { id: Number(cid) },
include: { links: true }
});
if (!categoryDB) {
return {
redirect: {
destination: '/'
}
}
}
const category = BuildCategory(categoryDB);
return {
props: {
category: JSON.parse(JSON.stringify(category))
}
}
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import nProgress from 'nprogress';
import axios, { AxiosResponse } from 'axios';
import { confirmAlert } from 'react-confirm-alert';
import 'react-confirm-alert/src/react-confirm-alert.css';
import FormLayout from '../../../components/FormLayout';
import TextBox from '../../../components/TextBox';
import styles from '../../../styles/create.module.scss';
import { Category } from '../../../types';
import { BuildCategory, HandleAxiosError } from '../../../utils/front';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function RemoveCategory({ category }: { category: Category; }) {
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (category.links.length > 0) {
setError('Vous devez supprimer tous les liens de cette catégorie avant de pouvoir supprimer cette catégorie')
setCanSubmit(false);
} else {
setCanSubmit(true);
}
}, [category]);
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
const handleSubmit = async (event) => {
event.preventDefault();
confirmAlert({
message: `Confirmer la suppression du lien "${category.name}"`,
buttons: [{
label: 'Yes',
onClick: async () => {
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const { data }: AxiosResponse<any> = await axios.delete(`/api/category/remove/${category.id}`);
setSuccess(data?.success || 'Categorie supprimée avec succès');
setCanSubmit(false);
} catch (error) {
setError(HandleAxiosError(error));
setCanSubmit(true);
} finally {
nProgress.done();
}
}
}, {
label: 'No',
onClick: () => { }
}]
});
}
return (<>
<FormLayout
title='Supprimer une catégorie'
errorMessage={error}
successMessage={success}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
classBtnConfirm='red-btn'
textBtnConfirm='Supprimer'
>
<TextBox
name='name'
label='Nom'
value={category.name}
fieldClass={styles['input-field']}
disabled={true}
/>
</FormLayout>
</>);
}
RemoveCategory.authRequired = true;
export default RemoveCategory;
export async function getServerSideProps({ query }) {
const { cid } = query;
const categoryDB = await prisma.category.findFirst({
where: { id: Number(cid) },
include: { links: true }
});
if (!categoryDB) {
return {
redirect: {
destination: '/'
}
}
}
const category = BuildCategory(categoryDB);
return {
props: {
category: JSON.parse(JSON.stringify(category))
}
}
}

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Provider } from 'react-redux';
import Head from 'next/head';
import Menu from '../components/Categories/SideMenu';
@@ -10,7 +9,6 @@ import { Category, Link } from '../types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { store } from '../redux';
import { BuildCategory } from '../utils/front';
import Links from '../components/Links/Links';
@@ -20,18 +18,13 @@ interface HomeProps {
favorites: Link[];
}
export default function Home({ categories, favorites }: HomeProps) {
const { data: session, status } = useSession({ required: true });
function Home({ categories, favorites }: HomeProps) {
const { data } = 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}>
return (<>
<Head>
<title>Superpipo</title>
</Head>
@@ -41,19 +34,19 @@ export default function Home({ categories, favorites }: HomeProps) {
favorites={favorites}
handleSelectCategory={handleSelectCategory}
categoryActive={categoryActive}
session={session}
session={data}
/>
<Links category={categoryActive} />
</div>
</Provider>
)
</>);
}
export async function getStaticProps() {
export async function getServerSideProps() {
const categoriesDB = await prisma.category.findMany({ include: { links: true } });
const favorites = [] as Link[];
const categories = categoriesDB.map((categoryDB) => {
console.log(categoryDB)
const category = BuildCategory(categoryDB);
category.links.map((link) => link.favorite ? favorites.push(link) : null);
return category;
@@ -62,7 +55,7 @@ export async function getStaticProps() {
if (categories.length === 0) {
return {
redirect: {
destination: '/category/create'
destination: '/category/create?info=Veuillez créer une catégorie'
}
}
}
@@ -74,3 +67,6 @@ export async function getStaticProps() {
}
}
}
Home.authRequired = true;
export default Home;

View File

@@ -1,88 +1,103 @@
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
import Input from '../../components/input';
import nProgress from 'nprogress';
import axios, { AxiosResponse } from 'axios';
import FormLayout from '../../components/FormLayout';
import TextBox from '../../components/TextBox';
import Selector from '../../components/Selector';
import Checkbox from '../../components/Checkbox';
import styles from '../../styles/create.module.scss';
import { Category } from '../../types';
import { BuildCategory } from '../../utils/front';
import { BuildCategory, HandleAxiosError, IsValidURL } from '../../utils/front';
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 });
function CreateLink({ categories }: { categories: Category[]; }) {
const [name, setName] = useState<string>('');
const [url, setUrl] = useState<string>('');
const [favorite, setFavorite] = useState<boolean>(false);
const [categoryId, setCategoryId] = useState<number | null>(categories?.[0].id || null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 {
if (name !== '' && IsValidURL(url) && favorite !== null && categoryId !== null) {
setCanSubmit(true);
} else {
setCanSubmit(false);
}
}, [name, url, categoryId]);
}, [name, url, favorite, categoryId]);
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
const handleSubmit = (event) => {
const handleSubmit = async (event) => {
event.preventDefault();
console.log('On peut envoyer la requête pour créer un lien');
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const payload = { name, url, favorite, categoryId };
const { data }: AxiosResponse<any> = await axios.post('/api/link/create', payload);
setSuccess(data?.success || 'Lien modifié avec succès');
} catch (error) {
setError(HandleAxiosError(error));
} finally {
setCanSubmit(true);
nProgress.done();
}
}
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
<FormLayout
title='Créer un lien'
errorMessage={error}
successMessage={success}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name='name'
label='Label'
onChangeCallback={({ target }, value) => setName(value)}
label='Nom'
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
placeholder='Label du lien'
placeholder='Nom du lien'
/>
<Input
<TextBox
name='url'
label='URL'
onChangeCallback={({ target }, value) => setUrl(value)}
value={name}
onChangeCallback={(value) => setUrl(value)}
value={url}
fieldClass={styles['input-field']}
placeholder='URL du lien'
placeholder='https://www.example.org/'
/>
<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>
onChangeCallback={(value: number) => setCategoryId(value)}
options={categories.map(({ id, name }) => ({ label: name, value: id }))}
/>
<Checkbox
name='favorite'
isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)}
label='Favoris'
/>
</FormLayout>
</>);
}
export async function getStaticProps() {
CreateLink.authRequired = true;
export default CreateLink;
export async function getServerSideProps() {
const categoriesDB = await prisma.category.findMany();
const categories = categoriesDB.map((categoryDB) => BuildCategory(categoryDB));

130
pages/link/edit/[lid].tsx Normal file
View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import axios, { AxiosResponse } from 'axios';
import nProgress from 'nprogress';
import FormLayout from '../../../components/FormLayout';
import TextBox from '../../../components/TextBox';
import Selector from '../../../components/Selector';
import Checkbox from '../../../components/Checkbox';
import styles from '../../../styles/create.module.scss';
import { Category, Link } from '../../../types';
import { BuildCategory, BuildLink, HandleAxiosError, IsValidURL } from '../../../utils/front';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function EditLink({ link, categories }: { link: Link; categories: Category[]; }) {
const [name, setName] = useState<string>(link.name);
const [url, setUrl] = useState<string>(link.url);
const [favorite, setFavorite] = useState<boolean>(link.favorite);
const [categoryId, setCategoryId] = useState<number | null>(link.category?.id || null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
useEffect(() => {
if (name !== link.name || url !== link.url || favorite !== link.favorite || categoryId !== link.category.id) {
if (name !== '' && IsValidURL(url) && favorite !== null && categoryId !== null) {
setCanSubmit(true);
} else {
setCanSubmit(false);
}
} else {
setCanSubmit(false);
}
}, [name, url, favorite, categoryId, link]);
const handleSubmit = async (event) => {
event.preventDefault();
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const payload = { name, url, favorite, categoryId };
const { data }: AxiosResponse<any> = await axios.put(`/api/link/edit/${link.id}`, payload);
setSuccess(data?.success || 'Lien modifié avec succès');
} catch (error) {
setError(HandleAxiosError(error));
} finally {
setCanSubmit(true);
nProgress.done();
}
}
return (<>
<FormLayout
title='Modifier un lien'
errorMessage={error}
successMessage={success}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name='name'
label='Nom'
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
placeholder={`Nom original : ${link.name}`}
/>
<TextBox
name='url'
label='URL'
onChangeCallback={(value) => setUrl(value)}
value={url}
fieldClass={styles['input-field']}
placeholder={`URL original : ${link.url}`}
/>
<Selector
name='category'
label='Catégorie'
value={categoryId}
onChangeCallback={(value: number) => setCategoryId(value)}
options={categories.map(({ id, name }) => ({ label: name, value: id }))}
/>
<Checkbox
name='favorite'
isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)}
label='Favoris'
/>
</FormLayout>
</>);
}
EditLink.authRequired = true;
export default EditLink;
export async function getServerSideProps({ query }) {
const { lid } = query;
const categoriesDB = await prisma.category.findMany();
const categories = categoriesDB.map((categoryDB) => BuildCategory(categoryDB));
const linkDB = await prisma.link.findFirst({
where: { id: Number(lid) },
include: { category: true }
});
if (!linkDB) {
return {
redirect: {
destination: '/'
}
}
}
const link = BuildLink(linkDB, { categoryId: linkDB.categoryId, categoryName: linkDB.category.name });
return {
props: {
link: JSON.parse(JSON.stringify(link)),
categories: JSON.parse(JSON.stringify(categories))
}
}
}

122
pages/link/remove/[lid].tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import axios, { AxiosResponse } from 'axios';
import nProgress from 'nprogress';
import { confirmAlert } from 'react-confirm-alert';
import 'react-confirm-alert/src/react-confirm-alert.css';
import FormLayout from '../../../components/FormLayout';
import TextBox from '../../../components/TextBox';
import Checkbox from '../../../components/Checkbox';
import styles from '../../../styles/create.module.scss';
import { Link } from '../../../types';
import { BuildLink, HandleAxiosError } from '../../../utils/front';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function RemoveLink({ link }: { link: Link; }) {
const [canSubmit, setCanSubmit] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleSubmit = (event) => {
event.preventDefault();
confirmAlert({
message: `Confirmer la suppression du lien "${link.name}"`,
buttons: [{
label: 'Confirmer',
onClick: async () => {
setSuccess(null);
setError(null);
setCanSubmit(false);
nProgress.start();
try {
const { data }: AxiosResponse<any> = await axios.delete(`/api/link/remove/${link.id}`);
setSuccess(data?.success || 'Lien supprimé avec succès');
setCanSubmit(false);
} catch (error) {
setError(HandleAxiosError(error));
setCanSubmit(true);
} finally {
nProgress.done();
}
}
}, {
label: 'Annuler',
onClick: () => { }
}]
});
}
return (<>
<FormLayout
title='Supprimer un lien'
errorMessage={error}
successMessage={success}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
classBtnConfirm='red-btn'
textBtnConfirm='Supprimer'
>
<TextBox
name='name'
label='Nom'
value={link.name}
fieldClass={styles['input-field']}
disabled={true}
/>
<TextBox
name='url'
label='URL'
value={link.url}
fieldClass={styles['input-field']}
disabled={true}
/>
<TextBox
name='category'
label='Catégorie'
value={link.category.name}
fieldClass={styles['input-field']}
disabled={true}
/>
<Checkbox
name='favorite'
label='Favoris'
isChecked={link.favorite}
disabled={true}
/>
</FormLayout>
</>);
}
RemoveLink.authRequired = true;
export default RemoveLink;
export async function getServerSideProps({ query }) {
const { lid } = query;
const linkDB = await prisma.link.findFirst({
where: { id: Number(lid) },
include: { category: true }
});
if (!linkDB) {
return {
redirect: {
destination: '/'
}
}
}
const link = BuildLink(linkDB, { categoryId: linkDB.categoryId, categoryName: linkDB.category.name });
return {
props: {
link: JSON.parse(JSON.stringify(link))
}
}
}

55
pages/signin.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { getProviders, signIn, useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import styles from '../styles/login.module.scss';
import MessageManager from '../components/MessageManager';
export default function SignIn({ providers }) {
const { data: session, status } = useSession();
const info = useRouter().query?.info as string;
const error = useRouter().query?.error as string;
if (status === 'loading') {
return (
<div className='App' style={{ alignItems: 'center' }}>
<p style={{ height: 'fit-content' }}>Chargement de la session en cours</p>
</div>
);
}
return (<>
<Head>
<title>Superpipo Authentification</title>
</Head>
<div className='App'>
<div className={styles['wrapper']}>
<h2>Se connecter</h2>
<MessageManager
error={error}
info={info}
/>
{session !== null && (<MessageManager info='Vous êtes déjà connecté' />)}
<div className={styles['providers']}>
{Object.values(providers).map(({ name, id }) => (
<button key={id} onClick={() => signIn(id, { callbackUrl: '/' })} disabled={session !== null}>
Continuer avec {name}
</button>
))}
</div>
<Link href='/'>
<a> Revenir à l'accueil</a>
</Link>
</div>
</div>
</>);
}
export async function getServerSideProps(context) {
const providers = await getProviders();
return {
props: { providers }
}
}

View File

@@ -12,8 +12,8 @@ datasource db {
model User {
id Int @id @default(autoincrement())
google_id String @unique
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -22,7 +22,7 @@ model Category {
id Int @id @default(autoincrement())
name String @unique
links Link[]
order Int
nextCategoryId Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -33,7 +33,7 @@ model Link {
url String
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
order Int
nextLinkId Int @default(0)
favorite Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

3
public/icons/edit.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg fill="#3f88c5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="48px" height="48px">
<path d="M 43.125 2 C 41.878906 2 40.636719 2.488281 39.6875 3.4375 L 38.875 4.25 L 45.75 11.125 C 45.746094 11.128906 46.5625 10.3125 46.5625 10.3125 C 48.464844 8.410156 48.460938 5.335938 46.5625 3.4375 C 45.609375 2.488281 44.371094 2 43.125 2 Z M 37.34375 6.03125 C 37.117188 6.0625 36.90625 6.175781 36.75 6.34375 L 4.3125 38.8125 C 4.183594 38.929688 4.085938 39.082031 4.03125 39.25 L 2.03125 46.75 C 1.941406 47.09375 2.042969 47.457031 2.292969 47.707031 C 2.542969 47.957031 2.90625 48.058594 3.25 47.96875 L 10.75 45.96875 C 10.917969 45.914063 11.070313 45.816406 11.1875 45.6875 L 43.65625 13.25 C 44.054688 12.863281 44.058594 12.226563 43.671875 11.828125 C 43.285156 11.429688 42.648438 11.425781 42.25 11.8125 L 9.96875 44.09375 L 5.90625 40.03125 L 38.1875 7.75 C 38.488281 7.460938 38.578125 7.011719 38.410156 6.628906 C 38.242188 6.246094 37.855469 6.007813 37.4375 6.03125 C 37.40625 6.03125 37.375 6.03125 37.34375 6.03125 Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

3
public/icons/remove.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg fill="#ff0000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48px" height="48px">
<path d="M 10 2 L 9 3 L 3 3 L 3 5 L 4.109375 5 L 5.8925781 20.255859 L 5.8925781 20.263672 C 6.023602 21.250335 6.8803207 22 7.875 22 L 16.123047 22 C 17.117726 22 17.974445 21.250322 18.105469 20.263672 L 18.107422 20.255859 L 19.890625 5 L 21 5 L 21 3 L 15 3 L 14 2 L 10 2 z M 6.125 5 L 17.875 5 L 16.123047 20 L 7.875 20 L 6.125 5 z" />
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -1,89 +0,0 @@
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;

View File

@@ -0,0 +1,38 @@
.info-msg {
height: fit-content;
width: 100%;
text-align: center;
font-style: italic;
font-size: 0.9em;
color: #005aa5;
background-color: #d3e8fa;
padding: 10px;
border-radius: 3px;
animation: fadein 250ms both;
}
.error-msg {
height: fit-content;
width: 100%;
text-align: center;
font-style: italic;
font-size: 0.9em;
color: #d8000c;
background-color: #ffbabab9;
padding: 10px;
border-radius: 3px;
animation: fadein 250ms both;
}
.success-msg {
height: fit-content;
width: 100%;
text-align: center;
font-style: italic;
font-size: 0.9em;
color: green;
background-color: #c1ffbab9;
padding: 10px;
border-radius: 3px;
animation: fadein 250ms both;
}

View File

@@ -1,5 +1,7 @@
.create-app {
height: fit-content;
width: 680px;
margin-top: 150px;
flex-direction: column;
gap: 15px;
@@ -8,6 +10,8 @@
}
& form {
height: 100%;
width: 100%;
display: flex;
gap: 5px;
flex-direction: column;
@@ -25,3 +29,13 @@
width: 100%;
}
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -0,0 +1,36 @@
.App {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
animation: fadein 250ms both;
& h1 {
display: inline-block;
border-right: 1px solid rgba(0, 0, 0, 0.3);
margin: 0;
margin-right: 20px;
padding: 10px 23px 10px 0;
font-size: 24px;
font-weight: 500;
vertical-align: top;
}
& h2 {
font-size: 14px;
font-weight: normal;
line-height: inherit;
margin: 0;
padding: 0;
}
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@@ -1,5 +1,3 @@
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=Rubik:ital,wght@0,400;0,700;1,400;1,700&display=swap");
* {
box-sizing: border-box;
outline: 0;
@@ -23,7 +21,6 @@ body {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
@@ -51,6 +48,15 @@ a {
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #3f88c5;
}
ul,
li {
list-style: none;
@@ -98,6 +104,28 @@ button {
color: #fff;
}
}
button.red-btn {
cursor: pointer;
width: 100%;
color: #fff;
background: red;
padding: 10px;
border: 1px solid red;
border-radius: 3px;
transition: 0.15s;
&:disabled {
cursor: default;
opacity: 0.5;
}
&:not(:disabled):hover {
box-shadow: red 0 0 3px 1px;
background: red;
border: 1px solid #ffbabab9;
color: #fff;
}
}
input:not(.nostyle) {
color: #333;
@@ -125,6 +153,12 @@ input::placeholder {
}
}
.checkbox-field {
display: flex;
align-items: center;
gap: 5px;
}
select:not(.nostyle) {
color: #333;
background: #fff;

View File

@@ -7,7 +7,7 @@
display: flex;
align-items: center;
flex-direction: column;
overflow-x: none;
overflow: hidden;
& .block-wrapper {
height: auto;
@@ -22,9 +22,15 @@
}
// List items
& .items {
animation: fadein 0.3s both;
}
& .items .item {
position: relative;
user-select: none;
cursor: pointer;
height: fit-content;
width: 100%;
background-color: #fff;
padding: 7px 12px;
border: 1px solid #dadce0;
@@ -36,11 +42,6 @@
margin-bottom: 5px;
}
& .links-count {
color: #bbb;
font-size: 0.85em;
}
&.active {
color: #fff;
background: #3f88c5;
@@ -95,10 +96,72 @@
flex-direction: column;
& .items {
overflow: auto;
padding-right: 5px;
overflow-y: scroll;
& .item {
display: flex;
align-items: center;
justify-content: space-between;
&.active .menu-item .option-edit svg {
fill: #fff;
}
& .content {
width: 100%;
display: flex;
flex: 1;
align-items: center;
& .name {
margin-right: 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
& .links-count {
min-width: fit-content;
font-size: 0.85em;
color: #bbb;
}
}
&:hover .content {
width: calc(100% - 42px);
}
& .menu-item {
height: 100%;
min-width: fit-content;
margin-left: 5px;
display: none;
gap: 2px;
align-items: center;
justify-content: center;
animation: fadein 0.3s both;
& > a {
border: 0;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
transform: scale(1.25);
}
& svg {
height: 20px;
width: 20px;
}
}
}
&:hover .menu-item {
display: flex;
}
}
}
}
@@ -148,3 +211,15 @@
}
}
}
@keyframes fadein {
0% {
transform: translateX(-15px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}

View File

@@ -5,13 +5,17 @@
align-items: center;
justify-content: center;
flex-direction: column;
animation: fadein 0.3s both;
}
.links-wrapper {
height: 100%;
padding: 10px;
display: flex;
flex: 1;
overflow-x: auto;
flex-direction: column;
overflow-x: hidden;
overflow-y: scroll;
& h2 {
color: #3f88c5;
@@ -27,36 +31,53 @@
& .links {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
animation: fadein 0.3s both; // bug on drag start
}
& .links .link {
user-select: none;
cursor: pointer;
animation: fadein 0.3s both;
& > 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;
align-items: center;
transition: 0.15s;
&:hover {
border-bottom-color: #3f88c5;
background: #f0eef6;
& .url-pathname {
opacity: 1 !important;
animation: fadein 0.3s both;
}
& .controls {
display: flex;
animation: fadein 0.3s both;
}
}
& > a {
height: 100%;
max-width: calc(100% - 50px);
text-decoration: none;
display: flex;
flex: 1;
flex-direction: column;
transition: 0.1s;
&,
&:hover {
border: 0;
}
& .link-name .link-category {
@@ -74,7 +95,31 @@
& .url-pathname {
opacity: 0;
transition: 0.1s;
}
}
}
& .controls {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
& > a {
border: 0;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
transform: scale(1.3);
}
& svg {
height: 20px;
width: 20px;
}
}
}

51
styles/login.module.scss Normal file
View File

@@ -0,0 +1,51 @@
.wrapper {
height: 100%;
width: 480px;
display: flex;
gap: 20px;
justify-content: center;
flex-direction: column;
animation: fadein 250ms both;
& .providers {
height: fit-content;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
& button {
width: 100%;
}
}
& .error {
height: fit-content;
width: 100%;
text-align: center;
font-style: italic;
font-size: 0.9em;
color: #d8000c;
background-color: #ffbabab9;
padding: 10px;
border-radius: 3px;
animation: fadein 250ms both;
}
}
@media (max-width: 680px) {
.login {
width: 100%;
}
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

4
types.d.ts vendored
View File

@@ -3,7 +3,7 @@ export interface Category {
name: string;
links: Link[];
order: number;
nextCategoryId: number;
createdAt: Date;
updatedAt: Date;
@@ -20,7 +20,7 @@ export interface Link {
name: string;
}
order: number;
nextLinkId: number;
favorite: boolean;
createdAt: Date;

21
utils/back.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextApiRequest, NextApiResponse } from 'next';
import nextConnect, { NextHandler } from 'next-connect';
import { Session } from 'next-auth';
import { getSession } from 'next-auth/react';
const apiRoute = nextConnect({
onError: (error: Error, req: NextApiRequest, res: NextApiResponse) => res.status(501).json({ error: `Une erreur est survenue! ${error.message}` }),
onNoMatch: (req: NextApiRequest, res: NextApiResponse) => res.status(405).json({ error: `La méthode '${req.method}' n'est pas autorisée` })
});
apiRoute.use(async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
const session: Session = await getSession({ req });
if (!session) {
return res.status(403).json({ error: 'Vous devez être connecté' });
} else {
next();
}
});
export { apiRoute };

View File

@@ -1,17 +1,18 @@
import { Category, Link } from "../types"
import axios from 'axios';
import { Category, Link } from '../types';
export function BuildCategory({ id, name, order, links = [], createdAt, updatedAt }): Category {
export function BuildCategory({ id, name, nextCategoryId, links = [], createdAt, updatedAt }): Category {
return {
id,
name,
links: links.map((link) => BuildLink(link, { categoryId: id, categoryName: name })),
order,
nextCategoryId,
createdAt,
updatedAt
}
}
export function BuildLink({ id, name, url, order, favorite, createdAt, updatedAt }, { categoryId, categoryName }): Link {
export function BuildLink({ id, name, url, nextLinkId, favorite, createdAt, updatedAt }, { categoryId, categoryName }): Link {
return {
id,
name,
@@ -20,9 +21,34 @@ export function BuildLink({ id, name, url, order, favorite, createdAt, updatedAt
id: categoryId,
name: categoryName
},
order,
nextLinkId,
favorite,
createdAt,
updatedAt
}
}
export function IsValidURL(url: string): boolean {
const regex = new RegExp(/^(?:http(s)?:\/\/)[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/);
return url.match(regex) ? true : false;
}
export function HandleAxiosError(error): string {
let errorText: string;
if (axios.isAxiosError(error)) {
if (error.response) {
const responseError = error.response.data?.['error'] || error.response.data;
errorText = responseError || 'Une erreur est survenue';
} else if (error.request) {
errorText = 'Aucune donnée renvoyée par le serveur';
} else {
errorText = 'Une erreur inconnue est survenue';
}
} else {
errorText = 'Une erreur est survenue';
}
console.error(error);
return errorText;
}