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} */ /** @type {import('next').NextConfig} */
const config = { const config = {
i18n,
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({
test: /\.svg$/, test: /\.svg$/,

416
package-lock.json generated
View File

@@ -8,22 +8,24 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.5.2", "@prisma/client": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"accept-language": "^3.0.18",
"axios": "^1.6.1", "axios": "^1.6.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"next": "^14.0.1", "i18next": "^23.7.1",
"next": "^14.0.2",
"next-auth": "^4.24.4", "next-auth": "^4.24.4",
"next-i18next": "^15.0.0",
"next-seo": "^6.4.0", "next-seo": "^6.4.0",
"node-html-parser": "^6.1.11", "node-html-parser": "^6.1.11",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.3.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"sass": "^1.69.5", "sass": "^1.69.5",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"toastr": "^2.1.4",
"yup": "^1.3.2" "yup": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -33,7 +35,7 @@
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-config-next": "14.0.1", "eslint-config-next": "14.0.2",
"prisma": "^5.5.2", "prisma": "^5.5.2",
"typescript": "5.2.2" "typescript": "5.2.2"
} }
@@ -60,11 +62,12 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.21.4", "version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"dependencies": { "dependencies": {
"@babel/highlight": "^7.18.6" "@babel/highlight": "^7.22.13",
"chalk": "^2.4.2"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -108,11 +111,11 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.21.4", "version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.4.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
"integrity": "sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==", "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
"dependencies": { "dependencies": {
"@babel/types": "^7.21.4", "@babel/types": "^7.23.0",
"@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/gen-mapping": "^0.3.2",
"@jridgewell/trace-mapping": "^0.3.17", "@jridgewell/trace-mapping": "^0.3.17",
"jsesc": "^2.5.1" "jsesc": "^2.5.1"
@@ -241,9 +244,9 @@
} }
}, },
"node_modules/@babel/helper-environment-visitor": { "node_modules/@babel/helper-environment-visitor": {
"version": "7.18.9", "version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
"integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -260,23 +263,23 @@
} }
}, },
"node_modules/@babel/helper-function-name": { "node_modules/@babel/helper-function-name": {
"version": "7.21.0", "version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
"integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
"dependencies": { "dependencies": {
"@babel/template": "^7.20.7", "@babel/template": "^7.22.15",
"@babel/types": "^7.21.0" "@babel/types": "^7.23.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-hoist-variables": { "node_modules/@babel/helper-hoist-variables": {
"version": "7.18.6", "version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
"integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
"dependencies": { "dependencies": {
"@babel/types": "^7.18.6" "@babel/types": "^7.22.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -397,28 +400,28 @@
} }
}, },
"node_modules/@babel/helper-split-export-declaration": { "node_modules/@babel/helper-split-export-declaration": {
"version": "7.18.6", "version": "7.22.6",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
"integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
"dependencies": { "dependencies": {
"@babel/types": "^7.18.6" "@babel/types": "^7.22.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.19.4", "version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.19.1", "version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -459,12 +462,12 @@
} }
}, },
"node_modules/@babel/highlight": { "node_modules/@babel/highlight": {
"version": "7.18.6", "version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.18.6", "@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.0.0", "chalk": "^2.4.2",
"js-tokens": "^4.0.0" "js-tokens": "^4.0.0"
}, },
"engines": { "engines": {
@@ -472,9 +475,9 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.21.4", "version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
"integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==", "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
}, },
@@ -1659,42 +1662,42 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.21.0", "version": "7.23.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.14.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.20.7", "version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
"integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.18.6", "@babel/code-frame": "^7.22.13",
"@babel/parser": "^7.20.7", "@babel/parser": "^7.22.15",
"@babel/types": "^7.20.7" "@babel/types": "^7.22.15"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.21.4", "version": "7.23.2",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.4.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
"integrity": "sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q==", "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.21.4", "@babel/code-frame": "^7.22.13",
"@babel/generator": "^7.21.4", "@babel/generator": "^7.23.0",
"@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-environment-visitor": "^7.22.20",
"@babel/helper-function-name": "^7.21.0", "@babel/helper-function-name": "^7.23.0",
"@babel/helper-hoist-variables": "^7.18.6", "@babel/helper-hoist-variables": "^7.22.5",
"@babel/helper-split-export-declaration": "^7.18.6", "@babel/helper-split-export-declaration": "^7.22.6",
"@babel/parser": "^7.21.4", "@babel/parser": "^7.23.0",
"@babel/types": "^7.21.4", "@babel/types": "^7.23.0",
"debug": "^4.1.0", "debug": "^4.1.0",
"globals": "^11.1.0" "globals": "^11.1.0"
}, },
@@ -1703,12 +1706,12 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.21.4", "version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.4.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
"integrity": "sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==", "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.19.4", "@babel/helper-string-parser": "^7.22.5",
"@babel/helper-validator-identifier": "^7.19.1", "@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0" "to-fast-properties": "^2.0.0"
}, },
"engines": { "engines": {
@@ -2012,23 +2015,23 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.2.tgz",
"integrity": "sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A==" "integrity": "sha512-HAW1sljizEaduEOes/m84oUqeIDAUYBR1CDwu2tobNlNDFP3cSm9d6QsOsGeNlIppU1p/p1+bWbYCbvwjFiceA=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.2.tgz",
"integrity": "sha512-bLjJMwXdzvhnQOnxvHoTTUh/+PYk6FF/DCgHi4BXwXCINer+o1ZYfL9aVeezj/oI7wqGJOqwGIXrlBvPbAId3w==", "integrity": "sha512-APrYFsXfAhnysycqxHcpg6Y4i7Ukp30GzVSZQRKT3OczbzkqGjt33vNhScmgoOXYBU1CfkwgtXmNxdiwv1jKmg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"glob": "7.1.7" "glob": "7.1.7"
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.2.tgz",
"integrity": "sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==", "integrity": "sha512-i+jQY0fOb8L5gvGvojWyZMfQoQtDVB2kYe7fufOEiST6sicvzI2W5/EXo4lX5bLUjapHKe+nFxuVv7BA+Pd7LQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2041,9 +2044,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.2.tgz",
"integrity": "sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==", "integrity": "sha512-zRCAO0d2hW6gBEa4wJaLn+gY8qtIqD3gYd9NjruuN98OCI6YyelmhWVVLlREjS7RYrm9OUQIp/iVJFeB6kP1hg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2056,9 +2059,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.2.tgz",
"integrity": "sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==", "integrity": "sha512-tSJmiaon8YaKsVhi7GgRizZoV0N1Sx5+i+hFTrCKKQN7s3tuqW0Rov+RYdPhAv/pJl4qiG+XfSX4eJXqpNg3dA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2071,9 +2074,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.2.tgz",
"integrity": "sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==", "integrity": "sha512-dXJLMSEOwqJKcag1BeX1C+ekdPPJ9yXbWIt3nAadhbLx5CjACoB2NQj9Xcqu2tmdr5L6m34fR+fjGPs+ZVPLzA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2086,9 +2089,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.2.tgz",
"integrity": "sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==", "integrity": "sha512-WC9KAPSowj6as76P3vf1J3mf2QTm3Wv3FBzQi7UJ+dxWjK3MhHVWsWUo24AnmHx9qDcEtHM58okgZkXVqeLB+Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2101,9 +2104,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.2.tgz",
"integrity": "sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==", "integrity": "sha512-KSSAwvUcjtdZY4zJFa2f5VNJIwuEVnOSlqYqbQIawREJA+gUI6egeiRu290pXioQXnQHYYdXmnVNZ4M+VMB7KQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2116,9 +2119,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.2.tgz",
"integrity": "sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==", "integrity": "sha512-2/O0F1SqJ0bD3zqNuYge0ok7OEWCQwk55RPheDYD0va5ij7kYwrFkq5ycCRN0TLjLfxSF6xI5NM6nC5ux7svEQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2131,9 +2134,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.2.tgz",
"integrity": "sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==", "integrity": "sha512-vJI/x70Id0oN4Bq/R6byBqV1/NS5Dl31zC+lowO8SDu1fHmUxoAdILZR5X/sKbiJpuvKcCrwbYgJU8FF/Gh50Q==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2146,9 +2149,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.2.tgz",
"integrity": "sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==", "integrity": "sha512-Ut4LXIUvC5m8pHTe2j0vq/YDnTEyq6RSR9vHYPqnELrDapPhLNz9Od/L5Ow3J8RNDWpEnfCiQXuVdfjlNEJ7ug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2570,6 +2573,15 @@
"node": ">=10.13.0" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2876,6 +2888,15 @@
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
"dev": true "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": { "node_modules/acorn": {
"version": "8.11.2", "version": "8.11.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "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": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -3537,6 +3566,16 @@
"node": ">= 0.6" "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": { "node_modules/core-js-compat": {
"version": "3.27.2", "version": "3.27.2",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.27.2.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.27.2.tgz",
@@ -4105,12 +4144,12 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.0.2.tgz",
"integrity": "sha512-QfIFK2WD39H4WOespjgf6PLv9Bpsd7KGGelCtmq4l67nGvnlsGpuvj0hIT+aIy6p5gKH+lAChYILsyDlxP52yg==", "integrity": "sha512-CasWThlsyIcg/a+clU6KVOMTieuDhTztsrqvniP6AsRki9v7FnojTa7vKQOYM8QSOsQdZ/aElLD1Y2Oc8/PsIg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "14.0.1", "@next/eslint-plugin-next": "14.0.2",
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.3.3",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
@@ -5114,6 +5153,41 @@
"react-is": "^16.7.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5591,11 +5665,6 @@
"url": "https://github.com/sponsors/panva" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5873,11 +5942,11 @@
"dev": true "dev": true
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.1.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.0.2.tgz",
"integrity": "sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==", "integrity": "sha512-jsAU2CkYS40GaQYOiLl9m93RTv2DA/tTJ0NRlmZIBIL87YwQ/xR8k796z7IqgM3jydI8G25dXvyYMC9VDIevIg==",
"dependencies": { "dependencies": {
"@next/env": "14.0.1", "@next/env": "14.0.2",
"@swc/helpers": "0.5.2", "@swc/helpers": "0.5.2",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406", "caniuse-lite": "^1.0.30001406",
@@ -5892,15 +5961,15 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.0.1", "@next/swc-darwin-arm64": "14.0.2",
"@next/swc-darwin-x64": "14.0.1", "@next/swc-darwin-x64": "14.0.2",
"@next/swc-linux-arm64-gnu": "14.0.1", "@next/swc-linux-arm64-gnu": "14.0.2",
"@next/swc-linux-arm64-musl": "14.0.1", "@next/swc-linux-arm64-musl": "14.0.2",
"@next/swc-linux-x64-gnu": "14.0.1", "@next/swc-linux-x64-gnu": "14.0.2",
"@next/swc-linux-x64-musl": "14.0.1", "@next/swc-linux-x64-musl": "14.0.2",
"@next/swc-win32-arm64-msvc": "14.0.1", "@next/swc-win32-arm64-msvc": "14.0.2",
"@next/swc-win32-ia32-msvc": "14.0.1", "@next/swc-win32-ia32-msvc": "14.0.2",
"@next/swc-win32-x64-msvc": "14.0.1" "@next/swc-win32-x64-msvc": "14.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@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": { "node_modules/next-seo": {
"version": "6.4.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.4.0.tgz", "resolved": "https://registry.npmjs.org/next-seo/-/next-seo-6.4.0.tgz",
@@ -5975,9 +6079,9 @@
} }
}, },
"node_modules/node-abi/node_modules/semver": { "node_modules/node-abi/node_modules/semver": {
"version": "7.5.1", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": { "dependencies": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
}, },
@@ -6549,17 +6653,6 @@
"react": "^18.2.0" "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": { "node_modules/react-hotkeys-hook": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz", "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz",
@@ -6569,6 +6662,27 @@
"react-dom": ">=16.8.1" "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": { "node_modules/react-icons": {
"version": "4.11.0", "version": "4.11.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz",
@@ -6678,9 +6792,9 @@
} }
}, },
"node_modules/regenerator-runtime": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
}, },
"node_modules/regenerator-transform": { "node_modules/regenerator-transform": {
"version": "0.15.1", "version": "0.15.1",
@@ -7098,6 +7212,12 @@
"node": ">=0.10.0" "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": { "node_modules/streamsearch": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -7414,14 +7534,6 @@
"node": ">=8.0" "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": { "node_modules/toposort": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
@@ -7698,6 +7810,14 @@
"uuid": "dist/bin/uuid" "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": { "node_modules/watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@@ -10,22 +10,24 @@
"dependencies": { "dependencies": {
"@prisma/client": "^5.5.2", "@prisma/client": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"accept-language": "^3.0.18",
"axios": "^1.6.1", "axios": "^1.6.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"next": "^14.0.1", "i18next": "^23.7.1",
"next": "^14.0.2",
"next-auth": "^4.24.4", "next-auth": "^4.24.4",
"next-i18next": "^15.0.0",
"next-seo": "^6.4.0", "next-seo": "^6.4.0",
"node-html-parser": "^6.1.11", "node-html-parser": "^6.1.11",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^13.3.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"sass": "^1.69.5", "sass": "^1.69.5",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"toastr": "^2.1.4",
"yup": "^1.3.2" "yup": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -35,7 +37,7 @@
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0", "eslint": "^8.53.0",
"eslint-config-next": "14.0.1", "eslint-config-next": "14.0.2",
"prisma": "^5.5.2", "prisma": "^5.5.2",
"typescript": "5.2.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 { & ul {
flex: 1;
animation: fadein 0.3s both; 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 { NextSeo } from "next-seo";
import Link from "next/link"; import Link from "next/link";
import MessageManager from "components/MessageManager/MessageManager";
interface FormProps { interface FormProps {
title: string; title: string;
@@ -29,11 +29,13 @@ export default function Form({
infoMessage, infoMessage,
canSubmit, canSubmit,
handleSubmit, handleSubmit,
textBtnConfirm = "Valider", textBtnConfirm = i18n.t("common:confirm"),
classBtnConfirm = "", classBtnConfirm = "",
children, children,
disableHomeLink = false, disableHomeLink = false,
}: FormProps) { }: FormProps) {
const { t } = useTranslation();
return ( return (
<> <>
<NextSeo title={title} /> <NextSeo title={title} />
@@ -46,7 +48,7 @@ export default function Form({
</form> </form>
{!disableHomeLink && ( {!disableHomeLink && (
<Link href={categoryId ? `/?categoryId=${categoryId}` : "/"}> <Link href={categoryId ? `/?categoryId=${categoryId}` : "/"}>
Revenir à l'accueil {t("common:back-home")}
</Link> </Link>
)} )}
<MessageManager <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 ButtonLink from "components/ButtonLink";
import CreateItem from "components/QuickActions/CreateItem"; 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 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 { 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"; import styles from "./links.module.scss";
export default function Links({ export default function Links({
@@ -26,11 +25,13 @@ export default function Links({
openMobileModal: () => void; openMobileModal: () => void;
openSearchModal: () => void; openSearchModal: () => void;
}) { }) {
const { t } = useTranslation("home");
if (category === null) { if (category === null) {
return ( return (
<div className={styles["no-category"]}> <div className={styles["no-category"]}>
<p>Veuillez séléctionner une categorié</p> <p>{t("home:select-categorie")}</p>
<LinkTag href="/category/create">ou en créer une</LinkTag> <LinkTag href="/category/create">{t("home:or-create-one")}</LinkTag>
</div> </div>
); );
} }
@@ -85,11 +86,14 @@ export default function Links({
damping: 20, damping: 20,
duration: 0.01, duration: 0.01,
}} }}
> dangerouslySetInnerHTML={{
Aucun lien pour <b>{name}</b> __html: t("home:no-link", { name } as TFunctionParam, {
</motion.p> interpolation: { escapeValue: false },
}),
}}
/>
<LinkTag href={`/link/create?categoryId=${id}`}> <LinkTag href={`/link/create?categoryId=${id}`}>
Créer un lien {t("common:link.create")}
</LinkTag> </LinkTag>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import ButtonLink from "components/ButtonLink"; import ButtonLink from "components/ButtonLink";
import { BiSearchAlt } from "react-icons/bi"; import { BiSearchAlt } from "react-icons/bi";
import styles from "./quickactions.module.scss"; import styles from "./quickactions.module.scss";
export default function QuickActionSearch({ 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 { ReactNode, useEffect, useMemo } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { SearchItem } from "types"; import { SearchItem } from "types";
import { groupItemBy } from "utils/array"; import { groupItemBy } from "utils/array";
import SearchListItem from "./SearchListItem"; import SearchListItem from "./SearchListItem";
import * as Keys from "constants/keys";
import styles from "./search.module.scss"; import styles from "./search.module.scss";
const isActiveItem = (item: SearchItem, otherItem: SearchItem) => const isActiveItem = (item: SearchItem, otherItem: SearchItem) =>
@@ -65,16 +63,18 @@ export default function SearchList({
<ul className={styles["search-list"]}> <ul className={styles["search-list"]}>
{groupedItems.length > 0 ? ( {groupedItems.length > 0 ? (
groupedItems.map(([key, items]) => ( groupedItems.map(([key, items]) => (
<li key={key + "-" + key}> <li key={`${key}-${key}`}>
<li>{typeof key === "undefined" ? "-" : key}</li> <span>{typeof key === "undefined" ? "-" : key}</span>
{items.map((item) => ( <ul>
<SearchListItem {items.map((item) => (
item={item} <SearchListItem
selected={isActiveItem(item, selectedItem)} item={item}
closeModal={closeModal} selected={isActiveItem(item, selectedItem)}
key={item.id} closeModal={closeModal}
/> key={item.id}
))} />
))}
</ul>
</li> </li>
)) ))
) : noItem ? ( ) : noItem ? (
@@ -87,5 +87,6 @@ export default function SearchList({
} }
function LabelNoItem() { 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} ref={ref}
key={id} key={id}
title={name}
> >
<LinkTag <LinkTag
href={url} 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 Modal from "components/Modal/Modal";
import TextBox from "components/TextBox"; 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 LabelSearchWithGoogle from "./LabelSearchWithGoogle";
import SearchList from "./SearchList"; 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"; import styles from "./search.module.scss";
export default function SearchModal({ export default function SearchModal({
@@ -27,6 +24,7 @@ export default function SearchModal({
items: SearchItem[]; items: SearchItem[];
noHeader?: boolean; noHeader?: boolean;
}) { }) {
const { t } = useTranslation();
const autoFocusRef = useAutoFocus(); const autoFocusRef = useAutoFocus();
const [canSearchLink, setCanSearchLink] = useLocalStorage( const [canSearchLink, setCanSearchLink] = useLocalStorage(
@@ -110,7 +108,7 @@ export default function SearchModal({
name="search" name="search"
onChangeCallback={handleSearchInputChange} onChangeCallback={handleSearchInputChange}
value={search} value={search}
placeholder="Rechercher" placeholder={t("common:search")}
innerRef={autoFocusRef} innerRef={autoFocusRef}
fieldClass={styles["search-input-field"]} fieldClass={styles["search-input-field"]}
inputClass={"reset"} inputClass={"reset"}
@@ -132,7 +130,7 @@ export default function SearchModal({
/> />
)} )}
<button type="submit" disabled={!canSubmit} style={{ display: "none" }}> <button type="submit" disabled={!canSubmit} style={{ display: "none" }}>
Valider {t("common:confirm")}
</button> </button>
</form> </form>
</Modal> </Modal>
@@ -150,6 +148,8 @@ function SearchFilter({
canSearchCategory: boolean; canSearchCategory: boolean;
setCanSearchCategory: (value: boolean) => void; setCanSearchCategory: (value: boolean) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<div <div
style={{ style={{
@@ -160,7 +160,6 @@ function SearchFilter({
marginBottom: "1em", marginBottom: "1em",
}} }}
> >
{/* à remplacer par des Chips Checkbox */}
<div style={{ display: "flex", gap: ".25em" }}> <div style={{ display: "flex", gap: ".25em" }}>
<input <input
type="checkbox" type="checkbox"
@@ -169,7 +168,7 @@ function SearchFilter({
onChange={({ target }) => setCanSearchLink(target.checked)} onChange={({ target }) => setCanSearchLink(target.checked)}
checked={canSearchLink} checked={canSearchLink}
/> />
<label htmlFor="filter-link">liens</label> <label htmlFor="filter-link">{t("common:link.links")}</label>
</div> </div>
<div style={{ display: "flex", gap: ".25em" }}> <div style={{ display: "flex", gap: ".25em" }}>
<input <input
@@ -179,7 +178,9 @@ function SearchFilter({
onChange={({ target }) => setCanSearchCategory(target.checked)} onChange={({ target }) => setCanSearchCategory(target.checked)}
checked={canSearchCategory} checked={canSearchCategory}
/> />
<label htmlFor="filter-category">categories</label> <label htmlFor="filter-category">
{t("common:category.categories")}
</label>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
import { useTranslation } from "next-i18next";
import { Link } from "types"; import { Link } from "types";
import FavoriteItem from "./FavoriteItem"; import FavoriteItem from "./FavoriteItem";
import styles from "./favorites.module.scss"; import styles from "./favorites.module.scss";
export default function Favorites({ favorites }: { favorites: Link[] }) { export default function Favorites({ favorites }: { favorites: Link[] }) {
const { t } = useTranslation();
return ( return (
favorites.length !== 0 && ( favorites.length !== 0 && (
<div className={styles["favorites"]}> <div className={styles["favorites"]}>
<h4>Favoris</h4> <h4>{t("common:favorite")}</h4>
<ul className={styles["items"]}> <ul className={styles["items"]}>
{favorites.map((link) => ( {favorites.map((link) => (
<FavoriteItem link={link} key={link.id} /> <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 ButtonLink from "components/ButtonLink";
import PATHS from "constants/paths";
import { useTranslation } from "next-i18next";
import { SideMenuProps } from "./SideMenu";
import styles from "./sidemenu.module.scss"; import styles from "./sidemenu.module.scss";
export default function NavigationLinks({ export default function NavigationLinks({
@@ -11,25 +11,25 @@ export default function NavigationLinks({
categoryActive: SideMenuProps["categoryActive"]; categoryActive: SideMenuProps["categoryActive"];
openSearchModal: SideMenuProps["openSearchModal"]; openSearchModal: SideMenuProps["openSearchModal"];
}) { }) {
const handleOpenSearchModal = (event) => { const { t } = useTranslation();
event.preventDefault();
openSearchModal();
};
return ( return (
<div className={styles["menu-controls"]}> <div className={styles["menu-controls"]}>
<div className={styles["action"]}> <div className={styles["action"]}>
<ButtonLink onClick={openSearchModal}>Rechercher</ButtonLink> <ButtonLink onClick={openSearchModal}>{t("common:search")}</ButtonLink>
<kbd>S</kbd> <kbd>S</kbd>
</div> </div>
<div className={styles["action"]}> <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> <kbd>C</kbd>
</div> </div>
<div className={styles["action"]}> <div className={styles["action"]}>
<ButtonLink <ButtonLink
href={`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`} href={`${PATHS.LINK.CREATE}?categoryId=${categoryActive.id}`}
> >
Créer lien {t("common:link.create")}
</ButtonLink> </ButtonLink>
<kbd>L</kbd> <kbd>L</kbd>
</div> </div>

View File

@@ -1,14 +1,11 @@
import { useHotkeys } from "react-hotkeys-hook";
import BlockWrapper from "components/BlockWrapper/BlockWrapper"; 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 * as Keys from "constants/keys";
import { useHotkeys } from "react-hotkeys-hook";
import { Category, Link } from "types"; 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"; import styles from "./sidemenu.module.scss";
export interface SideMenuProps { export interface SideMenuProps {

View File

@@ -1,12 +1,18 @@
import PATHS from "constants/paths";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import Image from "next/image"; import Image from "next/image";
import { FiLogOut } from "react-icons/fi"; import { FiLogOut } from "react-icons/fi";
import { TFunctionParam } from "types/i18next";
import PATHS from "constants/paths";
import styles from "./user-card.module.scss"; import styles from "./user-card.module.scss";
export default function UserCard() { export default function UserCard() {
const { data } = useSession({ required: true }); const { data } = useSession({ required: true });
const { t } = useTranslation();
const avatarLabel = t("common:avatar", {
name: data.user.name,
} as TFunctionParam);
return ( return (
<div className={styles["user-card-wrapper"]}> <div className={styles["user-card-wrapper"]}>
<div className={styles["user-card"]}> <div className={styles["user-card"]}>
@@ -14,13 +20,15 @@ export default function UserCard() {
src={data.user.image} src={data.user.image}
width={28} width={28}
height={28} height={28}
alt={`${data.user.name}'s avatar`} alt={avatarLabel}
title={avatarLabel}
/> />
{data.user.name} {data.user.name}
</div> </div>
<button <button
onClick={() => signOut({ callbackUrl: PATHS.LOGIN })} onClick={() => signOut({ callbackUrl: PATHS.LOGIN })}
className="reset" className="reset"
title={t("common:logout")}
> >
<FiLogOut size={24} /> <FiLogOut size={24} />
</button> </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 { NextSeo } from "next-seo";
import Link from "next/link";
import styles from "styles/error-page.module.scss"; import styles from "styles/error-page.module.scss";
export default function Custom404() { export default function Custom404() {
return ( return (
<> <PageTransition hideLangageSelector>
<NextSeo title="Page introuvable" /> <NextSeo title="Page not found" />
<div className={styles["App"]}> <div className={styles["App"]}>
<h1>404</h1> <h1>404</h1>
<h2>Cette page est introuvable.</h2> <h2>Page not found</h2>
</div> </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 { NextSeo } from "next-seo";
import Link from "next/link";
import styles from "styles/error-page.module.scss"; import styles from "styles/error-page.module.scss";
export default function Custom500() { export default function Custom500() {
return ( return (
<> <PageTransition hideLangageSelector>
<NextSeo title="Une erreur est survenue" /> <NextSeo title="Internal server error" />
<div className={styles["App"]}> <div className={styles["App"]}>
<h1>500</h1> <h1>500</h1>
<h2>Une erreur côté serveur est survenue.</h2> <h2>An internal server error has occurred</h2>
</div> </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 { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import { DefaultSeo } from "next-seo"; import { DefaultSeo } from "next-seo";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import nProgress from "nprogress"; import nProgress from "nprogress";
import "nprogress/nprogress.css";
import { useEffect } from "react"; import { useEffect } from "react";
import { useHotkeys } from "react-hotkeys-hook"; 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 "styles/globals.scss";
import nextI18nextConfig from "../../next-i18next.config";
function MyApp({ Component, pageProps: { session, ...pageProps } }) { function MyApp({ Component, pageProps: { session, ...pageProps } }) {
const router = useRouter(); const router = useRouter();
@@ -22,7 +21,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }) {
}); });
useEffect(() => { useEffect(() => {
// Chargement pages // Page loading events
router.events.on("routeChangeStart", nProgress.start); router.events.on("routeChangeStart", nProgress.start);
router.events.on("routeChangeComplete", nProgress.done); router.events.on("routeChangeComplete", nProgress.done);
router.events.on("routeChangeError", nProgress.done); router.events.on("routeChangeError", nProgress.done);
@@ -37,11 +36,11 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }) {
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<DefaultSeo titleTemplate="MyLinks — %s" defaultTitle="MyLinks" /> <DefaultSeo titleTemplate="MyLinks — %s" defaultTitle="MyLinks" />
<AppErrorBoundary> <ErrorBoundary>
<Component {...pageProps} /> <Component {...pageProps} />
</AppErrorBoundary> </ErrorBoundary>
</SessionProvider> </SessionProvider>
); );
} }
export default MyApp; export default appWithTranslation(MyApp, nextI18nextConfig);

View File

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

View File

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

View File

@@ -1,32 +1,29 @@
import axios from "axios"; import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox"; import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout"; import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition"; import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox"; import TextBox from "components/TextBox";
import PATHS from "constants/paths"; import PATHS from "constants/paths";
import { getServerSideTranslation } from "i18n";
import getUserCategory from "lib/category/getUserCategory"; 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 { Category } from "types";
import { HandleAxiosError } from "utils/front"; import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session"; import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageRemoveCategory({ export default function PageRemoveCategory({
category, category,
}: { }: {
category: Category; category: Category;
}) { }) {
const { t, i18n } = useTranslation();
const router = useRouter(); const router = useRouter();
const [error, setError] = useState<string | null>(
category.links.length > 0 const [error, setError] = useState<string>(null);
? "Vous devez supprimer tous les liens de cette catégorie avant de pouvoir supprimer cette catégorie"
: null
);
const [confirmDelete, setConfirmDelete] = useState<boolean>(false); const [confirmDelete, setConfirmDelete] = useState<boolean>(false);
const [submitted, setSubmitted] = 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 ( return (
<PageTransition className={styles["form-container"]}> <PageTransition className={styles["form-container"]}>
<FormLayout <FormLayout
title="Supprimer une catégorie" title={t("common:category.remove")}
categoryId={category.id.toString()} categoryId={category.id.toString()}
errorMessage={error} errorMessage={error}
canSubmit={canSubmit} canSubmit={canSubmit}
@@ -66,14 +69,14 @@ export default function PageRemoveCategory({
> >
<TextBox <TextBox
name="name" name="name"
label="Nom" label={t("common:category.name")}
value={category.name} value={category.name}
fieldClass={styles["input-field"]} fieldClass={styles["input-field"]}
disabled={true} disabled={true}
/> />
<Checkbox <Checkbox
name="confirm-delete" name="confirm-delete"
label="Confirmer la suppression ?" label={t("common:category.remove-confirm")}
isChecked={confirmDelete} isChecked={confirmDelete}
disabled={!!error} disabled={!!error}
onChangeCallback={(checked) => setConfirmDelete(checked)} onChangeCallback={(checked) => setConfirmDelete(checked)}
@@ -84,7 +87,7 @@ export default function PageRemoveCategory({
} }
export const getServerSideProps = withAuthentication( export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => { async ({ query, session, user, locale }) => {
const { cid } = query; const { cid } = query;
const category = await getUserCategory(user, Number(cid)); const category = await getUserCategory(user, Number(cid));
@@ -100,6 +103,7 @@ export const getServerSideProps = withAuthentication(
props: { props: {
session, session,
category: JSON.parse(JSON.stringify(category)), 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 BlockWrapper from "components/BlockWrapper/BlockWrapper";
import ButtonLink from "components/ButtonLink"; import ButtonLink from "components/ButtonLink";
import LangSelector from "components/LangSelector";
import Links from "components/Links/Links"; import Links from "components/Links/Links";
import Modal from "components/Modal/Modal"; import Modal from "components/Modal/Modal";
import PageTransition from "components/PageTransition"; import PageTransition from "components/PageTransition";
@@ -12,14 +8,18 @@ import SearchModal from "components/SearchModal/SearchModal";
import Categories from "components/SideMenu/Categories/Categories"; import Categories from "components/SideMenu/Categories/Categories";
import SideMenu from "components/SideMenu/SideMenu"; import SideMenu from "components/SideMenu/SideMenu";
import UserCard from "components/SideMenu/UserCard/UserCard"; import UserCard from "components/SideMenu/UserCard/UserCard";
import * as Keys from "constants/keys"; import * as Keys from "constants/keys";
import PATHS from "constants/paths"; import PATHS from "constants/paths";
import { AnimatePresence } from "framer-motion";
import { useMediaQuery } from "hooks/useMediaQuery"; import { useMediaQuery } from "hooks/useMediaQuery";
import useModal from "hooks/useModal"; import useModal from "hooks/useModal";
import { getServerSideTranslation } from "i18n";
import getUserCategories from "lib/category/getUserCategories"; 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 { Category, Link, SearchItem } from "types";
import { pushStateVanilla } from "utils/link";
import { withAuthentication } from "utils/session"; import { withAuthentication } from "utils/session";
interface HomePageProps { interface HomePageProps {
@@ -30,6 +30,7 @@ interface HomePageProps {
export default function HomePage(props: HomePageProps) { export default function HomePage(props: HomePageProps) {
const router = useRouter(); const router = useRouter();
const searchModal = useModal(); const searchModal = useModal();
const { t } = useTranslation();
const isMobile = useMediaQuery("(max-width: 768px)"); const isMobile = useMediaQuery("(max-width: 768px)");
const mobileModal = useModal(); const mobileModal = useModal();
@@ -105,7 +106,7 @@ export default function HomePage(props: HomePageProps) {
const handleSelectCategory = (category: Category) => { const handleSelectCategory = (category: Category) => {
setCategoryActive(category); setCategoryActive(category);
pushStateVanilla(`${PATHS.HOME}?categoryId=${category.id}`); router.push(`${PATHS.HOME}?categoryId=${category.id}`);
mobileModal.close(); mobileModal.close();
}; };
@@ -142,13 +143,22 @@ export default function HomePage(props: HomePageProps) {
<PageTransition className="App"> <PageTransition className="App">
{isMobile ? ( {isMobile ? (
<> <>
<UserCard /> <span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<UserCard />
<LangSelector />
</span>
<AnimatePresence> <AnimatePresence>
{mobileModal.isShowing && ( {mobileModal.isShowing && (
<Modal close={mobileModal.close}> <Modal close={mobileModal.close}>
<BlockWrapper style={{ minHeight: "0", flex: "1" }}> <BlockWrapper style={{ minHeight: "0", flex: "1" }}>
<ButtonLink href={PATHS.CATEGORY.CREATE}> <ButtonLink href={PATHS.CATEGORY.CREATE}>
Créer categorie {t("common:category.create")}
</ButtonLink> </ButtonLink>
<Categories <Categories
categories={categories} categories={categories}
@@ -193,7 +203,7 @@ export default function HomePage(props: HomePageProps) {
} }
export const getServerSideProps = withAuthentication( export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => { async ({ query, session, user, locale }) => {
const queryCategoryId = (query?.categoryId as string) || ""; const queryCategoryId = (query?.categoryId as string) || "";
const categories = await getUserCategories(user); const categories = await getUserCategories(user);
@@ -215,6 +225,7 @@ export const getServerSideProps = withAuthentication(
currentCategory: currentCategory currentCategory: currentCategory
? JSON.parse(JSON.stringify(currentCategory)) ? JSON.parse(JSON.stringify(currentCategory))
: null, : null,
...(await getServerSideTranslation(locale)),
}, },
}; };
} }

View File

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

View File

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

View File

@@ -1,22 +1,22 @@
import axios from "axios"; import axios from "axios";
import { useRouter } from "next/router";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import Checkbox from "components/Checkbox"; import Checkbox from "components/Checkbox";
import FormLayout from "components/FormLayout"; import FormLayout from "components/FormLayout";
import PageTransition from "components/PageTransition"; import PageTransition from "components/PageTransition";
import TextBox from "components/TextBox"; import TextBox from "components/TextBox";
import PATHS from "constants/paths"; import PATHS from "constants/paths";
import { getServerSideTranslation } from "i18n";
import getUserLink from "lib/link/getUserLink"; 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 { Link } from "types";
import { HandleAxiosError } from "utils/front"; import { HandleAxiosError } from "utils/front";
import { withAuthentication } from "utils/session"; import { withAuthentication } from "utils/session";
import styles from "styles/form.module.scss";
export default function PageRemoveLink({ link }: { link: Link }) { export default function PageRemoveLink({ link }: { link: Link }) {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,7 +47,7 @@ export default function PageRemoveLink({ link }: { link: Link }) {
return ( return (
<PageTransition className={styles["form-container"]}> <PageTransition className={styles["form-container"]}>
<FormLayout <FormLayout
title="Supprimer un lien" title={t("common:link.remove")}
categoryId={link.category.id.toString()} categoryId={link.category.id.toString()}
errorMessage={error} errorMessage={error}
canSubmit={canSubmit} canSubmit={canSubmit}
@@ -57,34 +57,34 @@ export default function PageRemoveLink({ link }: { link: Link }) {
> >
<TextBox <TextBox
name="name" name="name"
label="Nom" label={t("common:link.name")}
value={link.name} value={link.name}
fieldClass={styles["input-field"]} fieldClass={styles["input-field"]}
disabled={true} disabled={true}
/> />
<TextBox <TextBox
name="url" name="url"
label="URL" label={t("common:link.link")}
value={link.url} value={link.url}
fieldClass={styles["input-field"]} fieldClass={styles["input-field"]}
disabled={true} disabled={true}
/> />
<TextBox <TextBox
name="category" name="category"
label="Catégorie" label={t("common:category.category")}
value={link.category.name} value={link.category.name}
fieldClass={styles["input-field"]} fieldClass={styles["input-field"]}
disabled={true} disabled={true}
/> />
<Checkbox <Checkbox
name="favorite" name="favorite"
label="Favoris" label={t("common:favorite")}
isChecked={link.favorite} isChecked={link.favorite}
disabled={true} disabled={true}
/> />
<Checkbox <Checkbox
name="confirm-delete" name="confirm-delete"
label="Confirmer la suppression ?" label={t("common:category.remove-confirm")}
isChecked={confirmDelete} isChecked={confirmDelete}
onChangeCallback={(checked) => setConfirmDelete(checked)} onChangeCallback={(checked) => setConfirmDelete(checked)}
/> />
@@ -94,7 +94,7 @@ export default function PageRemoveLink({ link }: { link: Link }) {
} }
export const getServerSideProps = withAuthentication( export const getServerSideProps = withAuthentication(
async ({ query, session, user }) => { async ({ query, session, user, locale }) => {
const { lid } = query; const { lid } = query;
const link = await getUserLink(user, Number(lid)); const link = await getUserLink(user, Number(lid));
@@ -110,6 +110,7 @@ export const getServerSideProps = withAuthentication(
props: { props: {
session, session,
link: JSON.parse(JSON.stringify(link)), 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 { Provider } from "next-auth/providers";
import { getProviders, signIn } from "next-auth/react"; import { getProviders, signIn } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo"; import { NextSeo } from "next-seo";
import Image from "next/image"; import Image from "next/image";
import { FcGoogle } from "react-icons/fc"; 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 styles from "styles/login.module.scss";
import { getSession } from "utils/session";
interface SignInProps { interface SignInProps {
providers: Provider[]; providers: Provider[];
} }
export default function SignIn({ providers }: SignInProps) { export default function SignIn({ providers }: SignInProps) {
const { t } = useTranslation("login");
return ( return (
<div className={styles["login-page"]}> <div className={styles["login-page"]}>
<PageTransition className={styles["login-container"]}> <PageTransition className={styles["login-container"]} hideLangageSelector>
<NextSeo title="Authentification" /> <NextSeo title={t("login:title")} />
<div className={styles["image-wrapper"]}> <div className={styles["image-wrapper"]}>
<Image <Image
src={"/logo-light.png"} src={"/logo-light.png"}
@@ -31,24 +33,28 @@ export default function SignIn({ providers }: SignInProps) {
/> />
</div> </div>
<div className={styles["form-wrapper"]}> <div className={styles["form-wrapper"]}>
<h1>Authentification</h1> <h1>{t("login:title")}</h1>
<MessageManager info="Authentification requise pour utiliser ce service" /> <MessageManager info={t("login:informative-text")} />
{Object.values(providers).map(({ name, id }) => ( {Object.values(providers).map(({ name, id }) => (
<ButtonLink <ButtonLink
onClick={() => signIn(id, { callbackUrl: PATHS.HOME })} onClick={() => signIn(id, { callbackUrl: PATHS.HOME })}
className={styles["login-button"]} className={styles["login-button"]}
key={id} key={id}
> >
<FcGoogle size={"1.5em"} /> Continuer avec {name} <FcGoogle size={"1.5em"} />{" "}
{t("login:continue-with", { provider: name } as undefined)}
</ButtonLink> </ButtonLink>
))} ))}
</div> </div>
</PageTransition> </PageTransition>
<div className="lang-selector">
<LangSelector />
</div>
</div> </div>
); );
} }
export async function getServerSideProps({ req, res }) { export async function getServerSideProps({ req, res, locale }) {
const session = await getSession(req, res); const session = await getSession(req, res);
const user = await getUser(session); const user = await getUser(session);
if (user) { if (user) {
@@ -61,6 +67,10 @@ export async function getServerSideProps({ req, res }) {
const providers = await getProviders(); const providers = await getProviders();
return { return {
props: { session, providers }, props: {
session,
providers,
...(await getServerSideTranslation(locale)),
},
}; };
} }

View File

@@ -1,25 +1,25 @@
@import "keyframes.scss"; @import "keyframes.scss";
.App { .App {
height: 100%; margin-top: 10em;
margin-bottom: 3em;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadein 250ms both; animation: fadein 250ms both;
& h1 { & h1 {
display: inline-block; font-size: 1.75em;
border-right: 1px solid rgba(0, 0, 0, 0.3);
margin: 0;
margin-right: 20px;
padding: 10px 23px 10px 0;
font-size: 24px;
font-weight: 500; 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 { & h2 {
font-size: 14px; font-size: 1em;
font-weight: normal; font-weight: normal;
line-height: inherit; line-height: inherit;
margin: 0; margin: 0;

View File

@@ -193,6 +193,12 @@ kbd {
display: inline-block; display: inline-block;
} }
.lang-selector {
position: absolute;
bottom: 4em;
right: 2em;
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.App { .App {
width: 100%; 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) { export function groupItemBy(array: any[], property: string) {
const hash = {}; const hash = {};
const props = property.split("."); const props = property.split(".");
for (const item of array) { for (const item of array) {
const key = props.reduce((acc, prop) => acc && acc[prop], item); 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]) { if (!hash[hashKey]) {
hash[hashKey] = []; hash[hashKey] = [];

View File

@@ -1,11 +1,3 @@
export function faviconLinkBuilder(origin: string) { export function faviconLinkBuilder(origin: string) {
return `http://localhost:3000/api/favicon?url=${origin}`; 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, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "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"] "exclude": ["node_modules"]
} }