diff --git a/app/components/common/Cookies.telefunc.ts b/app/components/common/Cookies.telefunc.ts new file mode 100644 index 0000000..723a2c2 --- /dev/null +++ b/app/components/common/Cookies.telefunc.ts @@ -0,0 +1,25 @@ +import type { PageContext } from "vike/types"; + +import { getTelefuncContext } from "@/lib/getTelefuncContext"; +import { CookieParser } from "@/services/CookieParser"; + +export type ConsentCookies = keyof PageContext["cookies"]["consent"]; + +export async function onUpdateConsentCookie(cookieName: ConsentCookies, cookieValue: boolean) { + const context = getTelefuncContext(); + const { reply } = context; + + CookieParser.set(reply, cookieName, cookieValue.toString(), 365); + + return { ok: true, message: "Updated consent cookie", cookieName, cookieValue }; +} + +export async function onSetAllConsentCookie(cookieValue: boolean) { + const context = getTelefuncContext(); + const { reply } = context; + + CookieParser.set(reply, "analytics", cookieValue.toString(), 365); + CookieParser.set(reply, "customization", cookieValue.toString(), 365); + + return { ok: true, message: "Updated consents cookies" }; +} diff --git a/app/components/common/Cookies.tsx b/app/components/common/Cookies.tsx new file mode 100644 index 0000000..1074590 --- /dev/null +++ b/app/components/common/Cookies.tsx @@ -0,0 +1,192 @@ +import { onUpdateConsentCookie, onSetAllConsentCookie, type ConsentCookies } from "./Cookies.telefunc"; +import React, { useState, useContext, createContext, useMemo } from "react"; +import { usePageContext } from "vike-react/usePageContext"; +import { reload } from "vike/client/router"; +import { Button } from "@syntax/Button"; +import { toast } from "react-toastify"; +import { Toggle } from "./Toggle"; +import { Link } from "./Link"; + +export const CookiesContext = createContext<{ + cookies: { + analytics: boolean; + customization: boolean; + }; + setCookie: (cookieName: ConsentCookies, cookieValue: boolean) => void; + setAllCookies: (cookieValue: boolean) => void; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + isSelectionOpen: boolean; + setIsSelectionOpen: (isSelectionOpen: boolean) => void; +}>({ + cookies: { + analytics: false, + customization: false, + }, + setCookie: (_cookieName: ConsentCookies, _cookieValue: boolean) => {}, + setAllCookies: () => {}, + isOpen: false, + setIsOpen: () => {}, + isSelectionOpen: false, + setIsSelectionOpen: () => {}, +}); + +type CookiesContainerProps = { + children?: React.ReactNode; +}; + +export function CookiesContainer(props: CookiesContainerProps) { + const { cookies } = usePageContext(); + + const [consentCookies, setConsentCookies] = useState(cookies.consent); + const [isSelectionOpen, setIsSelectionOpen] = useState(false); + const [isOpen, setIsOpen] = useState(() => { + return !Object.keys(cookies.consent).every((value) => value); + }); + + const toastPromiseMessages = useMemo( + () => ({ + pending: "Mise à jour des cookies...", + success: "Cookies mis à jour !", + error: "Erreur lors de la mise à jour des cookies", + }), + [], + ); + + const handleUpdateCookie = (cookieName: ConsentCookies, cookieValue: boolean) => { + setConsentCookies((prev) => ({ + ...prev, + [cookieName]: cookieValue, + })); + + toast.promise(onUpdateConsentCookie(cookieName, cookieValue), toastPromiseMessages).then(() => { + setIsOpen(false); + reload(); + }); + }; + + const handleSetAll = (value: boolean) => { + setConsentCookies({ analytics: true, customization: true }); + + toast.promise(onSetAllConsentCookie(value), toastPromiseMessages).then(() => { + setIsOpen(false); + setIsSelectionOpen(false); + reload(); + }); + }; + + return ( + + {props.children} + {isSelectionOpen && } + {isOpen && } + + ); +} + +function CookieChoices() { + const cookiesContext = useContext(CookiesContext); + + return ( +
+
+ + +

Personnalisation des cookies 🍪

+ +
+ cookiesContext.setCookie("analytics", checked)} + /> + + cookiesContext.setCookie("customization", checked)} + /> +
+
+
+ ); +} + +function CookieModal() { + const cookiesContext = useContext(CookiesContext); + + return ( +
+ + +
+

+ Coucou c'est nous... +
+ les cookies ! 🍪 +

+ +

+ On ne t‘embête pas longtemps, on te laisse même le choix (si ça c‘est pas la classe 😎). +

+ +

+ Si tu veux en savoir plus, tu peux consulter la page{" "} + + Politique de confidentialité + + . +

+
+ +
+ + + + + +
+
+ ); +} diff --git a/app/components/common/Toggle.tsx b/app/components/common/Toggle.tsx new file mode 100644 index 0000000..6004f1a --- /dev/null +++ b/app/components/common/Toggle.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import clsx from "clsx"; + +type ToggleProps = { + id: string; + label: string; + onChange?: (checked: boolean) => void; + checked: boolean; +}; + +export function Toggle(props: ToggleProps) { + return ( +
+ props.onChange?.(e.target.checked)} + checked={props.checked} + aria-checked={props.checked} + role="switch" + aria-label={props.label} + /> + + +
+ ); +} diff --git a/app/components/syntax/Button.tsx b/app/components/syntax/Button.tsx index 6b5234d..119063f 100644 --- a/app/components/syntax/Button.tsx +++ b/app/components/syntax/Button.tsx @@ -7,6 +7,8 @@ const variantStyles = { "bg-violet-300 font-semibold text-slate-900 hover:bg-violet-200 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-300/50 active:bg-violet-500", secondary: "bg-slate-800 font-medium text-white hover:bg-slate-700 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 active:text-slate-400", + ghost: + "bg-transparent font-medium text-slate-900 dark:text-slate-400 hover:bg-slate-100 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300/50 active:bg-slate-200", }; const sizeStyles = { diff --git a/app/components/syntax/Navigation.tsx b/app/components/syntax/Navigation.tsx index daa77b7..1a1e99f 100644 --- a/app/components/syntax/Navigation.tsx +++ b/app/components/syntax/Navigation.tsx @@ -161,6 +161,7 @@ export function Navigation({ onLinkClick?: React.MouseEventHandler; }) { const firstSections = navigation.filter((section) => section.position === "start"); + const lastSections = navigation.filter((section) => section.position === "end"); const filteredSections = navigation .filter((section) => section.position === "auto" || section.position === undefined) @@ -192,6 +193,12 @@ export function Navigation({ ))} ))} +
  • +

    {lastSections[0]?.type}

    + {lastSections.map((section) => ( + + ))} +
  • ); diff --git a/app/components/syntax/ThemeSelector.tsx b/app/components/syntax/ThemeSelector.tsx index e067857..cfc7384 100644 --- a/app/components/syntax/ThemeSelector.tsx +++ b/app/components/syntax/ThemeSelector.tsx @@ -48,7 +48,7 @@ export function ThemeSelector(props: React.ComponentPropsWithoutRef diff --git a/app/eslint.config.js b/app/eslint.config.js index 23320c6..2e6b835 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -36,6 +36,7 @@ export default tseslint.config( "@typescript-eslint/no-unused-vars": [1, { argsIgnorePattern: "^_" }], "@typescript-eslint/no-namespace": 0, "react/react-in-jsx-scope": "warn", + "react/no-unescaped-entities": "off", "react/jsx-filename-extension": [1, { extensions: [".tsx"] }], }, }, diff --git a/app/fastify-entry.ts b/app/fastify-entry.ts index 1bf2846..2127835 100644 --- a/app/fastify-entry.ts +++ b/app/fastify-entry.ts @@ -1,6 +1,9 @@ +import type { Theme } from "@/contexts/ThemeContext"; + import { createHandler } from "@universal-middleware/fastify"; import { telefuncHandler } from "./server/telefunc-handler"; import { vikeHandler } from "./server/vike-handler"; +import fastifyCookie from "@fastify/cookie"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; import Fastify from "fastify"; @@ -12,9 +15,31 @@ const hmrPort = process.env.HMR_PORT ? parseInt(process.env.HMR_PORT, 10) : 2467 const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; const root = __dirname; +declare global { + namespace Vike { + interface PageContext { + cookies: { + consent: { + analytics: boolean; + customization: boolean; + }; + settings: { + theme: Theme; + }; + }; + } + } +} + async function startServer() { const app = Fastify(); + app.register(fastifyCookie, { + secret: "todo", + hook: "onRequest", + parseOptions: {}, + }); + // Avoid pre-parsing body, otherwise it will cause issue with universal handlers // This will probably change in the future though, you can follow https://github.com/magne4000/universal-middleware for updates app.removeAllContentTypeParsers(); diff --git a/app/layouts/LayoutDefault.tsx b/app/layouts/LayoutDefault.tsx index f784685..ac71417 100644 --- a/app/layouts/LayoutDefault.tsx +++ b/app/layouts/LayoutDefault.tsx @@ -1,3 +1,4 @@ +import { CookiesContainer } from "@/components/common/Cookies"; import { MobileNavigation } from "@syntax/MobileNavigation"; import { usePageContext } from "vike-react/usePageContext"; import { ThemeProvider } from "@/providers/ThemeProvider"; @@ -56,7 +57,9 @@ function Header() {
    - Memento Dev + + Memento Dev +
    @@ -74,30 +77,70 @@ function Header() { ); } +function Footer() { + return ( +
    +
    +
    +
    + +

    Memento Dev

    +
    + +

    + Plateforme de ressources et documentations synthétiques et concises, conçue pour les développeurs ou + passionnés de l‘informatique en quête de savoir. +

    +
    + +
    + +
    +
    +

    © 2022 - {new Date().getFullYear()} Memento Dev. Tous droits réservés

    +
    + +

    + Memento Dev est une plateforme open-source, développée par{" "} + + Gauthier Daniels + + , soutenue et maintenue par une communauté de contributeurs passionnés. +

    +
    +
    +
    + ); +} + export default function DefaultLayout({ children }: { children: React.ReactNode }) { - const { urlPathname } = usePageContext(); + const { urlPathname, cookies } = usePageContext(); const isHomePage = urlPathname === "/"; return ( - -
    -
    + + +
    +
    - {isHomePage && } + {isHomePage && } -
    -
    -
    -
    -
    -
    - +
    +
    +
    +
    +
    +
    + +
    +
    {children}
    - {children} + +
    -
    - - + + + ); } diff --git a/app/lib/getTelefuncContext.ts b/app/lib/getTelefuncContext.ts new file mode 100644 index 0000000..5071844 --- /dev/null +++ b/app/lib/getTelefuncContext.ts @@ -0,0 +1,18 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; + +import { getContext } from "telefunc"; + +export function getTelefuncContext() { + const context = getContext<{ + fastify: { + request: FastifyRequest; + reply: FastifyReply; + }; + }>(); + + return { + ...context, + reply: context.fastify.reply, + request: context.fastify.request, + }; +} diff --git a/app/lib/navigation.ts b/app/lib/navigation.ts index c584b75..a74b5e2 100644 --- a/app/lib/navigation.ts +++ b/app/lib/navigation.ts @@ -2,12 +2,13 @@ const navigationsTypes = { GLOBAL: "👋 Général", CERTIFICATIONS: "🎓 Certifications", DOCUMENTATIONS: "📚 Documentations", + OTHER: "🔗 Autres", }; export type NavigationSection = { title: string; type: (typeof navigationsTypes)[keyof typeof navigationsTypes]; - position: "start" | "auto"; + position: "start" | "end" | "auto"; links: NavigationLink[]; }; @@ -47,6 +48,15 @@ export const navigation: NavigationSection[] = [ { title: "Partages et réutilisations", href: "/docs/communaute/partages", subitems: [] }, ], }, + { + title: "Légal", + type: navigationsTypes.OTHER, + position: "end", + links: [ + { title: "Mentions légales", href: "/mentions-legales", subitems: [] }, + { title: "Politique de confidentialité", href: "/politique-de-confidentialite", subitems: [] }, + ], + }, { title: "Développeur Web et Web Mobile", type: navigationsTypes.CERTIFICATIONS, diff --git a/app/package.json b/app/package.json index 01afe5a..a6e5eba 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "prod": "npm-run-all build preview" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/middie": "^9.0.3", "@fastify/static": "^8.1.1", "@fontsource-variable/inter": "^5.2.5", diff --git a/app/pages/+Head.tsx b/app/pages/+Head.tsx index cb6c941..02506c6 100644 --- a/app/pages/+Head.tsx +++ b/app/pages/+Head.tsx @@ -1,10 +1,28 @@ +import { usePageContext } from "vike-react/usePageContext"; import logoUrl from "@/assets/logo.svg"; import React from "react"; export default function HeadDefault() { + const { cookies } = usePageContext(); + return ( <> + {cookies.consent.analytics && ( + <> +