migration vers typescript + refonte total de l'app

This commit is contained in:
Sonny
2022-04-28 04:30:38 +02:00
parent 1e79072626
commit 1ab51979fe
32 changed files with 2737 additions and 7815 deletions

View File

@@ -1,3 +1,6 @@
{
"extends": "next/core-web-vitals"
"extends": "next",
"rules": {
"react/no-unescaped-entities": "off"
}
}

View File

@@ -1,60 +0,0 @@
import { useState } from 'react';
import { signIn, signOut } from "next-auth/react"
import ModalAddCategory from './ModalAddCategory';
import styles from '../../styles/categories.module.scss';
export default function Categories({
categories,
favorites,
handleSelectCategory,
categoryActive,
session
}) {
const [modalOpen, setModalOpen] = useState(false);
return (<div className={styles['categories-wrapper']}>
<div className={`${styles['block-wrapper']} ${styles['favorites']}`}>
<h4>Favoris</h4>
<ul className={styles['items']}>
{favorites.map((link, key) => {
const { name, url, categoryName } = link;
return <li key={key} className={styles['item']}>
<a href={url} target={'_blank'} rel={'noreferrer'}>
{name} <span className={styles['category']}>- {categoryName}</span>
</a>
</li>;
})}
</ul>
</div>
<div className={`${styles['block-wrapper']} ${styles['categories']}`}>
<h4>Catégories</h4>
<ul className={styles['items']}>
{categories.map(({ id, name }, key) => {
const className = `${styles['item']} ${id === categoryActive ? styles['active'] : ''}`;
const onClick = () => handleSelectCategory(id);
return (
<li key={key} className={className} onClick={onClick}>
{name}
</li>
)
})}
</ul>
</div>
{session !== null ?
<button onClick={() => setModalOpen((state) => !state)}>
Ajouter une catégorie
</button> :
<div className={`${styles['block-wrapper']} ${styles['controls']}`} onClick={signIn}>
<button>Se connecter</button>
</div>}
<ModalAddCategory
categories={categories}
isOpen={modalOpen}
closeModal={() => setModalOpen(false)}
/>
</div>);
}

View File

@@ -0,0 +1,98 @@
import { signOut } from "next-auth/react"
import { Session } from 'next-auth';
import LinkTag from "next/link";
import Image from "next/image";
import styles from '../../styles/home/categories.module.scss';
import { Category, Link } from '../../types';
interface CategoryProps {
categories: Category[];
favorites: Link[];
handleSelectCategory: (category: Category) => void;
categoryActive: Category;
session: Session;
}
export default function Categories({
categories,
favorites,
handleSelectCategory,
categoryActive,
session
}: CategoryProps) {
return (<div className={styles['categories-wrapper']}>
<div className={`${styles['block-wrapper']} ${styles['favorites']}`}>
<h4>Favoris</h4>
<ul className={styles['items']}>
{favorites.map((link, key) => (
<LinkFavorite
link={link}
key={key}
/>
))}
</ul>
</div>
<div className={`${styles['block-wrapper']} ${styles['categories']}`}>
<h4>Catégories</h4>
<ul className={styles['items']}>
{categories.map((category, key) => (
<CategoryItem
category={category}
categoryActive={categoryActive}
handleSelectCategory={handleSelectCategory}
key={key}
/>
))}
</ul>
</div>
<div className={styles['controls']}>
<LinkTag href={'/category/create'}>
<a>Créer categorie</a>
</LinkTag>
<LinkTag href={'/link/create'}>
<a>Créer lien</a>
</LinkTag>
</div>
<div className={styles['user-card-wrapper']}>
<div className={styles['user-card']}>
<Image
src={session.user.image}
width={28}
height={28}
alt={`${session.user.name}'s avatar`}
/>
{session.user.name}
</div>
<button onClick={() => signOut()} className={styles['disconnect-btn']}>
Se déconnecter
</button>
</div>
</div>);
}
function LinkFavorite({ link }: { link: Link; }): JSX.Element {
const { name, url, category } = link;
return (
<li className={styles['item']}>
<a href={url} target={'_blank'} rel={'noreferrer'}>
{name} <span className={styles['category']}>- {category.name}</span>
</a>
</li>
)
}
interface CategoryItemProps {
category: Category;
categoryActive: Category;
handleSelectCategory: (category: Category) => void;
}
function CategoryItem({ category, categoryActive, handleSelectCategory }: CategoryItemProps): JSX.Element {
const className = `${styles['item']} ${category.id === categoryActive.id ? styles['active'] : ''}`;
const onClick = () => handleSelectCategory(category);
return (
<li className={className} onClick={onClick}>
{category.name}<span className={styles['links-count']}> {category.links.length}</span>
</li>
)
}

