diff --git a/app/bun.lock b/app/bun.lock index aa139dd..2831bbc 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -9,9 +9,11 @@ "@universal-middleware/fastify": "^0.5.16", "clsx": "^2.1.1", "fastify": "^5.3.0", + "solid-heroicons": "^3.2.4", "solid-highlight": "^0.1.26", "solid-js": "^1.9.5", "telefunc": "^0.2.3", + "terracotta": "^1.0.6", "vike": "^0.4.228", "vike-solid": "^0.7.9", }, @@ -725,12 +727,16 @@ "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], + "solid-heroicons": ["solid-heroicons@3.2.4", "", { "dependencies": { "solid-js": "^1.7.6" } }, "sha512-u6BMdFLvkJnvUGYzdFcWp1wvJ4hb9Y1zd3AbZ9D3bUmmiy9jBzNZX+RcqBCI2EKRvdQwAb1UB9bkESfqfhayDg=="], + "solid-highlight": ["solid-highlight@0.1.26", "", { "peerDependencies": { "prismjs": "^1.29.0", "solid-js": "^1.8.0" } }, "sha512-Iw1mi3vE+YCBBBU/+HHc5y8VNULaGXUX4OK2c9TbzegOGCitzW0uNIvb0s3S0KoVv7Uma/KadWHNFXkwZCwPgQ=="], "solid-js": ["solid-js@1.9.5", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", "seroval-plugins": "^1.1.0" } }, "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -765,6 +771,8 @@ "telefunc": ["telefunc@0.2.3", "", { "dependencies": { "@brillout/import": "^0.2.6", "@brillout/json-serializer": "^0.5.6", "@brillout/picocolors": "^1.0.26", "@brillout/vite-plugin-server-entry": "^0.7.5", "es-module-lexer": "^1.6.0", "magic-string": "^0.30.17", "ts-morph": "^19.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/parser": ">=7.0.0", "@babel/types": ">=7.0.0", "react": ">=18.0.0", "react-streaming": ">=0.3.3" }, "optionalPeers": ["@babel/core", "@babel/parser", "@babel/types", "react", "react-streaming"] }, "sha512-BZejs1iUrsodzti34+QvgaBUPMIf9H0eUJB0f1VzwVeU3mflvdNKZ5KHOP7iiQYyiNfFjcUbUktMFrjA2J8sHg=="], + "terracotta": ["terracotta@1.0.6", "", { "dependencies": { "solid-use": "^0.9.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-yVrmT/Lg6a3tEbeYEJH8ksb1PYkR5FA9k5gr1TchaSNIiA2ZWs5a+koEbePXwlBP0poaV7xViZ/v50bQFcMgqw=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], "tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx index 283e2e8..619109c 100644 --- a/app/components/Icon.tsx +++ b/app/components/Icon.tsx @@ -28,7 +28,7 @@ const iconStyles = { export type IconColor = keyof typeof iconStyles; -type IconProps = JSX.IntrinsicElements["svg"] & { +export type IconProps = JSX.IntrinsicElements["svg"] & { color?: IconColor; icon: keyof typeof icons; }; diff --git a/app/components/PrevNextLinks.tsx b/app/components/PrevNextLinks.tsx new file mode 100644 index 0000000..f3a6822 --- /dev/null +++ b/app/components/PrevNextLinks.tsx @@ -0,0 +1,97 @@ +import type { JSX } from "solid-js"; + +import { usePageContext } from "vike-solid/usePageContext"; +import { cleanProps } from "@/utils/cleanProps"; +import { navigation } from "@/libs/navigation"; +import { Link } from "@/components/Link"; +import clsx from "clsx"; + +function ArrowIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + ); +} + +type PageLinkProps = Omit & { + title: string; + href: string; + dir?: "previous" | "next"; +}; + +function PageLink(props: PageLinkProps) { + const pageCategory = navigation.find((section) => { + return section.links.some( + (link) => + link.href === props.href || + link.subitems.some((subitem) => subitem.href === props.href), + ); + }); + + return ( +
+
+ {props.dir === "next" ? "Suivant" : "Précédent"} +
+
+ +

+ {pageCategory && ( + + {pageCategory.title} + + )} + {props.title} +

