Add translation (#9)

* feat(wip): translation

* fix: some i18n errors + use ssr translation

* fix: i18next implementation

* fix: tsc errors

* feat: i18n middleware

* feat: translate link view home page

* feat: translate quick actions

* feat: translate search modal

* feat: translate side menu

* feat: native error boundary + translation

* feat: translate error pages

* feat: translate category forms

* feat: translate link forms

* refactor: LangSelector is no longer absolute by default
This commit is contained in:
Sonny
2023-11-11 00:07:10 +01:00
parent 6d07cea92f
commit 255f50080a
53 changed files with 896 additions and 419 deletions

47
middleware.ts Normal file
View File

@@ -0,0 +1,47 @@
import acceptLanguage from "accept-language";
import { NextResponse } from "next/server";
import { i18n } from "./next-i18next.config";
acceptLanguage.languages(i18n.locales);
export const config = {
matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)"],
};
const cookieName = "i18next";
// Source : https://github.com/i18next/next-app-dir-i18next-example/blob/3d653a46ae33f46abc011b6186f7a4595b84129f/middleware.js
export function middleware(req) {
if (
req.nextUrl.pathname.indexOf("icon") > -1 ||
req.nextUrl.pathname.indexOf("chrome") > -1
)
return NextResponse.next();
let lng;
if (req.cookies.has(cookieName))
lng = acceptLanguage.get(req.cookies.get(cookieName).value);
if (!lng) lng = acceptLanguage.get(req.headers.get("Accept-Language"));
if (!lng) lng = i18n.defaultLocale;
// Redirect if lng in path is not supported
if (
!i18n.locales.some((loc) => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith("/_next")
) {
return NextResponse.redirect(
new URL(`/${lng}${req.nextUrl.pathname}`, req.url)
);
}
if (req.headers.has("referer")) {
const refererUrl = new URL(req.headers.get("referer"));
const lngInReferer = i18n.locales.find((l) =>
refererUrl.pathname.startsWith(`/${l}`)
);
const response = NextResponse.next();
if (lngInReferer) response.cookies.set(cookieName, lngInReferer);
return response;
}
return NextResponse.next();
}

10
next-i18next.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next-i18next').UserConfig} */
module.exports = {
// debug: process.env.NODE_ENV === "development",
i18n: {
defaultLocale: "en",
locales: ["en", "fr"],
},
reloadOnPrerender: process.env.NODE_ENV === "development",
returnNull: false,
};

View File

@@ -1,5 +1,8 @@
const { i18n } = require("./next-i18next.config");
/** @type {import('next').NextConfig} */
const config = {
i18n,
webpack(config) {
config.module.rules.push({
test: /\.svg$/,

416
package-lock.json generated
View File

@@ -8,22 +8,24 @@
"dependencies": {
"@prisma/client": "^5.5.2",
"@svgr/webpack": "^8.1.0",
"accept-language": "^3.0.18",
"axios": "^1.6.1",
"framer-motion": "^10.16.4",
"next": "^14.0.1",
"i18next": "^23.7.1",
"next": "^14.0.2",
"next-auth": "^4.24.4",
"next-i18next": "^15.0.0",
"next-seo": "^6.4.0",
"node-html-parser": "^6.1.11",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11",
"react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.3.1",
"react-icons": "^4.11.0",
"react-select": "^5.8.0",
"sass": "^1.69.5",
"sharp": "^0.32.6",
"toastr": "^2.1.4",
"yup": "^1.3.2"
},
"devDependencies": {
@@ -33,7 +35,7 @@
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
"eslint-config-next": "14.0.1",
"eslint-config-next": "14.0.2",
"prisma": "^5.5.2",
"typescript": "5.2.2"
}
@@ -60,11 +62,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz",
"integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==",
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"dependencies": {
"@babel/highlight": "^7.18.6"
"@babel/highlight": "^7.22.13",
"chalk": "^2.4.2"
},
"engines": {
"node": ">=6.9.0"
@@ -108,11 +111,11 @@
}
},
"node_modules/@babel/generator": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.4.tgz",
"integrity": "sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
"integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
"dependencies": {
"@babel/types": "^7.21.4",
"@babel/types": "^7.23.0",
"@jridgewell/gen-mapping": "^0.3.2",
"@jridgewell/trace-mapping": "^0.3.17",
"jsesc": "^2.5.1"
@@ -241,9 +244,9 @@
}
},
"node_modules/@babel/helper-environment-visitor": {
"version": "7.18.9",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
"integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
"integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
"engines": {
"node": ">=6.9.0"
}
@@ -260,23 +263,23 @@
}
},
"node_modules/@babel/helper-function-name": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz",
"integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
"integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
"dependencies": {
"@babel/template": "^7.20.7",
"@babel/types": "^7.21.0"
"@babel/template": "^7.22.15",
"@babel/types": "^7.23.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-hoist-variables": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
"integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
"integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
"dependencies": {
"@babel/types": "^7.18.6"
"@babel/types": "^7.22.5"
},
"engines": {
"node": ">=6.9.0"
@@ -397,28 +400,28 @@
}
},
"node_modules/@babel/helper-split-export-declaration": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
"integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
"version": "7.22.6",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
"integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
"dependencies": {
"@babel/types": "^7.18.6"
"@babel/types": "^7.22.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.19.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
"integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
"integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"engines": {
"node": ">=6.9.0"
}
@@ -459,12 +462,12 @@
}
},
"node_modules/@babel/highlight": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
"integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.18.6",
"chalk": "^2.0.0",
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0"
},
"engines": {
@@ -472,9 +475,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz",
"integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -1659,42 +1662,42 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==",
"version": "7.23.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"dependencies": {
"regenerator-runtime": "^0.13.11"
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
"integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
"dependencies": {
"@babel/code-frame": "^7.18.6",
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7"
"@babel/code-frame": "^7.22.13",
"@babel/parser": "^7.22.15",
"@babel/types": "^7.22.15"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.4.tgz",
"integrity": "sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q==",
"version": "7.23.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
"integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
"dependencies": {
"@babel/code-frame": "^7.21.4",
"@babel/generator": "^7.21.4",
"@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.21.0",
"@babel/helper-hoist-variables": "^7.18.6",
"@babel/helper-split-export-declaration": "^7.18.6",
"@babel/parser": "^7.21.4",
"@babel/types": "^7.21.4",
"@babel/code-frame": "^7.22.13",
"@babel/generator": "^7.23.0",
"@babel/helper-environment-visitor": "^7.22.20",
"@babel/helper-function-name": "^7.23.0",
"@babel/helper-hoist-variables": "^7.22.5",
"@babel/helper-split-export-declaration": "^7.22.6",
"@babel/parser": "^7.23.0",
"@babel/types": "^7.23.0",
"debug": "^4.1.0",
"globals": "^11.1.0"
},
@@ -1703,12 +1706,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.4.tgz",
"integrity": "sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==",
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
"dependencies": {
"@babel/helper-string-parser": "^7.19.4",
"@babel/helper-validator-identifier": "^7.19.1",
"@babel/helper-string-parser": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
},
"engines": {
@@ -2012,23 +2015,23 @@
}
},
"node_modules/@next/env": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.1.tgz",
"integrity": "sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A=="
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.2.tgz",
"integrity": "sha512-HAW1sljizEaduEOes/m84oUqeIDAUYBR1CDwu2tobNlNDFP3cSm9d6QsOsGeNlIppU1p/p1+bWbYCbvwjFiceA=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.1.tgz",
"integrity": "sha512-bLjJMwXdzvhnQOnxvHoTTUh/+PYk6FF/DCgHi4BXwXCINer+o1ZYfL9aVeezj/oI7wqGJOqwGIXrlBvPbAId3w==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.2.tgz",
"integrity": "sha512-APrYFsXfAhnysycqxHcpg6Y4i7Ukp30GzVSZQRKT3OczbzkqGjt33vNhScmgoOXYBU1CfkwgtXmNxdiwv1jKmg==",
"dev": true,
"dependencies": {
"glob": "7.1.7"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz",
"integrity": "sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.2.tgz",
"integrity": "sha512-i+jQY0fOb8L5gvGvojWyZMfQoQtDVB2kYe7fufOEiST6sicvzI2W5/EXo4lX5bLUjapHKe+nFxuVv7BA+Pd7LQ==",
"cpu": [
"arm64"
],
@@ -2041,9 +2044,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz",
"integrity": "sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.2.tgz",
"integrity": "sha512-zRCAO0d2hW6gBEa4wJaLn+gY8qtIqD3gYd9NjruuN98OCI6YyelmhWVVLlREjS7RYrm9OUQIp/iVJFeB6kP1hg==",
"cpu": [
"x64"
],
@@ -2056,9 +2059,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz",
"integrity": "sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.2.tgz",
"integrity": "sha512-tSJmiaon8YaKsVhi7GgRizZoV0N1Sx5+i+hFTrCKKQN7s3tuqW0Rov+RYdPhAv/pJl4qiG+XfSX4eJXqpNg3dA==",
"cpu": [
"arm64"
],
@@ -2071,9 +2074,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz",
"integrity": "sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.2.tgz",
"integrity": "sha512-dXJLMSEOwqJKcag1BeX1C+ekdPPJ9yXbWIt3nAadhbLx5CjACoB2NQj9Xcqu2tmdr5L6m34fR+fjGPs+ZVPLzA==",
"cpu": [
"arm64"
],
@@ -2086,9 +2089,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz",
"integrity": "sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.2.tgz",
"integrity": "sha512-WC9KAPSowj6as76P3vf1J3mf2QTm3Wv3FBzQi7UJ+dxWjK3MhHVWsWUo24AnmHx9qDcEtHM58okgZkXVqeLB+Q==",
"cpu": [
"x64"
],
@@ -2101,9 +2104,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz",
"integrity": "sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.2.tgz",
"integrity": "sha512-KSSAwvUcjtdZY4zJFa2f5VNJIwuEVnOSlqYqbQIawREJA+gUI6egeiRu290pXioQXnQHYYdXmnVNZ4M+VMB7KQ==",
"cpu": [
"x64"
],
@@ -2116,9 +2119,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz",
"integrity": "sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.2.tgz",
"integrity": "sha512-2/O0F1SqJ0bD3zqNuYge0ok7OEWCQwk55RPheDYD0va5ij7kYwrFkq5ycCRN0TLjLfxSF6xI5NM6nC5ux7svEQ==",
"cpu": [
"arm64"
],
@@ -2131,9 +2134,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz",
"integrity": "sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.2.tgz",
"integrity": "sha512-vJI/x70Id0oN4Bq/R6byBqV1/NS5Dl31zC+lowO8SDu1fHmUxoAdILZR5X/sKbiJpuvKcCrwbYgJU8FF/Gh50Q==",
"cpu": [
"ia32"
],
@@ -2146,9 +2149,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz",
"integrity": "sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.2.tgz",
"integrity": "sha512-Ut4LXIUvC5m8pHTe2j0vq/YDnTEyq6RSR9vHYPqnELrDapPhLNz9Od/L5Ow3J8RNDWpEnfCiQXuVdfjlNEJ7ug==",
"cpu": [
"x64"
],
@@ -2570,6 +2573,15 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
"integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==",
"dependencies": {
"@types/react": "*",
"hoist-non-react-statics": "^3.3.0"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2876,6 +2888,15 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true
},
"node_modules/accept-language": {
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/accept-language/-/accept-language-3.0.18.tgz",
"integrity": "sha512-sUofgqBPzgfcF20sPoBYGQ1IhQLt2LSkxTnlQSuLF3n5gPEqd5AimbvOvHEi0T1kLMiGVqPWzI5a9OteBRth3A==",
"dependencies": {
"bcp47": "^1.1.2",
"stable": "^0.1.6"
}
},
"node_modules/acorn": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
@@ -3229,6 +3250,14 @@
}
]
},
"node_modules/bcp47": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/bcp47/-/bcp47-1.1.2.tgz",
"integrity": "sha512-JnkkL4GUpOvvanH9AZPX38CxhiLsXMBicBY2IAtqiVN8YulGDQybUydWA4W6yAMtw6iShtw+8HEF6cfrTHU+UQ==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -3537,6 +3566,16 @@
"node": ">= 0.6"
}
},
"node_modules/core-js": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.33.2.tgz",
"integrity": "sha512-XeBzWI6QL3nJQiHmdzbAOiMYqjrb7hwU7A39Qhvd/POSa/t9E1AeZyEZx3fNvp/vtM8zXwhoL0FsiS0hD0pruQ==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.27.2",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.27.2.tgz",
@@ -4105,12 +4144,12 @@
}
},
"node_modules/eslint-config-next": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.1.tgz",
"integrity": "sha512-QfIFK2WD39H4WOespjgf6PLv9Bpsd7KGGelCtmq4l67nGvnlsGpuvj0hIT+aIy6p5gKH+lAChYILsyDlxP52yg==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.2.tgz",
"integrity": "sha512-CasWThlsyIcg/a+clU6KVOMTieuDhTztsrqvniP6AsRki9v7FnojTa7vKQOYM8QSOsQdZ/aElLD1Y2Oc8/PsIg==",
"dev": true,
"dependencies": {
"@next/eslint-plugin-next": "14.0.1",
"@next/eslint-plugin-next": "14.0.2",
"@rushstack/eslint-patch": "^1.3.3",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0",
"eslint-import-resolver-node": "^0.3.6",
@@ -5114,6 +5153,41 @@
"react-is": "^16.7.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.7.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.7.1.tgz",
"integrity": "sha512-lD2lZkdhb9jnIGGc2ja8ER6cGStgJ+jFVL336Sa1C37//2Q8odC617ek2oafYbbs0/a+BbUqKe5JPST2r88UEQ==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-fs-backend": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.2.0.tgz",
"integrity": "sha512-VOPHhdDX0M/csRqEw+9Ectpf6wvTIg1MZDfAHxc3JKnAlJz7fcZSAKAeyDohOq0xuLx57esYpJopIvBaRb0Bag=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5591,11 +5665,6 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jquery": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz",
"integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5873,11 +5942,11 @@
"dev": true
},
"node_modules/next": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.1.tgz",
"integrity": "sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.2.tgz",
"integrity": "sha512-jsAU2CkYS40GaQYOiLl9m93RTv2DA/tTJ0NRlmZIBIL87YwQ/xR8k796z7IqgM3jydI8G25dXvyYMC9VDIevIg==",
"dependencies": {
"@next/env": "14.0.1",
"@next/env": "14.0.2",
"@swc/helpers": "0.5.2",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406",
@@ -5892,15 +5961,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.0.1",
"@next/swc-darwin-x64": "14.0.1",
"@next/swc-linux-arm64-gnu": "14.0.1",
"@next/swc-linux-arm64-musl": "14.0.1",
"@next/swc-linux-x64-gnu": "14.0.1",
"@next/swc-linux-x64-musl": "14.0.1",
"@next/swc-win32-arm64-msvc": "14.0.1",
"@next/swc-win32-ia32-msvc": "14.0.1",
"@next/swc-win32-x64-msvc": "14.0.1"
"@next/swc-darwin-arm64": "14.0.2",
"@next/swc-darwin-x64": "14.0.2",
"@next/swc-linux-arm64-gnu": "14.0.2",
"@next/swc-linux-arm64-musl": "14.0.2",
"@next/swc-linux-x64-gnu": "14.0.2",
"@next/swc-linux-x64-musl": "14.0.2",
"@next/swc-win32-arm64-msvc": "14.0.2",
"@next/swc-win32-ia32-msvc": "14.0.2",
"@next/swc-win32-x64-msvc": "14.0.2"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -5944,6 +6013,41 @@
}
}
},
"node_modules/next-i18next": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/next-i18next/-/next-i18next-15.0.0.tgz",
"integrity": "sha512-9iGEU4dt1YCC5CXh6H8YHmDpmeWKjxES6XfoABxy9mmfaLLJcqS92F56ZKmVuZUPXEOLtgY/JtsnxsHYom9J4g==",
"funding": [
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://locize.com"
}
],
"dependencies": {
"@babel/runtime": "^7.23.2",
"@types/hoist-non-react-statics": "^3.3.4",
"core-js": "^3",
"hoist-non-react-statics": "^3.3.2",
"i18next-fs-backend": "^2.2.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"i18next": "^23.6.0",
"next": ">= 12.0.0",
"react": ">= 17.0.2",
"react-i18next": "^13.3.1"
}
},
"node_modules/next-seo": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.4.0.tgz",
@@ -5975,9 +6079,9 @@
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
"integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -6549,17 +6653,6 @@
"react": "^18.2.0"
}
},
"node_modules/react-error-boundary": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.11.tgz",
"integrity": "sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-hotkeys-hook": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz",
@@ -6569,6 +6662,27 @@
"react-dom": ">=16.8.1"
}
},
"node_modules/react-i18next": {
"version": "13.3.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.3.1.tgz",
"integrity": "sha512-JAtYREK879JXaN9GdzfBI4yJeo/XyLeXWUsRABvYXiFUakhZJ40l+kaTo+i+A/3cKIED41kS/HAbZ5BzFtq/Og==",
"dependencies": {
"@babel/runtime": "^7.22.5",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-icons": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz",
@@ -6678,9 +6792,9 @@
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
},
"node_modules/regenerator-transform": {
"version": "0.15.1",
@@ -7098,6 +7212,12 @@
"node": ">=0.10.0"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
"deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -7414,14 +7534,6 @@
"node": ">=8.0"
}
},
"node_modules/toastr": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz",
"integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==",
"dependencies": {
"jquery": ">=1.12.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
@@ -7698,6 +7810,14 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@@ -10,22 +10,24 @@
"dependencies": {
"@prisma/client": "^5.5.2",
"@svgr/webpack": "^8.1.0",
"accept-language": "^3.0.18",
"axios": "^1.6.1",
"framer-motion": "^10.16.4",
"next": "^14.0.1",
"i18next": "^23.7.1",
"next": "^14.0.2",
"next-auth": "^4.24.4",
"next-i18next": "^15.0.0",
"next-seo": "^6.4.0",
"node-html-parser": "^6.1.11",
"nprogress": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11",
"react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.3.1",
"react-icons": "^4.11.0",
"react-select": "^5.8.0",
"sass": "^1.69.5",
"sharp": "^0.32.6",
"toastr": "^2.1.4",
"yup": "^1.3.2"
},
"devDependencies": {
@@ -35,7 +37,7 @@
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
"eslint-config-next": "14.0.1",
"eslint-config-next": "14.0.2",
"prisma": "^5.5.2",
"typescript": "5.2.2"
}

View File

@@ -0,0 +1,33 @@
{
"confirm": "Confirm",
"cancel": "Cancel",
"back-home": "← Back to home page",
"logout": "Logout",
"login": "Login",
"link": {
"links": "Links",
"link": "Link",
"name": "Link name",
"create": "Create a link",
"edit": "Edit a link",
"remove": "Delete a link",
"remove-confirm": "Confirm deletion?"
},
"category": {
"categories": "Categories",
"category": "Category",
"name": "Category name",
"create": "Create a category",
"edit": "Edit a category",
"remove": "Delete a category",
"remove-confirm": "Confirm deletion?",
"remove-description": "You must delete all links in this category before you can delete this category."
},
"favorite": "Favorite",
"no-item-found": "No item found",
"search": "Search",
"avatar": "{{name}}'s avatar",
"generic-error": "Something went wrong",
"generic-error-description": "An error has occurred, if this happens again please <a href=\"https://github.com/Sonny93/my-links\" target=\"_blank\">create an issue</a> with as much detail as possible.",
"retry": "Retry"
}

View File

@@ -0,0 +1,5 @@
{
"select-categorie": "Please select a category",
"or-create-one": "or create one",
"no-link": "No link for <b>{{name}}</b>"
}

View File

@@ -0,0 +1,5 @@
{
"title": "Authentication",
"informative-text": "Authentication required to use MyLinks",
"continue-with": "Continue with {{provider}}"
}

View File

@@ -0,0 +1,33 @@
{
"confirm": "Confirmer",
"cancel": "Annuler",
"back-home": "← Revenir à l'accueil",
"logout": "Déconnexion",
"login": "Connexion",
"link": {
"links": "Liens",
"link": "Lien",
"name": "Nom du lien",
"create": "Créer un lien",
"edit": "Modifier un lien",
"remove": "Supprimer un lien",
"remove-confirm": "Confirmer la suppression ?"
},
"category": {
"categories": "Catégories",
"category": "Catégorie",
"name": "Nom de la catégorie",
"create": "Créer une catégorie",
"edit": "Modifier une catégorie",
"remove": "Supprimer une categorie",
"remove-confirm": "Confirmer la suppression ?",
"remove-description": "Vous devez supprimer tous les liens de cette catégorie avant de pouvoir supprimer cette catégorie"
},
"favorite": "Favoris",
"no-item-found": "Aucun élément trouvé",
"search": "Rechercher",
"avatar": "Avatar de {{name}}",
"generic-error": "Une erreur est survenue",
"generic-error-description": "Une erreur est survenue, si cela se reproduit merci de <a href=\"https://github.com/Sonny93/my-links\" target=\"_blank\">créer une issue</a> avec le maximum de détails.",
"retry": "Recommencer"
}

View File

@@ -0,0 +1,5 @@
{
"select-categorie": "Veuillez séléctionner une categorie",
"or-create-one": "ou en créer une",
"no-link": "Aucun lien pour <b>{{name}}</b>"
}

View File

@@ -0,0 +1,5 @@
{
"title": "Authentification",
"informative-text": "Authentification requise pour utiliser ce service",
"continue-with": "Continuer avec {{provider}}"
}

View File

@@ -1,22 +0,0 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
function fallbackRender({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre style={{ color: "red" }}>{error.message}</pre>
<button onClick={resetErrorBoundary}>retry</button>
</div>
);
}
export default function AppErrorBoundary({
children,
}: {
children: ReactNode;
}) {
return (
<ErrorBoundary fallbackRender={fallbackRender}>{children}</ErrorBoundary>
);
}

View File

@@ -15,6 +15,7 @@
}
& ul {
flex: 1;
animation: fadein 0.3s both;
}