View File

@@ -1,11 +0,0 @@
import styles from '../../styles/links.module.scss';
export default function Link({ link }) {
return (
<li className={styles['link']}>
<a href={link?.url} target={'_blank'} rel={'noreferrer'}>
<span className={styles['link-name']}>{link?.name} <span className={styles['link-category']}> {link?.categoryName}</span></span> <span className={styles['link-url']}>{link?.url}</span>
</a>
</li>
);
}

View File

@@ -1,30 +0,0 @@
import { useEffect, useCallback } from 'react';
import { useInView } from 'react-intersection-observer';
import Link from './Link';
import styles from '../../styles/links.module.scss';
export default function LinkBlock({ category, setCategoryActive, refCategoryActive }) {
const { ref, inView } = useInView({ threshold: .5 });
const { id, name, links, ref: refCategory } = category;
useEffect(() => inView ? setCategoryActive(id) : null, [id, inView, setCategoryActive]);
const setRefs = useCallback((node) => {
refCategory.current = node;
refCategoryActive.current = node;
ref(node);
}, [ref, refCategoryActive, refCategory]);
return (
<div className={styles['link-block']} ref={setRefs}>
<h2>{name}</h2>
<ul className={styles['links']}>
{links.map((link, key2) => (
<Link key={key2} link={link} />
))}
</ul>
</div>
);
}

View File

@@ -1,16 +0,0 @@
import LinkBlock from './LinkBlock';
import styles from '../../styles/links.module.scss';
export default function Links({ categories, setCategoryActive, refCategoryActive }) {
return (<div className={styles['links-wrapper']}>
{categories.map((category, key) => (
<LinkBlock
key={key}
category={category}
setCategoryActive={setCategoryActive}
refCategoryActive={refCategoryActive}
/>
))}
</div>);
}

View File

@@ -0,0 +1,56 @@
import styles from '../../styles/home/links.module.scss';
import { Category, Link } from '../../types';
export default function Links({ category }: { category: Category; }) {
const { name, links } = category;
return (<div className={styles['links-wrapper']}>
<h2>{name}<span className={styles['links-count']}> {links.length}</span></h2>
<ul className={styles['links']}>
{links.map((link, key) => (
<LinkItem key={key} link={link} />
))}
</ul>
</div>);
}
function LinkItem({ link }: { link: Link; }) {
const { name, url, category } = link;
const { origin, pathname, search } = new URL(url);
return (
<li className={styles['link']}>
<a href={url} target={'_blank'} rel={'noreferrer'}>
<span className={styles['link-name']}>
{name}<span className={styles['link-category']}> {category.name}</span>
</span>
<LinkItemURL
origin={origin}
pathname={pathname}
search={search}
/>
</a>
</li>
);
}
function LinkItemURL({ origin, pathname, search }) {
let text = '';
if (pathname !== '/') {
text += pathname;
}
if (search !== '') {
if (text === '') {
text += '/';
}
text += search;
}
return (
<span className={styles['link-url']}>
{origin}<span className={styles['url-pathname']}>{text}</span>
</span>
)
}

57
components/input.tsx Normal file
View File

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