+ + +
+
+ ); +} + +export function PrevNextLinks() { + const { urlPathname } = usePageContext(); + + const allLinks = navigation + .flatMap((section) => section.links) + .flatMap((link) => { + return link.subitems ? [link, ...link.subitems] : link; + }); + + const getNeighboringLinks = () => { + const linkIndex = allLinks.findIndex((link) => link.href === urlPathname); + if (linkIndex === -1) return [null, null]; + + const previousPage = allLinks[linkIndex - 1] || null; + let nextPage = allLinks[linkIndex + 1] || null; + + if (nextPage?.href === urlPathname) { + nextPage = allLinks[linkIndex + 2] || null; + } + + return [previousPage, nextPage]; + }; + + const [previousPage, nextPage] = getNeighboringLinks(); + if (!nextPage && !previousPage) return null; + + return ( +
+ {previousPage && } + {nextPage && } +
+ ); +} diff --git a/app/components/QuickLinks.tsx b/app/components/QuickLinks.tsx new file mode 100644 index 0000000..93d8468 --- /dev/null +++ b/app/components/QuickLinks.tsx @@ -0,0 +1,46 @@ +import type { JSXElement } from "solid-js"; +import type { IconProps } from "./Icon"; + +import { Icon } from "./Icon"; +import { Link } from "./Link"; + +type QuickLinksProps = { + children: JSXElement; +}; + +export function QuickLinks(props: QuickLinksProps) { + return ( +
+ {props.children} +
+ ); +} + +type QuickLinkProps = { + title: string; + description: string; + href: string; + icon: IconProps["icon"]; +}; + +export function QuickLink(props: QuickLinkProps) { + return ( +
+ + ); +} diff --git a/app/layouts/MobileNavigation.tsx b/app/layouts/MobileNavigation.tsx new file mode 100644 index 0000000..2a1602d --- /dev/null +++ b/app/layouts/MobileNavigation.tsx @@ -0,0 +1,92 @@ +import type { JSX } from "solid-js"; + +import { usePageContext } from "vike-solid/usePageContext"; +import { createEffect, createSignal } from "solid-js"; +import { Dialog, DialogPanel } from "terracotta"; +import { Navigation } from "./Navigation"; +import { Link } from "@/components/Link"; +import { Logo } from "@/components/Logo"; + +function MenuIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + ); +} + +function CloseIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + ); +} + +function CloseOnNavigation({ close }: { close: () => void }) { + const { urlPathname } = usePageContext(); + + createEffect(() => { + close(); + }, [urlPathname, close]); + + return null; +} + +export function MobileNavigation() { + const [isOpen, setIsOpen] = createSignal(false); + const close = () => setIsOpen(false); + + return ( + <> + + + + + + +
+ + + + + +
+ +
+
+ + ); +} diff --git a/app/layouts/Navigation.tsx b/app/layouts/Navigation.tsx new file mode 100644 index 0000000..1b7c7fb --- /dev/null +++ b/app/layouts/Navigation.tsx @@ -0,0 +1,259 @@ +import { chevronDown, chevronUp } from "solid-heroicons/solid"; +import { createEffect, createSignal, For } from "solid-js"; +import { usePageContext } from "vike-solid/usePageContext"; +import { navigation } from "@/libs/navigation"; +import { Link } from "@/components/Link"; +import { Icon } from "solid-heroicons"; +import clsx from "clsx"; + +type NavigationItemProps = { + section: (typeof navigation)[number]; + onLinkClick?: (event: MouseEvent) => void; +}; + +function NavigationItem(props: NavigationItemProps) { + const { urlPathname } = usePageContext(); + + const [isOpened, setIsOpened] = createSignal( + props.section.links.some( + (link) => + link.href === urlPathname || + link.subitems?.some((subitem) => subitem.href === urlPathname), + ), + ); + + return ( + <> +

+ +

+ {isOpened() && ( +
    + + {(link) => ( +
  • + subitem.href === urlPathname, + ) + } + /> +
  • + )} +
    +
+ )} + + ); +} + +type NavigationSubItemProps = { + link: (typeof navigation)[number]["links"][number]; + onLinkClick?: (event: MouseEvent) => void; + isOpened?: boolean; +}; + +function NavigationSubItem(props: NavigationSubItemProps) { + const [isOpened, setIsOpened] = createSignal(props.isOpened); + const { urlPathname } = usePageContext(); + + createEffect(() => { + setIsOpened( + props.link.href === urlPathname || + props.link.subitems?.some((subitem) => subitem.href === urlPathname), + ); + }, [urlPathname, props.link]); + + return ( + <> + + {props.link.subitems.length > 0 && ( + + )} + + + {props.link.title} + {props.link.subitems.length > 0 && ( + + {" "} + ({props.link.subitems.length}) + + )} + + + {props.link.subitems.length > 0 && isOpened() && ( +
    + + {(subitem) => ( +
  • + + {subitem.title} + +
  • + )} +
    +
+ )} + + ); +} + +export function Navigation(props: { + class?: string; + onLinkClick?: (event: MouseEvent) => void; +}) { + 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, + ) + .reduce( + (acc, section) => { + if (!acc[section.type]) { + acc[section.type] = []; + } + acc[section.type].push(section); + return acc; + }, + {} as Record, + ); + + return ( + + ); +} diff --git a/app/libs/navigation.ts b/app/libs/navigation.ts new file mode 100644 index 0000000..4706cbb --- /dev/null +++ b/app/libs/navigation.ts @@ -0,0 +1,172 @@ +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" | "end" | "auto"; + links: NavigationLink[]; +}; + +export type NavigationOG = Partial<{ + image: string; +}>; + +export type NavigationLink = { + title: string; + href: string; + og?: NavigationOG; + subitems: NavigationSubItem[]; +}; + +export type NavigationSubItem = { + title: string; + href: string; +}; + +export const navigation: NavigationSection[] = [ + { + title: "Préambule", + type: navigationsTypes.GLOBAL, + position: "start", + links: [ + { title: "Memento Dev", href: "/", subitems: [] }, + { title: "Certifications", href: "/certifications", subitems: [] }, + { title: "Documentations", href: "/docs", subitems: [] }, + ], + }, + { + title: "Communauté", + type: navigationsTypes.GLOBAL, + position: "start", + links: [ + { + title: "Influenceurs", + href: "/docs/communaute/influenceurs", + subitems: [], + }, + { + 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, + position: "auto", + links: [ + { title: "Résumé du titre", href: "/certifications/dwwm", subitems: [] }, + { + title: "Activité Type 1", + href: "/certifications/dwwm/at1", + subitems: [ + { title: "Résumé de l'AT", href: "/certifications/dwwm/at1" }, + { title: "CP 1", href: "/certifications/dwwm/at1/cp1" }, + { title: "CP 2", href: "/certifications/dwwm/at1/cp2" }, + { title: "CP 3", href: "/certifications/dwwm/at1/cp3" }, + { title: "CP 4", href: "/certifications/dwwm/at1/cp4" }, + ], + }, + { + title: "Activité Type 2", + href: "/certifications/dwwm/at2", + subitems: [ + { title: "Résumé de l'AT", href: "/certifications/dwwm/at2" }, + { title: "CP 5", href: "/certifications/dwwm/at2/cp5" }, + { title: "CP 6", href: "/certifications/dwwm/at2/cp6" }, + { title: "CP 7", href: "/certifications/dwwm/at2/cp7" }, + { title: "CP 8", href: "/certifications/dwwm/at2/cp8" }, + ], + }, + ], + }, + { + title: "Front-end", + type: navigationsTypes.DOCUMENTATIONS, + position: "auto", + links: [ + { + title: "React", + href: "/docs/react", + subitems: [ + { title: "Introduction", href: "/docs/react" }, + { title: "Initialisation", href: "/docs/react/initialisation" }, + { title: "Syntaxe JSX", href: "/docs/react/jsx" }, + { title: "Premier composant", href: "/docs/react/premier-composant" }, + { + title: "State et cycle de vie", + href: "/docs/react/state-et-cycle-de-vie", + }, + { title: "Hooks", href: "/docs/react/hooks" }, + { title: "Le hook useContext", href: "/docs/react/use-context" }, + { title: "Le hook useReducer", href: "/docs/react/use-reducer" }, + ], + }, + ], + }, + { + title: "Base de données", + type: navigationsTypes.DOCUMENTATIONS, + position: "auto", + links: [ + { + title: "Merise", + href: "/docs/merise", + og: { image: "/merise/og.webp" }, + subitems: [ + { title: "Introduction", href: "/docs/merise" }, + { + title: "Dictionnaire de données", + href: "/docs/merise/dictionnaire-de-donnees", + }, + { title: "Modèle Conceptuel de Données", href: "/docs/merise/mcd" }, + { title: "Modèle Logique de Données", href: "/docs/merise/mld" }, + { title: "Modèle Physique de Données", href: "/docs/merise/mpd" }, + ], + }, + ], + }, +]; + +export function doesLinkSubitemExist( + link: NavigationLink, + subitemHref: string, +): boolean { + return link.subitems.some((subitem) => subitem.href === subitemHref); +} + +export function findNavigationLink( + namespace: string, + href?: string, +): NavigationLink | undefined { + const currentUrl = `/${namespace}/${href}` + .replace(/\/+/g, "/") + .replace(/\/$/, ""); + + const foundLink = navigation + .flatMap((section) => section.links) + .find((link) => { + return link.href === currentUrl || doesLinkSubitemExist(link, currentUrl); + }); + + return foundLink; +} diff --git a/app/package.json b/app/package.json index be49410..a21cbf1 100755 --- a/app/package.json +++ b/app/package.json @@ -14,9 +14,11 @@ "@universal-middleware/fastify": "^0.5.16", "clsx": "^2.1.1", "fastify": "^5.3.0", + "solid-heroicons": "^3.2.4", "solid-highlight": "^0.1.26", "solid-js": "^1.9.5", "telefunc": "^0.2.3", + "terracotta": "^1.0.6", "vike": "^0.4.228", "vike-solid": "^0.7.9" }, diff --git a/app/utils/cleanProps.ts b/app/utils/cleanProps.ts new file mode 100644 index 0000000..bb6fc45 --- /dev/null +++ b/app/utils/cleanProps.ts @@ -0,0 +1,10 @@ +export function cleanProps( + props: Record, + ...propsToRemove: string[] +): Record { + const newProps = { ...props }; + for (const prop of propsToRemove) { + delete newProps[prop]; + } + return newProps; +}