mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 22:53:25 +00:00
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:
2
.env
2
.env
@@ -1 +1 @@
|
||||
DATABASE_URL="mysql://hp_user:oxU9ExgAHXktIhQZ@79.143.186.18:3306/Superpipo"
|
||||
DATABASE_URL="mysql://root:@localhost:3306/superpipo"
|
||||
20
components/AuthRequired.tsx
Normal file
20
components/AuthRequired.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
57
components/Checkbox.tsx
Normal 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
55
components/FormLayout.tsx
Normal 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>
|
||||
</>)
|
||||
}
|
||||
@@ -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,42 +38,58 @@ 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 }) {
|
||||
let text = '';
|
||||
function LinkItemURL({ url }: { url: string; }) {
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(url);
|
||||
let text = '';
|
||||
|
||||
if (pathname !== '/') {
|
||||
text += pathname;
|
||||
}
|
||||
|
||||
if (search !== '') {
|
||||
if (text === '') {
|
||||
text += '/';
|
||||
if (pathname !== '/') {
|
||||
text += pathname;
|
||||
}
|
||||
text += search;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles['link-url']}>
|
||||
{origin}<span className={styles['url-pathname']}>{text}</span>
|
||||
</span>
|
||||
)
|
||||
if (search !== '') {
|
||||
if (text === '') {
|
||||
text += '/';
|
||||
}
|
||||
text += search;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles['link-url']}>
|
||||
{origin}<span className={styles['url-pathname']}>{text}</span>
|
||||
</span>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
return (
|
||||
<span className={styles['link-url']}>
|
||||
{url}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
14
components/MessageManager.tsx
Normal file
14
components/MessageManager.tsx
Normal 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>)}
|
||||
</>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['lh3.googleusercontent.com']
|
||||
}
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ['lh3.googleusercontent.com']
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"]
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
6155
package-lock.json
generated
6155
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
14
pages/404.tsx
Normal 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
14
pages/500.tsx
Normal 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>
|
||||
</>)
|
||||
}
|
||||
@@ -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 {...pageProps} />
|
||||
{Component.authRequired ? (
|
||||
<AuthRequired>
|
||||
<Component {...pageProps} />
|
||||
</AuthRequired>
|
||||
) : (
|
||||
<Component {...pageProps} />
|
||||
)}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
60
pages/api/auth/[...nextauth].ts
Normal file
60
pages/api/auth/[...nextauth].ts
Normal 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'
|
||||
}
|
||||
});
|
||||
39
pages/api/category/create.ts
Normal file
39
pages/api/category/create.ts
Normal 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;
|
||||
45
pages/api/category/edit/[cid].ts
Normal file
45
pages/api/category/edit/[cid].ts
Normal 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;
|
||||
36
pages/api/category/remove/[cid].ts
Normal file
36
pages/api/category/remove/[cid].ts
Normal 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
68
pages/api/link/create.ts
Normal 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;
|
||||
59
pages/api/link/edit/[lid].ts
Normal file
59
pages/api/link/edit/[lid].ts
Normal 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;
|
||||
36
pages/api/link/remove/[lid].ts
Normal file
36
pages/api/link/remove/[lid].ts
Normal 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;
|
||||
@@ -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
|
||||
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>
|
||||
);
|
||||
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={(value) => setName(value)}
|
||||
value={name}
|
||||
fieldClass={styles['input-field']}
|
||||
placeholder='Nom...'
|
||||
/>
|
||||
</FormLayout>
|
||||
</>);
|
||||
}
|
||||
|
||||
CreateCategory.authRequired = true;
|
||||
export default CreateCategory;
|
||||
|
||||
109
pages/category/edit/[cid].tsx
Normal file
109
pages/category/edit/[cid].tsx
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
113
pages/category/remove/[cid].tsx
Normal file
113
pages/category/remove/[cid].tsx
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,40 +18,35 @@ 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}>
|
||||
<Head>
|
||||
<title>Superpipo</title>
|
||||
</Head>
|
||||
<div className='App'>
|
||||
<Menu
|
||||
categories={categories}
|
||||
favorites={favorites}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
categoryActive={categoryActive}
|
||||
session={session}
|
||||
/>
|
||||
<Links category={categoryActive} />
|
||||
</div>
|
||||
</Provider>
|
||||
)
|
||||
return (<>
|
||||
<Head>
|
||||
<title>Superpipo</title>
|
||||
</Head>
|
||||
<div className='App'>
|
||||
<Menu
|
||||
categories={categories}
|
||||
favorites={favorites}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
categoryActive={categoryActive}
|
||||
session={data}
|
||||
/>
|
||||
<Links category={categoryActive} />
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,4 +66,7 @@ export async function getStaticProps() {
|
||||
favorites: JSON.parse(JSON.stringify(favorites)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Home.authRequired = true;
|
||||
export default Home;
|
||||
@@ -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
|
||||
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>
|
||||
<FormLayout
|
||||
title='Créer 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 du lien'
|
||||
/>
|
||||
<TextBox
|
||||
name='url'
|
||||
label='URL'
|
||||
onChangeCallback={(value) => setUrl(value)}
|
||||
value={url}
|
||||
fieldClass={styles['input-field']}
|
||||
placeholder='https://www.example.org/'
|
||||
/>
|
||||
<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>
|
||||
</>);
|
||||
}
|
||||
|
||||
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
130
pages/link/edit/[lid].tsx
Normal 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
122
pages/link/remove/[lid].tsx
Normal 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
55
pages/signin.tsx
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -11,30 +11,30 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
id Int @id @default(autoincrement())
|
||||
google_id String @unique
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
links Link[]
|
||||
order Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
links Link[]
|
||||
nextCategoryId Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Link {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int
|
||||
order Int
|
||||
favorite Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
nextLinkId Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
3
public/icons/edit.svg
Normal file
3
public/icons/edit.svg
Normal 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
3
public/icons/remove.svg
Normal 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 |
89
redux.ts
89
redux.ts
@@ -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;
|
||||
38
styles/components/message-manager.module.scss
Normal file
38
styles/components/message-manager.module.scss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
36
styles/error-page.module.scss
Normal file
36
styles/error-page.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
margin-right: 5px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
color: #3f88c5;
|
||||
background-color: #fff;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #dadce0;
|
||||
border-bottom: 2px solid #dadce0;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: #3f88c5;
|
||||
background: #f0eef6;
|
||||
|
||||
& .url-pathname {
|
||||
animation: fadein 0.3s both;
|
||||
}
|
||||
|
||||
& .controls {
|
||||
display: flex;
|
||||
animation: fadein 0.3s both;
|
||||
}
|
||||
}
|
||||
|
||||
& > a {
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
color: #3f88c5;
|
||||
background-color: #fff;
|
||||
height: 100%;
|
||||
max-width: calc(100% - 50px);
|
||||
text-decoration: none;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #dadce0;
|
||||
border-bottom: 2px solid #dadce0;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
transition: 0.1s;
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
border-bottom-color: #3f88c5;
|
||||
background: #f0eef6;
|
||||
|
||||
& .url-pathname {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
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
51
styles/login.module.scss
Normal 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
4
types.d.ts
vendored
@@ -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
21
utils/back.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user