Compare commits
9 Commits
949843cf4e
...
bbdbb1aec1
| Author | SHA1 | Date | |
|---|---|---|---|
| bbdbb1aec1 | |||
| 03b6a9a0a7 | |||
| 53c708d0ca | |||
| 438d3e62e4 | |||
| fb2d767a37 | |||
| 861ac6eb11 | |||
| 50d90107a1 | |||
| 993089e8a0 | |||
| df859c0c06 |
44
app/components/common/Toggle.tsx
Normal file
44
app/components/common/Toggle.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
type ToggleProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
checked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Toggle(props: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={props.id}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => props.onChange?.(e.target.checked)}
|
||||||
|
checked={props.checked}
|
||||||
|
aria-checked={props.checked}
|
||||||
|
role="switch"
|
||||||
|
aria-label={props.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor={props.id} className="flex cursor-pointer items-center justify-between rounded-full">
|
||||||
|
<span className="relative flex h-6 w-10 items-center">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"h-4 w-4 rounded-full bg-white shadow-md transition-transform duration-200 ease-in-out z-10",
|
||||||
|
props.checked ? "translate-x-[calc(100%+.25em)]" : "translate-x-1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"absolute top-1/2 left-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 rounded-full transition duration-200 ease-in-out z-0",
|
||||||
|
props.checked ? "bg-violet-500" : "bg-slate-300",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-2 text-sm text-slate-700 dark:text-slate-300">{props.label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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",
|
"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:
|
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",
|
"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 = {
|
const sizeStyles = {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export function ThemeSelector(props: React.ComponentPropsWithoutRef<typeof Listb
|
|||||||
<Listbox as="div" value={theme} onChange={setTheme} {...props}>
|
<Listbox as="div" value={theme} onChange={setTheme} {...props}>
|
||||||
<Label className="sr-only">Theme</Label>
|
<Label className="sr-only">Theme</Label>
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-lg ring-1 shadow-md shadow-black/5 ring-black/5 dark:bg-slate-700 dark:ring-white/5 dark:ring-inset"
|
className="flex h-6 w-6 items-center justify-center rounded-lg ring-1 shadow-md shadow-black/5 ring-black/5 dark:bg-slate-700 dark:ring-white/5 dark:ring-inset cursor-pointer"
|
||||||
aria-label="Theme"
|
aria-label="Theme"
|
||||||
>
|
>
|
||||||
<LightIcon className={clsx("h-4 w-4 dark:hidden", "fill-violet-400")} />
|
<LightIcon className={clsx("h-4 w-4 dark:hidden", "fill-violet-400")} />
|
||||||
|
|||||||
@ -23,7 +23,9 @@ declare global {
|
|||||||
analytics: boolean;
|
analytics: boolean;
|
||||||
customization: boolean;
|
customization: boolean;
|
||||||
};
|
};
|
||||||
theme: Theme;
|
settings: {
|
||||||
|
theme: Theme;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/layouts/LayoutDefault.telefunc.ts
Normal file
25
app/layouts/LayoutDefault.telefunc.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { PageContext } from "vike/types";
|
||||||
|
|
||||||
|
import { getTelefuncContext } from "@/lib/getTelefuncContext";
|
||||||
|
import { CookieParser } from "@/services/CookieParser";
|
||||||
|
|
||||||
|
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 onAcceptAllConsentCookie() {
|
||||||
|
const context = getTelefuncContext();
|
||||||
|
const { reply } = context;
|
||||||
|
|
||||||
|
CookieParser.set(reply, "analytics", "true", 365);
|
||||||
|
CookieParser.set(reply, "customization", "true", 365);
|
||||||
|
|
||||||
|
return { ok: true, message: "Updated consents cookies" };
|
||||||
|
}
|
||||||
@ -1,12 +1,17 @@
|
|||||||
|
import { onUpdateConsentCookie, onAcceptAllConsentCookie } from "./LayoutDefault.telefunc";
|
||||||
import { MobileNavigation } from "@syntax/MobileNavigation";
|
import { MobileNavigation } from "@syntax/MobileNavigation";
|
||||||
import { usePageContext } from "vike-react/usePageContext";
|
import { usePageContext } from "vike-react/usePageContext";
|
||||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||||
|
import { ToastContainer, toast } from "react-toastify";
|
||||||
import { ThemeSelector } from "@syntax/ThemeSelector";
|
import { ThemeSelector } from "@syntax/ThemeSelector";
|
||||||
|
import { Button } from "@/components/syntax/Button";
|
||||||
|
import { Toggle } from "@/components/common/Toggle";
|
||||||
import { clientOnly } from "vike-react/clientOnly";
|
import { clientOnly } from "vike-react/clientOnly";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ToastContainer } from "react-toastify";
|
|
||||||
import { Navigation } from "@syntax/Navigation";
|
import { Navigation } from "@syntax/Navigation";
|
||||||
import { Link } from "@/components/common/Link";
|
import { Link } from "@/components/common/Link";
|
||||||
|
import { navigation } from "@/lib/navigation";
|
||||||
|
import { reload } from "vike/client/router";
|
||||||
import { Hero } from "@syntax/Hero";
|
import { Hero } from "@syntax/Hero";
|
||||||
import { Logo } from "@syntax/Logo";
|
import { Logo } from "@syntax/Logo";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@ -56,7 +61,9 @@ function Header() {
|
|||||||
<div className="relative flex grow basis-0 items-center">
|
<div className="relative flex grow basis-0 items-center">
|
||||||
<Link href="/" aria-label="Home page" className="flex items-center gap-2">
|
<Link href="/" aria-label="Home page" className="flex items-center gap-2">
|
||||||
<Logo className="h-9 w-auto" />
|
<Logo className="h-9 w-auto" />
|
||||||
<span className="hidden lg:inline text-2xl font-bold -tracking-tight">Memento Dev</span>
|
<span className="hidden lg:inline text-2xl font-bold -tracking-tight text-slate-900 dark:text-slate-50">
|
||||||
|
Memento Dev
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -74,12 +81,185 @@ function Header() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CookieModal() {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSelectionOpen) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm">
|
||||||
|
<div className="relative flex flex-col gap-2 bg-slate-50 dark:bg-slate-800 rounded-md shadow-xl w-full max-w-sm p-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-0 right-0"
|
||||||
|
onClick={() => setIsSelectionOpen(false)}
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="font-display dark:text-slate-300 font-bold text-lg">Personnalisation des cookies 🍪</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full items-start">
|
||||||
|
<Toggle
|
||||||
|
id="cookies-analytics"
|
||||||
|
label="Cookies d'analyse (Umami et Google Analytics)"
|
||||||
|
checked={consentCookies.analytics}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setConsentCookies({ ...consentCookies, analytics: checked });
|
||||||
|
|
||||||
|
toast
|
||||||
|
.promise(onUpdateConsentCookie("analytics", checked), {
|
||||||
|
pending: "Mise à jour des cookies...",
|
||||||
|
success: "Cookies mis à jour !",
|
||||||
|
error: "Erreur lors de la mise à jour des cookies.",
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
id="cookies-customization"
|
||||||
|
label="Cookie de personnalisation (thème)"
|
||||||
|
checked={consentCookies.customization}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setConsentCookies({ ...consentCookies, analytics: checked });
|
||||||
|
|
||||||
|
toast
|
||||||
|
.promise(onUpdateConsentCookie("customization", checked), {
|
||||||
|
pending: "Mise à jour des cookies...",
|
||||||
|
success: "Cookies mis à jour !",
|
||||||
|
error: "Erreur lors de la mise à jour des cookies.",
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setConsentCookies({ ...consentCookies, [data.cookieName]: data.cookieValue });
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col fixed bottom-4 left-4 bg-slate-50 dark:bg-slate-800 z-50 rounded-md shadow-xl w-full max-w-sm overflow-hidden">
|
||||||
|
<Button variant="ghost" size="sm" className="absolute top-0 right-0" onClick={() => setIsOpen(false)}>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 p-4">
|
||||||
|
<p className="font-display dark:text-slate-300">
|
||||||
|
<span className="text-sm">Coucou c'est nous...</span>
|
||||||
|
<br />
|
||||||
|
<span className="font-bold text-lg">les cookies ! 🍪</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-slate-700 dark:text-slate-300">
|
||||||
|
On ne t'embête pas longtemps, on te laisse même le choix <em>(si ça c'est pas la classe 😎)</em>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-slate-700 dark:text-slate-300">
|
||||||
|
Si tu veux en savoir plus, tu peux consulter la page{" "}
|
||||||
|
<Link href="/politique-de-confidentialite" className="font-bold">
|
||||||
|
Politique de confidentialité
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid items-center grid-cols-3 justify-between bg-slate-100 dark:bg-slate-700">
|
||||||
|
<button
|
||||||
|
className="cursor-pointer px-2 py-1 text-slate-600 dark:text-slate-300"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Non merci
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="cursor-pointer px-2 py-1 text-slate-600 dark:text-slate-300"
|
||||||
|
onClick={() => setIsSelectionOpen(true)}
|
||||||
|
>
|
||||||
|
Je choisis
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="cursor-pointer px-2 py-1 font-bold text-white dark:text-black bg-violet-600 dark:bg-violet-300"
|
||||||
|
onClick={() => {
|
||||||
|
setConsentCookies({ analytics: true, customization: true });
|
||||||
|
|
||||||
|
toast
|
||||||
|
.promise(onAcceptAllConsentCookie(), {
|
||||||
|
pending: "Mise à jour des cookies...",
|
||||||
|
success: "Cookies mis à jour !",
|
||||||
|
error: "Erreur lors de la mise à jour des cookies.",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Oui, j'ai faim !
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-slate-50 dark:bg-slate-950 text-slate-700 dark:text-slate-200">
|
||||||
|
<div className="mx-auto w-full flex flex-col max-w-8xl sm:px-2 lg:px-8 xl:px-12 py-8">
|
||||||
|
<section>
|
||||||
|
<header className="flex items-center gap-2 mb-2">
|
||||||
|
<Logo className="h-8 w-auto" />
|
||||||
|
<h2 className="font-display text-2xl">Memento Dev</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="my-6 border-slate-200 dark:border-slate-600" />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header className="flex items-center gap-2">
|
||||||
|
<h2 className="font-display">© 2022 - {new Date().getFullYear()} Memento Dev. Tous droits réservés</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Memento Dev est une plateforme open-source, développée par{" "}
|
||||||
|
<Link href="https://gauthierdaniels.fr" className="font-bold">
|
||||||
|
Gauthier Daniels
|
||||||
|
</Link>
|
||||||
|
, soutenue et maintenue par une communauté de contributeurs passionnés.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DefaultLayout({ children }: { children: React.ReactNode }) {
|
export default function DefaultLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { urlPathname, cookies } = usePageContext();
|
const { urlPathname, cookies } = usePageContext();
|
||||||
const isHomePage = urlPathname === "/";
|
const isHomePage = urlPathname === "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider defaultTheme={cookies.theme}>
|
<ThemeProvider defaultTheme={cookies.settings.theme}>
|
||||||
|
<CookieModal />
|
||||||
|
|
||||||
<div className="flex w-full flex-col font-sans">
|
<div className="flex w-full flex-col font-sans">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
@ -96,6 +276,8 @@ export default function DefaultLayout({ children }: { children: React.ReactNode
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
18
app/lib/getTelefuncContext.ts
Normal file
18
app/lib/getTelefuncContext.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
app/pages/politique-de-confidentialite/+Page.tsx
Normal file
3
app/pages/politique-de-confidentialite/+Page.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function Page() {
|
||||||
|
return <div>Rédaction en cours</div>;
|
||||||
|
}
|
||||||
@ -16,10 +16,12 @@ export const vikeHandler: Get<[], UniversalHandler> = () => async (request, cont
|
|||||||
headersOriginal: request.headers,
|
headersOriginal: request.headers,
|
||||||
cookies: {
|
cookies: {
|
||||||
consent: {
|
consent: {
|
||||||
analytics: cookies.get("consent-analytics", Boolean) || false,
|
analytics: cookies.get<boolean>("analytics", (value) => value === "true") || false,
|
||||||
customization: cookies.get("consent-customization", Boolean) || false,
|
customization: cookies.get<boolean>("customization", (value) => value === "true") || false,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
theme: cookies.get("theme") || "light",
|
||||||
},
|
},
|
||||||
theme: cookies.get("theme") || "light",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const pageContext = await renderPage(pageContextInit);
|
const pageContext = await renderPage(pageContextInit);
|
||||||
|
|||||||
@ -1,3 +1,12 @@
|
|||||||
|
import type { PageContext } from "vike/types";
|
||||||
|
|
||||||
|
import { FastifyReply } from "fastify";
|
||||||
|
|
||||||
|
type ConsentCookies = keyof PageContext["cookies"]["consent"];
|
||||||
|
type SettingsCookie = keyof PageContext["cookies"]["settings"];
|
||||||
|
|
||||||
|
type CookieKeys = ConsentCookies | SettingsCookie;
|
||||||
|
|
||||||
export class CookieParser {
|
export class CookieParser {
|
||||||
private rawCookies: string;
|
private rawCookies: string;
|
||||||
private cookies: Record<string, string>;
|
private cookies: Record<string, string>;
|
||||||
@ -8,8 +17,8 @@ export class CookieParser {
|
|||||||
this.parse();
|
this.parse();
|
||||||
}
|
}
|
||||||
|
|
||||||
parse(): Record<string, string> {
|
parse() {
|
||||||
return this.rawCookies.split("; ").reduce(
|
this.cookies = this.rawCookies.split("; ").reduce(
|
||||||
(acc, cookie) => {
|
(acc, cookie) => {
|
||||||
const [key, value] = cookie.split("=");
|
const [key, value] = cookie.split("=");
|
||||||
acc[key] = decodeURIComponent(value);
|
acc[key] = decodeURIComponent(value);
|
||||||
@ -19,10 +28,25 @@ export class CookieParser {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string, formatter?: Function): string | undefined {
|
get(key: CookieKeys): string | undefined;
|
||||||
|
get<T = unknown>(key: CookieKeys, formatter: (value: string) => T): T | undefined;
|
||||||
|
get<T = unknown>(key: CookieKeys, formatter?: (value: string) => T): T | string | undefined {
|
||||||
const value = this.cookies[key];
|
const value = this.cookies[key];
|
||||||
|
|
||||||
if (formatter) return formatter(value);
|
if (formatter) return formatter(value);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static set(reply: FastifyReply, key: CookieKeys, value: string, daysDuration = 30): FastifyReply {
|
||||||
|
const options = {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 24 * daysDuration * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
reply.setCookie(key, value, options);
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user