View File

@@ -0,0 +1,48 @@
import { withTranslation } from 'next-i18next';
import React from 'react';
import LangSelector from '../LangSelector';
import styles from './error-boundary.module.scss';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: false, errorInfo: null };
}
componentDidCatch(error, errorInfo) {
// Catch errors in any components below and re-render with error message
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (!this.state.errorInfo) return this.props.children;
return (
<div className={styles["error-boundary"]}>
<div className={styles["boundary-content"]}>
<h1>{this.props.t('common:generic-error')}</h1>
<p
dangerouslySetInnerHTML={{
__html: this.props.t('common:generic-error-description')
}}
/>
<button onClick={() => window.location.reload()}>
{this.props.t('common:retry')}
</button>
<details>
<summary>{this.state.error && this.state.error.toString()}</summary>
<code>{this.state.errorInfo.componentStack}</code>
</details>
<div className='lang-selector'>
<LangSelector />
</div>
</div>
</div>
);
}
}
export default withTranslation()(ErrorBoundary);

View File

@@ -0,0 +1,29 @@
.error-boundary {
padding: 3em 2em;
display: flex;
justify-content: center;
& .boundary-content {
height: 100%;
width: 600px;
gap: 2em;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
& h1 {
margin: 0;
}
& button {
width: 175px;
}
}
@media (max-width: 600px) {
.error-boundary .boundary-content {
width: 100%;
}
}

View File

@@ -1,8 +1,8 @@
import MessageManager from "components/MessageManager/MessageManager";
import { i18n, useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import Link from "next/link";
import MessageManager from "components/MessageManager/MessageManager";
interface FormProps {
title: string;
@@ -29,11 +29,13 @@ export default function Form({
infoMessage,
canSubmit,
handleSubmit,
textBtnConfirm = "Valider",
textBtnConfirm = i18n.t("common:confirm"),
classBtnConfirm = "",
children,
disableHomeLink = false,
}: FormProps) {
const { t } = useTranslation();
return (
<>
<NextSeo title={title} />
@@ -46,7 +48,7 @@ export default function Form({
</form>
{!disableHomeLink && (
<Link href={categoryId ? `/?categoryId=${categoryId}` : "/"}>
Revenir à l'accueil
{t("common:back-home")}
</Link>
)}
<MessageManager

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
export default function LangSelector() {
const router = useRouter();
const { i18n } = useTranslation();
const onToggleLanguageClick = (newLocale: string) => {
const { pathname, asPath, query } = router;
i18n.changeLanguage(newLocale);
router.push({ pathname, query }, asPath, { locale: newLocale });
};
const languages = ["en", "fr"];
return (
<select
name="lng-select"
id="lng-select"
onChange={(event) => {
onToggleLanguageClick(event.target.value);
}}
value={i18n.language}
>
{languages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
);
}

View File

@@ -1,16 +1,15 @@
import { motion } from "framer-motion";
import LinkTag from "next/link";
import { Category, Link } from "types";
import EditItem from "components/QuickActions/EditItem";
import RemoveItem from "components/QuickActions/RemoveItem";
import LinkItem from "./LinkItem";
import ButtonLink from "components/ButtonLink";
import CreateItem from "components/QuickActions/CreateItem";
import EditItem from "components/QuickActions/EditItem";
import RemoveItem from "components/QuickActions/RemoveItem";
import QuickActionSearch from "components/QuickActions/Search";
import { motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import LinkTag from "next/link";
import { RxHamburgerMenu } from "react-icons/rx";
import { Category, Link } from "types";
import { TFunctionParam } from "types/i18next";
import LinkItem from "./LinkItem";
import styles from "./links.module.scss";
export default function Links({
@@ -26,11 +25,13 @@ export default function Links({
openMobileModal: () => void;
openSearchModal: () => void;
}) {
const { t } = useTranslation("home");
if (category === null) {
return (
<div className={styles["no-category"]}>
<p>Veuillez séléctionner une categorié</p>
<LinkTag href="/category/create">ou en créer une</LinkTag>
<p>{t("home:select-categorie")}</p>
<LinkTag href="/category/create">{t("home:or-create-one")}</LinkTag>
</div>
);
}
@@ -85,11 +86,14 @@ export default function Links({
damping: 20,
duration: 0.01,
}}
>
Aucun lien pour <b>{name}</b>
</motion.p>
dangerouslySetInnerHTML={{
__html: t("home:no-link", { name } as TFunctionParam, {
interpolation: { escapeValue: false },
}),
}}
/>
<LinkTag href={`/link/create?categoryId=${id}`}>
Créer un lien
{t("common:link.create")}
</LinkTag>
</div>
)}

View File

@@ -1,15 +1,21 @@
import { motion } from "framer-motion";
import useIsMobile from "hooks/useIsMobile";
import { CSSProperties, ReactNode } from "react";
import LangSelector from "./LangSelector";
export default function PageTransition({
className,
children,
style = {},
hideLangageSelector = false,
}: {
className?: string;
children: ReactNode;
style?: CSSProperties;
hideLangageSelector?: boolean;
}) {
const isMobile = useIsMobile();
return (
<motion.div
className={className}
@@ -23,6 +29,11 @@ export default function PageTransition({
style={style}
>
{children}
{!hideLangageSelector && !isMobile && (
<div className="lang-selector">
<LangSelector />
</div>
)}
</motion.div>
);
}

View File

@@ -1,8 +1,7 @@
import { useTranslation } from "next-i18next";
import LinkTag from "next/link";
import { IoAddOutline } from "react-icons/io5";
import { Category } from "types";
import styles from "./quickactions.module.scss";
export default function CreateItem({
@@ -12,12 +11,14 @@ export default function CreateItem({
}: {
type: "category" | "link";
categoryId?: Category["id"];
onClick?: (event: any) => void; // FIXME: find good event type
onClick?: (event: any) => void;
}) {
const { t } = useTranslation("home");
return (
<LinkTag
href={`/${type}/create${categoryId && `?categoryId=${categoryId}`}`}
title={`Create ${type}`}
title={t(`common:${type}.create`)}
className={styles["action"]}
onClick={onClick && onClick}
>

View File

@@ -1,8 +1,7 @@
import LinkTag from "next/link";
import { useTranslation } from "next-i18next";
import { AiOutlineEdit } from "react-icons/ai";
import { Category, Link } from "types";
import styles from "./quickactions.module.scss";
export default function EditItem({
@@ -13,13 +12,15 @@ export default function EditItem({
}: {
type: "category" | "link";
id: Link["id"] | Category["id"];
onClick?: (event: any) => void; // FIXME: find good event type
onClick?: (event: any) => void;
className?: string;
}) {
const { t } = useTranslation("home");
return (
<LinkTag
href={`/${type}/edit/${id}`}
title={`Edit ${type}`}
title={t(`common:${type}.edit`)}
className={`${styles["action"]} ${className ? className : ""}`}
onClick={onClick && onClick}
>

View File

@@ -1,8 +1,7 @@
import { useTranslation } from "next-i18next";
import LinkTag from "next/link";
import { CgTrashEmpty } from "react-icons/cg";
import { Category, Link } from "types";
import styles from "./quickactions.module.scss";
export default function RemoveItem({
@@ -12,12 +11,14 @@ export default function RemoveItem({
}: {
type: "category" | "link";
id: Link["id"] | Category["id"];
onClick?: (event: any) => void; // FIXME: find good event type
onClick?: (event: any) => void;
}) {
const { t } = useTranslation("home");
return (
<LinkTag
href={`/${type}/remove/${id}`}
title={`Remove ${type}`}
title={t(`common:${type}.remove`)}
className={styles["action"]}
onClick={onClick && onClick}
>

View File

@@ -1,6 +1,5 @@
import ButtonLink from "components/ButtonLink";
import { BiSearchAlt } from "react-icons/bi";
import styles from "./quickactions.module.scss";
export default function QuickActionSearch({

View File

@@ -1,12 +1,10 @@
import * as Keys from "constants/keys";
import { useTranslation } from "next-i18next";
import { ReactNode, useEffect, useMemo } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { SearchItem } from "types";
import { groupItemBy } from "utils/array";
import SearchListItem from "./SearchListItem";
import * as Keys from "constants/keys";
import styles from "./search.module.scss";
const isActiveItem = (item: SearchItem, otherItem: SearchItem) =>
@@ -65,8 +63,9 @@ export default function SearchList({
<ul className={styles["search-list"]}>
{groupedItems.length > 0 ? (
groupedItems.map(([key, items]) => (
<li key={key + "-" + key}>
<li>{typeof key === "undefined" ? "-" : key}</li>
<li key={`${key}-${key}`}>
<span>{typeof key === "undefined" ? "-" : key}</span>
<ul>
{items.map((item) => (
<SearchListItem
item={item}
@@ -75,6 +74,7 @@ export default function SearchList({
key={item.id}
/>
))}
</ul>
</li>
))
) : noItem ? (
@@ -87,5 +87,6 @@ export default function SearchList({
}
function LabelNoItem() {
return <i className={styles["no-item"]}>Aucun élément trouvé</i>;
const { t } = useTranslation("home");
return <i className={styles["no-item"]}>{t("common:no-item-found")}</i>;
}

View File

@@ -34,6 +34,7 @@ export default function SearchListItem({
}
ref={ref}
key={id}
title={name}
>
<LinkTag
href={url}

View File

@@ -1,17 +1,14 @@
import { FormEvent, useCallback, useMemo, useState } from "react";
import { BsSearch } from "react-icons/bs";
import useAutoFocus from "hooks/useAutoFocus";
import Modal from "components/Modal/Modal";
import TextBox from "components/TextBox";
import { GOOGLE_SEARCH_URL } from "constants/search-urls";
import useAutoFocus from "hooks/useAutoFocus";
import { useLocalStorage } from "hooks/useLocalStorage";
import { useTranslation } from "next-i18next";
import { FormEvent, useCallback, useMemo, useState } from "react";
import { BsSearch } from "react-icons/bs";
import { Category, SearchItem } from "types";
import LabelSearchWithGoogle from "./LabelSearchWithGoogle";
import SearchList from "./SearchList";
import { GOOGLE_SEARCH_URL } from "constants/search-urls";
import { useLocalStorage } from "hooks/useLocalStorage";
import { Category, SearchItem } from "types";
import styles from "./search.module.scss";
export default function SearchModal({
@@ -27,6 +24,7 @@ export default function SearchModal({
items: SearchItem[];
noHeader?: boolean;
}) {
const { t } = useTranslation();
const autoFocusRef = useAutoFocus();
const [canSearchLink, setCanSearchLink] = useLocalStorage(
@@ -110,7 +108,7 @@ export default function SearchModal({
name="search"
onChangeCallback={handleSearchInputChange}
value={search}
placeholder="Rechercher"
placeholder={t("common:search")}
innerRef={autoFocusRef}
fieldClass={styles["search-input-field"]}
inputClass={"reset"}
@@ -132,7 +130,7 @@ export default function SearchModal({
/>
)}
<button type="submit" disabled={!canSubmit} style={{ display: "none" }}>
Valider
{t("common:confirm")}
</button>
</form>
</Modal>
@@ -150,6 +148,8 @@ function SearchFilter({
canSearchCategory: boolean;
setCanSearchCategory: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div
style={{
@@ -160,7 +160,6 @@ function SearchFilter({
marginBottom: "1em",
}}
>
{/* à remplacer par des Chips Checkbox */}
<div style={{ display: "flex", gap: ".25em" }}>
<input
type="checkbox"
@@ -169,7 +168,7 @@ function SearchFilter({
onChange={({ target }) => setCanSearchLink(target.checked)}
checked={canSearchLink}
/>
<label htmlFor="filter-link">liens</label>
<label htmlFor="filter-link">{t("common:link.links")}</label>
</div>
<div style={{ display: "flex", gap: ".25em" }}>
<input
@@ -179,7 +178,9 @@ function SearchFilter({
onChange={({ target }) => setCanSearchCategory(target.checked)}
checked={canSearchCategory}
/>
<label htmlFor="filter-category">categories</label>
<label htmlFor="filter-category">
{t("common:category.categories")}
</label>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "next-i18next";
import { useMemo } from "react";
import { Category } from "types";
import CategoryItem from "./CategoryItem";
import { useMemo } from "react";
import styles from "./categories.module.scss";
interface CategoriesProps {
@@ -14,13 +14,17 @@ export default function Categories({
categoryActive,
handleSelectCategory,
}: CategoriesProps) {
const { t } = useTranslation();
const linksCount = useMemo(
() => categories.reduce((acc, current) => (acc += current.links.length), 0),
[categories]
);
return (
<div className={styles["categories"]}>
<h4>Catégories {linksCount}</h4>
<h4>
{t("common:category.categories")} {linksCount}
</h4>
<ul className={styles["items"]}>
{categories.map((category, index) => (
<CategoryItem

View File

@@ -1,9 +1,7 @@
import { motion } from "framer-motion";
import { useEffect, useRef } from "react";
import { AiFillFolderOpen, AiOutlineFolder } from "react-icons/ai";
import { Category } from "types";
import { motion } from "framer-motion";
import styles from "./categories.module.scss";
interface CategoryItemProps {

View File

@@ -1,13 +1,15 @@
import { useTranslation } from "next-i18next";
import { Link } from "types";
import FavoriteItem from "./FavoriteItem";
import styles from "./favorites.module.scss";
export default function Favorites({ favorites }: { favorites: Link[] }) {
const { t } = useTranslation();
return (
favorites.length !== 0 && (
<div className={styles["favorites"]}>
<h4>Favoris</h4>
<h4>{t("common:favorite")}</h4>
<ul className={styles["items"]}>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />

View File

@@ -1,7 +1,7 @@
import PATHS from "constants/paths";
import { SideMenuProps } from "./SideMenu";
import ButtonLink from "components/ButtonLink";
import PATHS from "constants/paths";
import { useTranslation } from "next-i18next";
import { SideMenuProps } from "./SideMenu";
import styles from "./sidemenu.module.scss";
export default function NavigationLinks({
@@ -11,25 +11,25 @@ export default function NavigationLinks({
categoryActive: SideMenuProps["categoryActive"];
openSearchModal: SideMenuProps["openSearchModal"];
}) {
const handleOpenSearchModal = (event) => {
event.preventDefault();
openSearchModal();
};
const { t } = useTranslation();
return (
<div className={styles["menu-controls"]}>
<div className={styles["action"]}>
<ButtonLink onClick={openSearchModal}>Rechercher</ButtonLink>
<ButtonLink onClick={openSearchModal}>{t("common:search")}</ButtonLink>
<kbd>S</kbd>
</div>
<div className={styles["action"]}>
<ButtonLink href={PATHS.CATEGORY.CREATE}>Créer categorie</ButtonLink>
<ButtonLink href={PATHS.CATEGORY.CREATE}>
{t("common:category.create")}
</ButtonLink>
<kbd>C</kbd>
</div>
<div className={styles["action"]}>
<ButtonLink
href={`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`}
>
Créer lien
{t("common:link.create")}
</ButtonLink>
<kbd>L</kbd>
</div>

View File

@@ -1,14 +1,11 @@
import { useHotkeys } from "react-hotkeys-hook";
import BlockWrapper from "components/BlockWrapper/BlockWrapper";
import Categories from "./Categories/Categories";
import NavigationLinks from "./NavigationLinks";
import Favorites from "./Favorites/Favorites";
import UserCard from "./UserCard/UserCard";
import * as Keys from "constants/keys";
import { useHotkeys } from "react-hotkeys-hook";
import { Category, Link } from "types";
import Categories from "./Categories/Categories";
import Favorites from "./Favorites/Favorites";
import NavigationLinks from "./NavigationLinks";
import UserCard from "./UserCard/UserCard";
import styles from "./sidemenu.module.scss";
export interface SideMenuProps {

View File

@@ -1,12 +1,18 @@
import PATHS from "constants/paths";
import { signOut, useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import Image from "next/image";
import { FiLogOut } from "react-icons/fi";
import PATHS from "constants/paths";
import { TFunctionParam } from "types/i18next";
import styles from "./user-card.module.scss";
export default function UserCard() {
const { data } = useSession({ required: true });
const { t } = useTranslation();
const avatarLabel = t("common:avatar", {
name: data.user.name,
} as TFunctionParam);
return (
<div className={styles["user-card-wrapper"]}>
<div className={styles["user-card"]}>
@@ -14,13 +20,15 @@ export default function UserCard() {
src={data.user.image}
width={28}
height={28}
alt={`${data.user.name}'s avatar`}
alt={avatarLabel}
title={avatarLabel}
/>
{data.user.name}
</div>
<button
onClick={() => signOut({ callbackUrl: PATHS.LOGIN })}
className="reset"
title={t("common:logout")}
>
<FiLogOut size={24} />
</button>

View File

@@ -0,0 +1,8 @@
const MOBILE_SCREEN_SIZE = 768;
export default function useIsMobile() {
return (
typeof window !== "undefined" &&
window.matchMedia(`screen and (max-width: ${MOBILE_SCREEN_SIZE}px)`).matches
);
}

12
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import nextI18NextConfig from "../../next-i18next.config";
async function getServerSideTranslation(locale: string = "en") {
return await serverSideTranslations(
locale,
["common", "login", "home"],
nextI18NextConfig
);
}
export { getServerSideTranslation };

11
src/i18n/resources.ts Normal file
View File

@@ -0,0 +1,11 @@
import common from "../../public/locales/en/common.json";
import home from "../../public/locales/en/home.json";
import login from "../../public/locales/en/login.json";
const resources = {
common,
login,
home,
} as const;
export default resources;

View File

@@ -1,15 +1,18 @@
import PageTransition from "components/PageTransition";
import PATHS from "constants/paths";
import { NextSeo } from "next-seo";
import Link from "next/link";
import styles from "styles/error-page.module.scss";
export default function Custom404() {
return (
<>
<NextSeo title="Page introuvable" />
<PageTransition hideLangageSelector>
<NextSeo title="Page not found" />
<div className={styles["App"]}>
<h1>404</h1>
<h2>Cette page est introuvable.</h2>
<h2>Page not found</h2>
</div>
</>
<Link href={PATHS.HOME}> Back to home page</Link>
</PageTransition>
);
}

View File

@@ -1,15 +1,18 @@
import PageTransition from "components/PageTransition";
import PATHS from "constants/paths";
import { NextSeo } from "next-seo";
import Link from "next/link";
import styles from "styles/error-page.module.scss";
export default function Custom500() {
return (
<>
<NextSeo title="Une erreur est survenue" />
<PageTransition hideLangageSelector>
<NextSeo title="Internal server error" />
<div className={styles["App"]}>
<h1>500</h1>
<h2>Une erreur côté serveur est survenue.</h2>
<h2>An internal server error has occurred</h2>
</div>
</>
<Link href={PATHS.HOME}> Back to home page</Link>
</PageTransition>
);
}

View File

@@ -1,17 +1,16 @@
import ErrorBoundary from "components/ErrorBoundary/ErrorBoundary";
import * as Keys from "constants/keys";
import PATHS from "constants/paths";
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import "nprogress/nprogress.css";
import { useEffect } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import AppErrorBoundary from "components/AppErrorBoundary";
import * as Keys from "constants/keys";
import PATHS from "constants/paths";
import "nprogress/nprogress.css";
import "styles/globals.scss";
import nextI18nextConfig from "../../next-i18next.config";
function MyApp({ Component, pageProps: { session, ...pageProps } }) {
const router = useRouter();
@@ -22,7 +21,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }) {
});
useEffect(() => {
// Chargement pages
// Page loading events
router.events.on("routeChangeStart", nProgress.start);
router.events.on("routeChangeComplete", nProgress.done);
router.events.on("routeChangeError", nProgress.done);
@@ -37,11 +36,11 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }) {
return (
<SessionProvider session={session}>
<DefaultSeo titleTemplate="MyLinks — %s" defaultTitle="MyLinks" />
<AppErrorBoundary>
<ErrorBoundary>
<Component {...pageProps} />
</AppErrorBoundary>
</ErrorBoundary>
</SessionProvider>
);
}
export default MyApp;
export default appWithTranslation(MyApp, nextI18nextConfig);

View File

@@ -1,28 +1,28 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import { getServerSideTranslation } from "i18n";
import getUserCategoriesCount from "lib/category/getUserCategoriesCount";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { redirectWithoutClientCache } from "utils/client";
import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageCreateCategory({
categoriesCount,
}: {
categoriesCount: number;
}) {
const autoFocusRef = useAutoFocus();
const { t } = useTranslation();
const router = useRouter();
const autoFocusRef = useAutoFocus();
const info = useRouter().query?.info as string;
const [name, setName] = useState<string>("");
@@ -57,7 +57,7 @@ export default function PageCreateCategory({
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Créer une catégorie"
title={t("common:category.create")}
errorMessage={error}
infoMessage={info}
canSubmit={canSubmit}
@@ -66,11 +66,11 @@ export default function PageCreateCategory({
>
<TextBox
name="name"
label="Nom de la catégorie"
label={t("common:category.name")}
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles["input-field"]}
placeholder="Nom..."
placeholder={t("common:category.name")}
innerRef={autoFocusRef}
/>
</FormLayout>
@@ -79,12 +79,14 @@ export default function PageCreateCategory({
}
export const getServerSideProps = withAuthentication(
async ({ session, user }) => {
async ({ session, user, locale }) => {
const categoriesCount = await getUserCategoriesCount(user);
return {
props: {
session,
categoriesCount,
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,24 +1,24 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import { getServerSideTranslation } from "i18n";
import getUserCategory from "lib/category/getUserCategory";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { Category } from "types";
import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageEditCategory({ category }: { category: Category }) {
const autoFocusRef = useAutoFocus();
const { t } = useTranslation();
const router = useRouter();
const autoFocusRef = useAutoFocus();
const [name, setName] = useState<string>(category.name);
@@ -53,18 +53,18 @@ export default function PageEditCategory({ category }: { category: Category }) {
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Modifier une catégorie"
title={t("common:category.edit")}
errorMessage={error}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name="name"
label="Nom"
label={t("common:category.name")}
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles["input-field"]}
placeholder={`Nom original : ${category.name}`}
placeholder={`${t("common:category.name")} : ${category.name}`}
innerRef={autoFocusRef}
/>
</FormLayout>
@@ -73,7 +73,7 @@ export default function PageEditCategory({ category }: { category: Category }) {
}
export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => {
async ({ query, session, user, locale }) => {
const { cid } = query;
const category = await getUserCategory(user, Number(cid));
@@ -89,6 +89,7 @@ export const getServerSideProps = withAuthentication(
props: {
session,
category: JSON.parse(JSON.stringify(category)),
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,32 +1,29 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import { getServerSideTranslation } from "i18n";
import getUserCategory from "lib/category/getUserCategory";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useEffect, useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { Category } from "types";
import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageRemoveCategory({
category,
}: {
category: Category;
}) {
const { t, i18n } = useTranslation();
const router = useRouter();
const [error, setError] = useState<string | null>(
category.links.length > 0
? "Vous devez supprimer tous les liens de cette catégorie avant de pouvoir supprimer cette catégorie"
: null
);
const [error, setError] = useState<string>(null);
const [confirmDelete, setConfirmDelete] = useState<boolean>(false);
const [submitted, setSubmitted] = useState<boolean>(false);
@@ -53,10 +50,16 @@ export default function PageRemoveCategory({
}
};
useEffect(() => {
setError(
category.links.length > 0 ? t("common:category.remove-description") : null
);
}, [category.links.length, i18n.language, t]);
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Supprimer une catégorie"
title={t("common:category.remove")}
categoryId={category.id.toString()}
errorMessage={error}
canSubmit={canSubmit}
@@ -66,14 +69,14 @@ export default function PageRemoveCategory({
>
<TextBox
name="name"
label="Nom"
label={t("common:category.name")}
value={category.name}
fieldClass={styles["input-field"]}
disabled={true}
/>
<Checkbox
name="confirm-delete"
label="Confirmer la suppression ?"
label={t("common:category.remove-confirm")}
isChecked={confirmDelete}
disabled={!!error}
onChangeCallback={(checked) => setConfirmDelete(checked)}
@@ -84,7 +87,7 @@ export default function PageRemoveCategory({
}
export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => {
async ({ query, session, user, locale }) => {
const { cid } = query;
const category = await getUserCategory(user, Number(cid));
@@ -100,6 +103,7 @@ export const getServerSideProps = withAuthentication(
props: {
session,
category: JSON.parse(JSON.stringify(category)),
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,10 +1,6 @@
import { AnimatePresence } from "framer-motion";
import { useRouter } from "next/router";
import { useCallback, useMemo, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import BlockWrapper from "components/BlockWrapper/BlockWrapper";
import ButtonLink from "components/ButtonLink";
import LangSelector from "components/LangSelector";
import Links from "components/Links/Links";
import Modal from "components/Modal/Modal";
import PageTransition from "components/PageTransition";
@@ -12,14 +8,18 @@ import SearchModal from "components/SearchModal/SearchModal";
import Categories from "components/SideMenu/Categories/Categories";
import SideMenu from "components/SideMenu/SideMenu";
import UserCard from "components/SideMenu/UserCard/UserCard";
import * as Keys from "constants/keys";
import PATHS from "constants/paths";
import { AnimatePresence } from "framer-motion";
import { useMediaQuery } from "hooks/useMediaQuery";
import useModal from "hooks/useModal";
import { getServerSideTranslation } from "i18n";
import getUserCategories from "lib/category/getUserCategories";
import { useRouter } from "next/router";
import { useCallback, useMemo, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { Category, Link, SearchItem } from "types";
import { pushStateVanilla } from "utils/link";
import { withAuthentication } from "utils/session";
interface HomePageProps {
@@ -30,6 +30,7 @@ interface HomePageProps {
export default function HomePage(props: HomePageProps) {
const router = useRouter();
const searchModal = useModal();
const { t } = useTranslation();
const isMobile = useMediaQuery("(max-width: 768px)");
const mobileModal = useModal();
@@ -105,7 +106,7 @@ export default function HomePage(props: HomePageProps) {
const handleSelectCategory = (category: Category) => {
setCategoryActive(category);
pushStateVanilla(`${PATHS.HOME}?categoryId=${category.id}`);
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
mobileModal.close();
};
@@ -142,13 +143,22 @@ export default function HomePage(props: HomePageProps) {
<PageTransition className="App">
{isMobile ? (
<>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<UserCard />
<LangSelector />
</span>
<AnimatePresence>
{mobileModal.isShowing && (
<Modal close={mobileModal.close}>
<BlockWrapper style={{ minHeight: "0", flex: "1" }}>
<ButtonLink href={PATHS.CATEGORY.CREATE}>
Créer categorie
{t("common:category.create")}
</ButtonLink>
<Categories
categories={categories}
@@ -193,7 +203,7 @@ export default function HomePage(props: HomePageProps) {
}
export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => {
async ({ query, session, user, locale }) => {
const queryCategoryId = (query?.categoryId as string) || "";
const categories = await getUserCategories(user);
@@ -215,6 +225,7 @@ export const getServerSideProps = withAuthentication(
currentCategory: currentCategory
? JSON.parse(JSON.stringify(currentCategory))
: null,
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,30 +1,30 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import Selector from "components/Selector";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import { getServerSideTranslation } from "i18n";
import getUserCategories from "lib/category/getUserCategories";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { Category, Link } from "types";
import { HandleAxiosError, IsValidURL } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageCreateLink({
categories,
}: {
categories: Category[];
}) {
const autoFocusRef = useAutoFocus();
const { t } = useTranslation();
const router = useRouter();
const autoFocusRef = useAutoFocus();
const categoryIdQuery = router.query?.categoryId as string;
const [name, setName] = useState<Link["name"]>("");
@@ -69,7 +69,7 @@ export default function PageCreateLink({
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Créer un lien"
title={t("common:link.create")}
categoryId={categoryIdQuery}
errorMessage={error}
canSubmit={canSubmit}
@@ -77,24 +77,24 @@ export default function PageCreateLink({
>
<TextBox
name="name"
label="Nom"
label={t("common:link.name")}
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles["input-field"]}
placeholder="Nom du lien"
placeholder={t("common:link.name")}
innerRef={autoFocusRef}
/>
<TextBox
name="url"
label="URL"
label={t("common:link.link")}
onChangeCallback={(value) => setUrl(value)}
value={url}
fieldClass={styles["input-field"]}
placeholder="https://www.example.org/"
placeholder="https://www.example.com/"
/>
<Selector
name="category"
label="Catégorie"
label={t("common:category.category")}
value={categoryId}
onChangeCallback={(value: number) => setCategoryId(value)}
options={categories.map(({ id, name }) => ({
@@ -106,7 +106,7 @@ export default function PageCreateLink({
name="favorite"
isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)}
label="Favoris"
label={t("common:favorite")}
/>
</FormLayout>
</PageTransition>
@@ -114,7 +114,7 @@ export default function PageCreateLink({
}
export const getServerSideProps = withAuthentication(
async ({ session, user }) => {
async ({ session, user, locale }) => {
const categories = await getUserCategories(user);
if (categories.length === 0) {
return {
@@ -128,6 +128,7 @@ export const getServerSideProps = withAuthentication(
props: {
session,
categories: JSON.parse(JSON.stringify(categories)),
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,24 +1,23 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import Selector from "components/Selector";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import useAutoFocus from "hooks/useAutoFocus";
import { getServerSideTranslation } from "i18n";
import getUserCategories from "lib/category/getUserCategories";
import getUserLink from "lib/link/getUserLink";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { Category, Link } from "types";
import { HandleAxiosError, IsValidURL } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageEditLink({
link,
categories,
@@ -26,8 +25,9 @@ export default function PageEditLink({
link: Link;
categories: Category[];
}) {
const autoFocusRef = useAutoFocus();
const { t } = useTranslation();
const router = useRouter();
const autoFocusRef = useAutoFocus();
const [name, setName] = useState<string>(link.name);
const [url, setUrl] = useState<string>(link.url);
@@ -85,31 +85,31 @@ export default function PageEditLink({
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Modifier un lien"
title={t("common:link.edit")}
errorMessage={error}
canSubmit={canSubmit}
handleSubmit={handleSubmit}
>
<TextBox
name="name"
label="Nom"
label={t("common:link.name")}
onChangeCallback={(value) => setName(value)}
value={name}
fieldClass={styles["input-field"]}
placeholder={`Nom original : ${link.name}`}
placeholder={`${t("common:link.name")} : ${link.name}`}
innerRef={autoFocusRef}
/>
<TextBox
name="url"
label="URL"
label={t("common:link.link")}
onChangeCallback={(value) => setUrl(value)}
value={url}
fieldClass={styles["input-field"]}
placeholder={`URL original : ${link.url}`}
placeholder="https://example.com/"
/>
<Selector
name="category"
label="Catégorie"
label={t("common:category.category")}
value={categoryId}
onChangeCallback={(value: number) => setCategoryId(value)}
options={categories.map(({ id, name }) => ({
@@ -121,7 +121,7 @@ export default function PageEditLink({
name="favorite"
isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)}
label="Favoris"
label={t("common:favorite")}
/>
</FormLayout>
</PageTransition>
@@ -129,7 +129,7 @@ export default function PageEditLink({
}
export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => {
async ({ query, session, user, locale }) => {
const { lid } = query;
const categories = await getUserCategories(user);
@@ -147,6 +147,7 @@ export const getServerSideProps = withAuthentication(
session,
link: JSON.parse(JSON.stringify(link)),
categories: JSON.parse(JSON.stringify(categories)),
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,22 +1,22 @@
import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox";
import PATHS from "constants/paths";
import { getServerSideTranslation } from "i18n";
import getUserLink from "lib/link/getUserLink";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import styles from "styles/form.module.scss";
import { Link } from "types";
import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageRemoveLink({ link }: { link: Link }) {
const { t } = useTranslation();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
@@ -47,7 +47,7 @@ export default function PageRemoveLink({ link }: { link: Link }) {
return (
<PageTransition className={styles["form-container"]}>
<FormLayout
title="Supprimer un lien"
title={t("common:link.remove")}
categoryId={link.category.id.toString()}
errorMessage={error}
canSubmit={canSubmit}
@@ -57,34 +57,34 @@ export default function PageRemoveLink({ link }: { link: Link }) {
>
<TextBox
name="name"
label="Nom"
label={t("common:link.name")}
value={link.name}
fieldClass={styles["input-field"]}
disabled={true}
/>
<TextBox
name="url"
label="URL"
label={t("common:link.link")}
value={link.url}
fieldClass={styles["input-field"]}
disabled={true}
/>
<TextBox
name="category"
label="Catégorie"
label={t("common:category.category")}
value={link.category.name}
fieldClass={styles["input-field"]}
disabled={true}
/>
<Checkbox
name="favorite"
label="Favoris"
label={t("common:favorite")}
isChecked={link.favorite}
disabled={true}
/>
<Checkbox
name="confirm-delete"
label="Confirmer la suppression ?"
label={t("common:category.remove-confirm")}
isChecked={confirmDelete}
onChangeCallback={(checked) => setConfirmDelete(checked)}
/>
@@ -94,7 +94,7 @@ export default function PageRemoveLink({ link }: { link: Link }) {
}
export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => {
async ({ query, session, user, locale }) => {
const { lid } = query;
const link = await getUserLink(user, Number(lid));
@@ -110,6 +110,7 @@ export const getServerSideProps = withAuthentication(
props: {
session,
link: JSON.parse(JSON.stringify(link)),
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,27 +1,29 @@
import ButtonLink from "components/ButtonLink";
import LangSelector from "components/LangSelector";
import MessageManager from "components/MessageManager/MessageManager";
import PageTransition from "components/PageTransition";
import PATHS from "constants/paths";
import { getServerSideTranslation } from "i18n/index";
import getUser from "lib/user/getUser";
import { Provider } from "next-auth/providers";
import { getProviders, signIn } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import Image from "next/image";
import { FcGoogle } from "react-icons/fc";
import ButtonLink from "components/ButtonLink";
import MessageManager from "components/MessageManager/MessageManager";
import PageTransition from "components/PageTransition";
import PATHS from "constants/paths";
import getUser from "lib/user/getUser";
import { getSession } from "utils/session";
import styles from "styles/login.module.scss";
import { getSession } from "utils/session";
interface SignInProps {
providers: Provider[];
}
export default function SignIn({ providers }: SignInProps) {
const { t } = useTranslation("login");
return (
<div className={styles["login-page"]}>
<PageTransition className={styles["login-container"]}>
<NextSeo title="Authentification" />
<PageTransition className={styles["login-container"]} hideLangageSelector>
<NextSeo title={t("login:title")} />
<div className={styles["image-wrapper"]}>
<Image
src={"/logo-light.png"}
@@ -31,24 +33,28 @@ export default function SignIn({ providers }: SignInProps) {
/>
</div>
<div className={styles["form-wrapper"]}>
<h1>Authentification</h1>
<MessageManager info="Authentification requise pour utiliser ce service" />
<h1>{t("login:title")}</h1>
<MessageManager info={t("login:informative-text")} />
{Object.values(providers).map(({ name, id }) => (
<ButtonLink
onClick={() => signIn(id, { callbackUrl: PATHS.HOME })}
className={styles["login-button"]}
key={id}
>
<FcGoogle size={"1.5em"} /> Continuer avec {name}
<FcGoogle size={"1.5em"} />{" "}
{t("login:continue-with", { provider: name } as undefined)}
</ButtonLink>
))}
</div>
</PageTransition>
<div className="lang-selector">
<LangSelector />
</div>
</div>
);
}
export async function getServerSideProps({ req, res }) {
export async function getServerSideProps({ req, res, locale }) {
const session = await getSession(req, res);
const user = await getUser(session);
if (user) {
@@ -61,6 +67,10 @@ export async function getServerSideProps({ req, res }) {
const providers = await getProviders();
return {
props: { session, providers },
props: {
session,
providers,
...(await getServerSideTranslation(locale)),
},
};
}

View File

@@ -1,25 +1,25 @@
@import "keyframes.scss";
.App {
height: 100%;
margin-top: 10em;
margin-bottom: 3em;
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-size: 1.75em;
font-weight: 500;
vertical-align: top;
margin: 0;
margin-right: 1em;
border-right: 1px solid rgba(0, 0, 0, 0.3);
padding: 10px 23px 10px 0;
display: inline-block;
}
& h2 {
font-size: 14px;
font-size: 1em;
font-weight: normal;
line-height: inherit;
margin: 0;

View File

@@ -193,6 +193,12 @@ kbd {
display: inline-block;
}
.lang-selector {
position: absolute;
bottom: 4em;
right: 2em;
}
@media (max-width: 1280px) {
.App {
width: 100%;

22
src/types/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* If you want to enable locale keys typechecking and enhance IDE experience.
*
* Requires `resolveJsonModule:true` in your tsconfig.json.
*
* @link https://www.i18next.com/overview/typescript
*/
import "i18next";
// resources.ts file is generated with `npm run toc`
import resources from "../i18n/resources";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "common";
resources: typeof resources;
returnNull: false;
}
}
// Ugly hack because of the above declaration, i cant use "t" function params
type TFunctionParam = undefined;

View File

@@ -1,10 +1,13 @@
import { i18n } from "next-i18next";
export function groupItemBy(array: any[], property: string) {
const hash = {};
const props = property.split(".");
for (const item of array) {
const key = props.reduce((acc, prop) => acc && acc[prop], item);
const hashKey = key !== undefined ? key : "catégories";
const hashKey =
key !== undefined ? key : i18n.t("common:category.categories");
if (!hash[hashKey]) {
hash[hashKey] = [];

View File

@@ -1,11 +1,3 @@
export function faviconLinkBuilder(origin: string) {
return `http://localhost:3000/api/favicon?url=${origin}`;
}
export function pushStateVanilla(newUrl: string) {
window.history.replaceState(
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl
);
}

View File

@@ -14,8 +14,9 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "./src"
"baseUrl": "./src",
"typeRoots": ["./src/types"]
},
"include": ["next-env.d.ts", "@/**.*", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}