52
components/selector.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { MutableRefObject, useState } from "react";
interface SelectorProps {
name: string;
label?: string;
labelComponent?: JSX.Element;
innerRef?: MutableRefObject<any>;
fieldClass?: string;
value?: string | number;
onChangeCallback: ({ target }, value) => void;
children?: any;
}
export default function Selector({
name,
label,
labelComponent,
innerRef = null,
fieldClass = '',
value,
onChangeCallback,
children
}: SelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState<string | number>(value);
function onChange({ target }) {
setInputValue(target.value);
onChangeCallback({ target }, target.value);
}
return (<div className={`input-field ${fieldClass}`}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
{!!labelComponent && (
<label htmlFor={name} title={`${name} field`}>
{labelComponent}
</label>
)}
<select
id={name}
name={name}
onChange={onChange}
value={inputValue}
ref={innerRef}
>
{children}
</select>
</div>);
}

7
example.env.local Normal file
View File

@@ -0,0 +1,7 @@
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,3 +1,6 @@
module.exports = {
reactStrictMode: true,
images: {
domains: ['lh3.googleusercontent.com']
}
}

5920
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,19 +8,24 @@
"lint": "next lint"
},
"dependencies": {
"@prisma/client": "3.6.0",
"@prisma/client": "^3.13.0",
"@reduxjs/toolkit": "^1.8.1",
"bcrypt": "^5.0.1",
"next": "12.0.7",
"next": "^12.1.5",
"next-auth": "^4.0.6",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-intersection-observer": "^8.33.1",
"nprogress": "^0.2.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-modal": "^3.14.4",
"react-redux": "^8.0.1",
"sass": "^1.46.0"
},
"devDependencies": {
"@types/node": "^17.0.29",
"@types/nprogress": "^0.2.0",
"@types/react": "^18.0.8",
"eslint": "7",
"eslint-config-next": "12.0.7",
"prisma": "3.6.0"
"prisma": "^3.13.0"
}
}

View File

@@ -1,15 +0,0 @@
import '../styles/globals.scss';
import { SessionProvider } from 'next-auth/react';
function MyApp({
Component,
pageProps: { session, ...pageProps }
}) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
);
}
export default MyApp;

34
pages/_app.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { SessionProvider } from 'next-auth/react';
import nProgress from "nprogress";
import { useRouter } from "next/router";
import '../styles/globals.scss';
import "nprogress/nprogress.css"
import { useEffect } from 'react';
function MyApp({
Component,
pageProps: { session, ...pageProps }
}) {
const router = useRouter();
useEffect(() => { // Chargement pages
router.events.on('routeChangeStart', nProgress.start);
router.events.on('routeChangeComplete', nProgress.done);
router.events.on('routeChangeError', nProgress.done);
return () => {
router.events.off('routeChangeStart', nProgress.start);
router.events.off('routeChangeComplete', nProgress.done);
router.events.off('routeChangeError', nProgress.done);
}
});
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
);
}
export default MyApp;

17
pages/_document.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Html, Head, Main, NextScript } from 'next/document';
const Document = () => (
<Html lang='fr'>
<Head />
<title>Superpipo</title>
<body>
<noscript>
Vous devez activer JavaScript pour utiliser ce site
</noscript>
<Main />
<NextScript />
</body>
</Html>
)
export default Document;

View File

@@ -1,48 +1,30 @@
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
export default NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
username: { label: 'Email', type: 'text', placeholder: 'user@example.com' },
password: { label: 'Mot de passe', type: 'password', placeholder: '********' }
},
async authorize(credentials, req) {
return { email: 'user@example.com' };
const email = credentials?.email;
const password = credentials?.password;
if (!email || !password)
return null;
let user;
try {
user = await prisma.user.findUnique({
where: { email }
});
} catch (error) {
console.error(`Impossible de récupérer l'utilisateur avec les identifiants : ${credentials}`);
return null;
}
if (!user)
return null;
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch)
return null;
return {
email: user.email
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;
}
}
});

45
pages/category/create.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
import Input from '../../components/input';
import styles from '../../styles/create.module.scss';
import Head from 'next/head';
export default function CreateCategory() {
const { data: session, status } = useSession({ required: true });
const [name, setName] = useState<string>('');
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
const handleSubmit = (event) => {
event.preventDefault();
console.log('On peut envoyer la requête pour créer une catégorie');
}
return (
<div className={`App ${styles['create-app']}`}>
<Head>
<title>Superpipo Créer une categorie</title>
</Head>
<h2>Créer une categorie</h2>
<form onSubmit={handleSubmit}>
<Input
name='name'
label='Nom de la catégorie'
onChangeCallback={({ target }, value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
/>
<button type='submit' disabled={name.length < 1}>
Valider
</button>
</form>
<Link href='/'>
<a> Revenir à l'accueil</a>
</Link>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { createRef, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
import Categories from '../components/Categories/Categories';
import Links from '../components/Links/Links';
import styles from '../styles/Home.module.scss';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default function Home({ categories, favorites }) {
const { data: session, status } = useSession();
const [categoryActive, setCategoryActive] = useState(categories?.[0]?.id);
const refCategoryActive = useRef();
const handleSelectCategory = (id) => {
if (!refCategoryActive?.current) return;
const { ref } = categories.find(c => c.id === id);
ref?.current?.scrollIntoView({
block: 'end',
behavior: 'smooth'
});
}
console.log(session, session?.user);
return (
<div className={styles.App}>
<Categories
categories={categories}
favorites={favorites}
handleSelectCategory={handleSelectCategory}
categoryActive={categoryActive}
session={session}
/>
<Links
categories={categories}
setCategoryActive={setCategoryActive}
refCategoryActive={refCategoryActive}
session={session}
/>
</div>
)
}
export async function getStaticProps(context) {
const categories = await prisma.category.findMany({
include: {
links: true
}
});
const favorites = [];
categories.map((category) => {
category['ref'] = createRef();
category['links'] = category.links.map((link) => {
if (link.favorite)
favorites.push(link);
link['categoryName'] = category.name;
return link;
});
return category;
});
return {
props: {
categories: JSON.parse(JSON.stringify(categories)),
favorites: JSON.parse(JSON.stringify(favorites))
}
}
}

93
pages/index.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { createRef, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
import { Provider } from 'react-redux';
import Head from 'next/head'
import Categories from '../components/Categories/Categories';
import Links from '../components/Links/Links';
import { Category, Link } from '../types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { store } from '../redux';
interface HomeProps {
categories: Category[];
favorites: Link[];
}
export default function Home({ categories, favorites }: HomeProps) {
const { data: session, status } = useSession({ required: true });
const [categoryActive, setCategoryActive] = useState<Category | null>(categories?.[0]);
const handleSelectCategory = (category: Category) => setCategoryActive(category);
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
return (
<Provider store={store}>
<Head>
<title>Superpipo</title>
</Head>
<div className='App'>
<Categories
categories={categories}
favorites={favorites}
handleSelectCategory={handleSelectCategory}
categoryActive={categoryActive}
session={session}
/>
<Links category={categoryActive} />
</div>
</Provider>
)
}
export async function getStaticProps() {
const categoriesDB = await prisma.category.findMany({ include: { links: true } });
const favorites = [] as Link[];
const categories = categoriesDB.map((categoryDB) => {
const category = BuildCategory(categoryDB);
category.links.map((link) => link.favorite ? favorites.push(link) : null);
return category;
});
return {
props: {
categories: JSON.parse(JSON.stringify(categories)),
favorites: JSON.parse(JSON.stringify(favorites)),
}
}
}
export function BuildCategory({ id, name, order, links = [], createdAt, updatedAt }): Category {
return {
id,
name,
links: links.map((link) => BuildLink(link, { categoryId: id, categoryName: name })),
order,
createdAt,
updatedAt
}
}
export function BuildLink({ id, name, url, order, favorite, createdAt, updatedAt }, { categoryId, categoryName }): Link {
return {
id,
name,
url,
category: {
id: categoryId,
name: categoryName
},
order,
favorite,
createdAt,
updatedAt
}
}

94
pages/link/create.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
import Input from '../../components/input';
import styles from '../../styles/create.module.scss';
import { Category } from '../../types';
import { BuildCategory } from '..';
import { PrismaClient } from '@prisma/client';
import Selector from '../../components/selector';
import Head from 'next/head';
const prisma = new PrismaClient();
export default function CreateLink({ categories }: { categories: Category[]; }) {
const { status } = useSession({ required: true });
const [name, setName] = useState<string>('');
const [url, setUrl] = useState<string>('');
const [categoryId, setCategoryId] = useState<number | null>(categories?.[0].id || null);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
useEffect(() => {
const regex = new RegExp('https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,}');
if (name !== '' && url.match(regex) && categoryId !== null) {
setCanSubmit(false);
} else {
setCanSubmit(true);
}
}, [name, url, categoryId]);
if (status === 'loading') {
return (<p>Chargement de la session en cours</p>)
}
const handleSubmit = (event) => {
event.preventDefault();
console.log('On peut envoyer la requête pour créer un lien');
}
return (<>
<Head>
<title>Superpipo Créer un lien</title>
</Head>
<div className={`App ${styles['create-app']}`}>
<h2>Créer un lien</h2>
<form onSubmit={handleSubmit}>
<Input
name='name'
label='Label'
onChangeCallback={({ target }, value) => setName(value)}
value={name}
fieldClass={styles['input-field']}
placeholder='Label du lien'
/>
<Input
name='url'
label='URL'
onChangeCallback={({ target }, value) => setUrl(value)}
value={name}
fieldClass={styles['input-field']}
placeholder='URL du lien'
/>
<Selector
name='category'
label='Catégorie'
value={categoryId}
onChangeCallback={({ target }, value) => setCategoryId(value)}
>
{categories.map((category, key) => (
<option key={key} value={category.id}>{category.name}</option>
))}
</Selector>
<button type='submit' disabled={canSubmit}>
Valider
</button>
</form>
<Link href='/'>
<a> Revenir à l'accueil</a>
</Link>
</div>
</>);
}
export async function getStaticProps() {
const categoriesDB = await prisma.category.findMany();
const categories = categoriesDB.map((categoryDB) => BuildCategory(categoryDB));
return {
props: {
categories: JSON.parse(JSON.stringify(categories))
}
}
}

89
redux.ts Normal file
View File

@@ -0,0 +1,89 @@
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Category, Link } from "./types";
const categoriesSlice = createSlice({
name: 'categories',
initialState: [] as Category[],
reducers: {
addCategory: (state: Category[], { payload }: { payload: Category }) => {
const categories = [...state];
categories.push(payload);
return categories;
},
editCategory: (state: Category[], { payload }: { payload: Category }) => {
const categories = [...state];
const categoryIndex = categories.findIndex(category => category.id === payload.id);
if (categoryIndex === -1) {
return categories
}
categories[categoryIndex] = payload;
return categories;
},
removeCategory: (state: Category[], { payload }: { payload: Category }) => {
const categories = [...state];
const categoryIndex = categories.findIndex(category => category.id === payload.id);
if (categoryIndex === -1) {
return categories
}
categories.slice(categoryIndex, 1);
return categories;
},
addLink: (state: Category[], { payload }: { payload: Link }) => {
const categories = [...state];
const categoryIndex = state.findIndex(link => link.id === payload.id);
if (categoryIndex === -1) {
return categories;
}
categories[categoryIndex].links.push(payload);
return categories;
},
editLink: (state: Category[], { payload }: { payload: Link }) => {
const categories = [...state];
const categoryIndex = state.findIndex(link => link.id === payload.id);
if (categoryIndex === -1) {
return categories;
}
const linkIndex = categories[categoryIndex].links.findIndex(link => link.id === payload.id);
if (linkIndex === -1) {
return categories;
}
categories[categoryIndex].links[linkIndex] = payload;
return categories;
},
removeLink: (state: Category[], { payload }: { payload: Link }) => {
const categories = [...state];
const categoryIndex = state.findIndex(link => link.id === payload.id);
if (categoryIndex === -1) {
return categories;
}
const linkIndex = categories[categoryIndex].links.findIndex(link => link.id === payload.id);
if (linkIndex === -1) {
return categories;
}
categories[categoryIndex].links.splice(linkIndex, 1);
return categories;
},
}
});
export const store = configureStore({
reducer: {
categories: categoriesSlice.reducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false })
});
export const {
addCategory,
addLink,
editLink,
removeLink
} = categoriesSlice.actions;

View File

@@ -1,13 +0,0 @@
.App {
height: 100%;
width: 1280px;
padding: 10px;
display: flex;
justify-content: center;
}
@media (max-width: 1280px) {
.App {
width: 100%;
}
}

27
styles/create.module.scss Normal file
View File

@@ -0,0 +1,27 @@
.create-app {
width: 680px;
flex-direction: column;
gap: 15px;
& h2 {
color: #3f88c5;
}
& form {
display: flex;
gap: 5px;
flex-direction: column;
}
& .input-field {
display: flex;
gap: 5px;
flex-direction: column;
}
}
@media (max-width: 680px) {
.create-app {
width: 100%;
}
}

View File

@@ -12,6 +12,7 @@ body {
height: 100%;
width: 100%;
color: #111;
background-color: #f0eef6;
font-family: "Poppins", sans-serif;
padding: 0;
margin: 0;
@@ -27,6 +28,28 @@ body {
flex-direction: column;
}
.App {
height: 100%;
width: 1280px;
padding: 10px;
display: flex;
justify-content: center;
}
a {
width: fit-content;
color: #3f88c5;
border-bottom: 1px solid transparent;
text-decoration: none;
transition: 0.15s;
&:hover,
&:focus {
color: #3d7bab;
border-bottom: 1px solid #3d7bab;
}
}
ul,
li {
list-style: none;
@@ -61,9 +84,61 @@ button {
padding: 10px;
border: 1px solid #3f88c5;
border-radius: 3px;
transition: .15s;
transition: 0.15s;
&:hover {
transform: scale(1.01);
&:disabled {
cursor: default;
opacity: 0.5;
}
}
&:not(:disabled):hover {
box-shadow: #105b97 0 0 3px 1px;
background: #105b97;
color: #fff;
}
}
input:not(.nostyle) {
color: #333;
background: #fff;
padding: 10px;
border: 1px solid #dadce0;
border-bottom: 3px solid #dadce0;
transition: 0.15s;
&:focus {
border-bottom: 3px solid #3f88c5;
}
}
input::placeholder {
font-style: italic;
color: #dadce0;
}
.input-field {
& label,
& input,
& select {
width: 100%;
}
}
select:not(.nostyle) {
color: #333;
background: #fff;
padding: 10px;
border: 1px solid #dadce0;
border-bottom: 3px solid #dadce0;
transition: 0.15s;
&:focus {
border-bottom: 3px solid #3f88c5;
}
}
@media (max-width: 1280px) {
.App {
width: 100%;
}
}

View File

@@ -12,12 +12,11 @@
& .block-wrapper {
height: auto;
width: 100%;
margin-bottom: 15px;
& h4 {
user-select: none;
text-transform: uppercase;
font-size: .85em;
font-size: 0.85em;
color: #bbb;
margin-bottom: 5px;
}
@@ -26,13 +25,22 @@
& .items .item {
user-select: none;
cursor: pointer;
padding: 5px 10px;
background-color: #fff;
padding: 7px 12px;
border: 1px solid #dadce0;
border-bottom: 2px solid #dadce0;
border-radius: 3px;
margin-bottom: 5px;
transition: .15s;
transition: 0.15s;
&:not(:last-child) {
margin-bottom: 5px;
}
& .links-count {
color: #bbb;
font-size: 0.85em;
}
&.active {
color: #fff;
background: #3f88c5;
@@ -41,7 +49,7 @@
&:hover:not(.active) {
color: #3f88c5;
background: #eee;
background: #f0eef6;
border-bottom: 2px solid #3f88c5;
}
}
@@ -49,6 +57,8 @@
// Favorites
& .block-wrapper.favorites {
margin-bottom: 15px;
& .items .item a {
text-decoration: none;
color: inherit;
@@ -56,7 +66,7 @@
& .items .item .category {
color: #bbb;
font-size: .85em;
font-size: 0.85em;
}
}
@@ -66,7 +76,7 @@
display: flex;
flex: 1;
flex-direction: column;
& .items {
overflow: auto;
@@ -77,16 +87,50 @@
}
// Controls
& .block-wrapper.controls {
margin-bottom: 0;
.controls {
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
}
.modal {
height: 500px;
width: 500px;
}
// User Card
& .user-card-wrapper {
position: relative;
height: fit-content;
width: 100%;
color: #333;
background-color: #fff;
& .user-card {
border-right: 1px solid #dadce0;
padding: 7px 12px;
display: flex;
gap: 10px;
align-items: center;
& img {
border-radius: 50%;
}
}
& .disconnect-btn {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
color: #fff;
background-color: red;
border: 1px solid red;
display: none;
}
&:hover .disconnect-btn {
display: block;
border: 1px solid darkred;
box-shadow: red 0 0 3px 1px;
}
}
}

View File

@@ -0,0 +1,72 @@
.links-wrapper {
height: 100%;
padding: 10px;
flex: 1;
overflow-x: auto;
& h2 {
color: #3f88c5;
margin-bottom: 15px;
& .links-count {
color: #bbb;
font-weight: 300;
font-size: 0.8em;
}
}
& .links {
width: 100%;
display: flex;
flex-direction: column;
}
& .links .link {
user-select: none;
cursor: pointer;
& > a {
height: fit-content;
width: 100%;
color: #3f88c5;
background-color: #fff;
text-decoration: none;
padding: 10px 15px;
border: 1px solid #dadce0;
border-bottom: 2px solid #dadce0;
border-radius: 3px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
transition: 0.1s;
&:hover {
border-bottom-color: #3f88c5;
background: #f0eef6;
& .url-pathname {
opacity: 1 !important;
}
}
& .link-name .link-category {
color: #bbb;
font-size: 0.9em;
}
& .link-url {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: #bbb;
font-size: 0.8em;
& .url-pathname {
opacity: 0;
transition: 0.1s;
}
}
}
}
}

View File

@@ -1,66 +0,0 @@
.links-wrapper {
height: 100%;
padding: 10px;
flex: 1;
overflow-x: auto;
scroll-snap-type: y mandatory;
& .link-block {
min-height: 100%;
width: 100%;
margin-bottom: 10px;
flex: 1;
display: flex;
flex-direction: column;
scroll-snap-align: center;
& h2 {
color: #3f88c5;
margin-bottom: 15px;
}
& .links {
width: 100%;
display: flex;
flex-direction: column;
}
& .links .link {
user-select: none;
cursor: pointer;
& > a {
color: #3f88c5;
text-decoration: none;
padding: 10px 15px;
border: 1px solid #dadce0;
border-bottom: 2px solid #dadce0;
border-radius: 3px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
transition: .05s;
&:hover {
border-bottom-color: #3f88c5;
background: #eee;
margin: 0 5px 10px;
}
& .link-name .link-category {
color: #bbb;
font-size: .9em;
}
& .link-url {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: #bbb;
font-size: .8em;
}
}
}
}
}

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

28
types.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
export interface Category {
id: number;
name: string;
links: Link[];
order: number;
createdAt: Date;
updatedAt: Date;
}
export interface Link {
id: number;
name: string;
url: string;
category: {
id: number;
name: string;
}
order: number;
favorite: boolean;
createdAt: Date;
updatedAt: Date;
}

3292
yarn.lock

File diff suppressed because it is too large Load Diff