rework/lightweight #12

Merged
GauthierWebDev merged 106 commits from rework/lightweight into main 2025-04-21 16:27:38 +00:00
251 changed files with 9044 additions and 25440 deletions

View File

@ -1,4 +0,0 @@
**/node_modules/
**/.pnpm-store/
**/dist/
.git

View File

@ -1,3 +1,2 @@
PORT=5500 PORT=5500
HMR_PORT=5501 HMR_PORT=5501
NODE_ENV=development

View File

@ -41,7 +41,7 @@ jobs:
VPS_PATH: ${{ secrets.VPS_PATH }} VPS_PATH: ${{ secrets.VPS_PATH }}
VPS_PORT: ${{ secrets.VPS_PORT }} VPS_PORT: ${{ secrets.VPS_PORT }}
run: | run: |
ssh -i ~/.ssh/id_ed25519 -p $VPS_PORT $VPS_USER@$VPS_HOST "cd $VPS_PATH && docker compose -f compose-prod.yml build --no-cache" ssh -i ~/.ssh/id_ed25519 -p $VPS_PORT $VPS_USER@$VPS_HOST "cd $VPS_PATH && COMPOSE_BAKE=true docker compose -f compose.prod.yml build --no-cache"
echo "📦 The application have been builded on the VPS." echo "📦 The application have been builded on the VPS."
- name: Start the application - name: Start the application
@ -51,5 +51,5 @@ jobs:
VPS_PATH: ${{ secrets.VPS_PATH }} VPS_PATH: ${{ secrets.VPS_PATH }}
VPS_PORT: ${{ secrets.VPS_PORT }} VPS_PORT: ${{ secrets.VPS_PORT }}
run: | run: |
ssh -i ~/.ssh/id_ed25519 -p $VPS_PORT $VPS_USER@$VPS_HOST "cd $VPS_PATH && docker compose -f compose-prod.yml up -d" ssh -i ~/.ssh/id_ed25519 -p $VPS_PORT $VPS_USER@$VPS_HOST "cd $VPS_PATH && docker compose -f compose.prod.yml up -d"
echo "🚀 The application has been started on the VPS." echo "🚀 The application has been started on the VPS."

10
.gitignore vendored
View File

@ -1,6 +1,12 @@
/.env /.env
**/*~lock*
app-old/.pnpm-store
app-old/node_modules
app-old/dist
app-old/public/sitemap.xml
app/.pnpm-store app/.pnpm-store
app/node_modules app/node_modules
app/dist app/dist
app/public/sitemap.xml app/public/sitemap.xml
**/*~lock*

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"biome.searchInPath": false,
"biome.lspBin": "app/node_modules/@biomejs/biome/bin/biome",
}

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM oven/bun:alpine
WORKDIR /app
COPY ./app/package.json ./app/bun.lock ./
RUN bun install --frozen-lockfile
COPY ./app /app
RUN bun run build
EXPOSE 3000
CMD [ "bun", "run", "preview" ]

49
README.md Normal file
View File

@ -0,0 +1,49 @@
Generated with [vike.dev/new](https://vike.dev/new) ([version 429](https://www.npmjs.com/package/create-vike/v/0.0.429)) using this command:
```sh
bun create vike@latest --solid --tailwindcss --authjs --telefunc --fastify --google-analytics --eslint --prettier --biome
```
## Contents
* [`/pages/+config.ts`](#pagesconfigts)
* [Routing](#routing)
* [`/pages/_error/+Page.jsx`](#pages_errorpagejsx)
* [`/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts`](#pagesonpagetransitionstartts-and-pagesonpagetransitionendts)
* [SSR](#ssr)
* [HTML Streaming](#html-streaming)
This app is ready to start. It's powered by [Vike](https://vike.dev) and [SolidJS](https://www.solidjs.com/guides/getting-started).
### `/pages/+config.ts`
Such `+` files are [the interface](https://vike.dev/config) between Vike and your code. It defines:
* A default [`<Layout>` component](https://vike.dev/Layout) (that wraps your [`<Page>` components](https://vike.dev/Page)).
* A default [`title`](https://vike.dev/title).
* Global [`<head>` tags](https://vike.dev/head-tags).
### Routing
[Vike's built-in router](https://vike.dev/routing) lets you choose between:
* [Filesystem Routing](https://vike.dev/filesystem-routing) (the URL of a page is determined based on where its `+Page.jsx` file is located on the filesystem)
* [Route Strings](https://vike.dev/route-string)
* [Route Functions](https://vike.dev/route-function)
### `/pages/_error/+Page.jsx`
The [error page](https://vike.dev/error-page) which is rendered when errors occur.
### `/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts`
The [`onPageTransitionStart()` hook](https://vike.dev/onPageTransitionStart), together with [`onPageTransitionEnd()`](https://vike.dev/onPageTransitionEnd), enables you to implement page transition animations.
### SSR
SSR is enabled by default. You can [disable it](https://vike.dev/ssr) for all your pages or only for some pages.
### HTML Streaming
You can enable/disable [HTML streaming](https://vike.dev/stream) for all your pages, or only for some pages while still using it for others.

9
app/.env Normal file → Executable file
View File

@ -1,7 +1,4 @@
# Environment variables declared in this file are automatically made available to Prisma. # Google Analytics
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. # See the documentation https://support.google.com/analytics/answer/9304153?hl=en#zippy=%2Cweb
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings PUBLIC_ENV__GOOGLE_ANALYTICS="G-XXXXXXXXXX"
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

149
app/.gitignore vendored Executable file
View File

@ -0,0 +1,149 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# firebase-admin service-account
firebase
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Cloudflare
.wrangler/
# Vercel
.vercel/
# Sentry Vite Plugin
.env.sentry-build-plugin
# aws-cdk
.cdk.staging
cdk.out
## Panda
styled-system
styled-system-studio

View File

@ -1 +0,0 @@
data/**/*.md

0
app/.prettierrc Normal file → Executable file
View File

22
app/README.md Normal file → Executable file
View File

@ -1,23 +1,19 @@
Generated with [vike.dev/new](https://vike.dev/new) ([version 410](https://www.npmjs.com/package/create-vike/v/0.0.410)) using this command: Generated with [vike.dev/new](https://vike.dev/new) ([version 429](https://www.npmjs.com/package/create-vike/v/0.0.429)) using this command:
```sh ```sh
pnpm create vike@latest --react --tailwindcss --telefunc --fastify --eslint --prettier bun create vike@latest --solid --tailwindcss --telefunc --fastify --google-analytics --eslint --prettier --biome
``` ```
## Contents ## Contents
* [React](#react) * [`/pages/+config.ts`](#pagesconfigts)
* [Routing](#routing)
* [`/pages/_error/+Page.jsx`](#pages_errorpagejsx)
* [`/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts`](#pagesonpagetransitionstartts-and-pagesonpagetransitionendts)
* [SSR](#ssr)
* [HTML Streaming](#html-streaming)
* [`/pages/+config.ts`](#pagesconfigts) This app is ready to start. It's powered by [Vike](https://vike.dev) and [SolidJS](https://www.solidjs.com/guides/getting-started).
* [Routing](#routing)
* [`/pages/_error/+Page.jsx`](#pages_errorpagejsx)
* [`/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts`](#pagesonpagetransitionstartts-and-pagesonpagetransitionendts)
* [SSR](#ssr)
* [HTML Streaming](#html-streaming)
## React
This app is ready to start. It's powered by [Vike](https://vike.dev) and [React](https://react.dev/learn).
### `/pages/+config.ts` ### `/pages/+config.ts`

11
app/buildPublicUrl.ts Normal file
View File

@ -0,0 +1,11 @@
import type { PageContext } from "vike/types";
export function buildPublicUrl(pageContext: PageContext, resource: string) {
const { baseUrl } = pageContext;
const url = new URL(
resource,
process.env.NODE_ENV === "production" ? "https://memento-dev.fr" : baseUrl,
).toString();
return url;
}

1334
app/bun.lock Normal file

File diff suppressed because it is too large Load Diff

47
app/components/Button.tsx Normal file
View File

@ -0,0 +1,47 @@
import type { JSX } from "solid-js";
import { Link } from "./Link";
import clsx from "clsx";
const variantStyles = {
primary:
"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 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 = {
sm: "rounded-md py-1 px-2 text-xs",
md: "rounded-full py-2 px-4 text-sm",
lg: "rounded-full py-3 px-6 text-base",
};
type ButtonProps = {
variant?: keyof typeof variantStyles;
size?: keyof typeof sizeStyles;
className?: string;
} & (
| JSX.IntrinsicElements["button"]
| (JSX.IntrinsicElements["a"] & { href: string })
);
export function Button(props: ButtonProps) {
const className = clsx(
variantStyles[props.variant ?? "primary"],
sizeStyles[props.size ?? "md"],
"cursor-pointer",
props.className,
);
return "href" in props && props.href ? (
<Link
{...(props as JSX.IntrinsicElements["a"])}
class={className}
href={props.href}
/>
) : (
<button {...(props as JSX.IntrinsicElements["button"])} class={className} />
);
}

View File

@ -0,0 +1,68 @@
import type { JSX } from "solid-js";
import { Icon } from "./Icon";
import clsx from "clsx";
const styles = {
note: {
container: "bg-violet-50",
title: "text-violet-900",
body: "text-slate-800 [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900",
},
warning: {
container: "bg-amber-50",
title: "text-amber-900",
body: "text-slate-800 [--tw-prose-underline:var(--color-slate-400)] [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900",
},
question: {
container: "bg-amber-50",
title: "text-amber-900",
body: "text-slate-800 [--tw-prose-underline:var(--color-slate-400)] [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900",
},
};
const icons = {
note: (props: { class?: string }) => <Icon icon="lightbulb" {...props} />,
warning: (props: { class?: string }) => (
<Icon icon="warning" color="amber" {...props} />
),
question: (props: { class?: string }) => (
<Icon icon="question" color="blue" {...props} />
),
};
export default function Callout(props: {
title: string;
children: JSX.Element;
type?: keyof typeof styles;
collapsible?: boolean;
}) {
const IconComponent = icons[props.type || "note"];
return (
<div
class={clsx(
"my-8 flex flex-col rounded-3xl p-6",
styles[props.type || "note"].container,
{ "cursor-pointer": props.collapsible },
)}
>
<div class="flex items-center gap-6">
<IconComponent class="h-8 w-8 flex-none" />
<p
class={clsx(
"!m-0 font-display text-xl text-balance",
styles[props.type || "note"].title,
)}
>
{props.title}
</p>
</div>
<div class="mt-4 flex-auto">
<div class={clsx("prose mt-2.5", styles[props.type || "note"].body)}>
{props.children}
</div>
</div>
</div>
);
}

49
app/components/Form.tsx Normal file
View File

@ -0,0 +1,49 @@
import clsx from "clsx";
type ToggleProps = {
id: string;
label: string;
onChange?: (checked: boolean) => void;
checked: boolean;
};
export function Toggle(props: ToggleProps) {
return (
<div class="flex items-center justify-center">
<input
type="checkbox"
id={props.id}
class="sr-only"
onChange={(e) => props.onChange?.(e.target.checked)}
checked={props.checked}
aria-checked={props.checked}
role="switch"
aria-label={props.label}
/>
<label
for={props.id}
class="flex cursor-pointer items-center justify-between rounded-full"
>
<span class="relative flex h-6 w-10 items-center">
<span
class={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
class={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 class="ml-2 text-sm text-slate-700">{props.label}</span>
</label>
</div>
);
}

View File

@ -0,0 +1,117 @@
import type { ComponentProps, ParentComponent } from "solid-js";
import {
createEffect,
createMemo,
For,
mergeProps,
on,
splitProps,
} from "solid-js";
import { clipboard } from "solid-heroicons/solid";
import { Icon } from "solid-heroicons";
import * as Prismjs from "prismjs";
import toast from "solid-toast";
import clsx from "clsx";
type Props = {
language: string;
class?: string;
dark?: boolean;
withLineNumbers?: boolean;
} & ComponentProps<"code">;
export const Highlight: ParentComponent<Props> = (_props) => {
const props = mergeProps({ language: "javascript" }, _props);
const [, rest] = splitProps(props, [
"language",
"children",
"class",
"innerHTML",
]);
const languageClass = createMemo(() => `language-${props.language}`);
const highlightedCode = createMemo<string | undefined>(() => {
const childrenString = props.children?.toString();
if (!childrenString) return;
const grammar = Prismjs.languages[props.language];
if (!grammar) return;
const result = Prismjs.highlight(childrenString, grammar, props.language);
return result;
});
createEffect(
on([languageClass, highlightedCode], () => {
Prismjs.highlightAll();
}),
);
const handleCopyToClipboard = () => {
if (props.innerHTML) {
navigator.clipboard.writeText(props.innerHTML);
} else if (props.children) {
navigator.clipboard.writeText(props.children.toString());
}
toast.success("Copié dans le presse-papier", {
duration: 2000,
position: "top-right",
});
};
return (
<div class={clsx("group flex items-start px-4 py-2 w-full", props.class)}>
<button
class="absolute cursor-pointer z-10 top-2 right-2 text-slate-500 bg-slate-200/10 rounded-md hover:bg-linear-to-r hover:from-violet-400/30 hover:via-violet-400 hover:to-violet-400/30 p-px hover:text-violet-300"
type="button"
onClick={handleCopyToClipboard}
>
<span
class={clsx(
props.dark ? "hover:bg-slate-800" : "hover:bg-white",
"p-2 block rounded-md",
)}
>
<span class="sr-only">Copier l'extrait de code</span>
<Icon path={clipboard} class="w-5 h-5" />
</span>
</button>
{props.withLineNumbers && props.children?.toString() && (
<div
aria-hidden="true"
class="border-r leading-6 border-slate-300/5 pr-4 font-mono text-slate-600 select-none"
>
<For
each={Array.from({
length: props.children.toString().split("\n").length,
})}
>
{(_, index) => (
<>
{(index() + 1).toString().padStart(2, "0")}
<br />
</>
)}
</For>
</div>
)}
<pre
class={clsx("not-prose h-full w-full prism-code flex", languageClass())}
>
<code
class={clsx("leading-6", props.withLineNumbers ? "px-4" : "pr-4")}
innerHTML={highlightedCode()}
{...rest}
>
{props.children}
</code>
</pre>
</div>
);
};

84
app/components/Icon.tsx Normal file
View File

@ -0,0 +1,84 @@
import type { JSX } from "solid-js";
import { InstallationIcon } from "@/icons/InstallationIcon";
import { LightbulbIcon } from "@/icons/LightbulbIcon";
import { QuestionIcon } from "@/icons/QuestionIcon";
import { PluginsIcon } from "@/icons/PluginsIcon";
import { PresetsIcon } from "@/icons/PresetsIcon";
import { ThemingIcon } from "@/icons/ThemingIcon";
import { WarningIcon } from "@/icons/WarningIcon";
import { useId } from "@/hooks/useId";
import { For } from "solid-js";
import clsx from "clsx";
const icons = {
installation: InstallationIcon,
presets: PresetsIcon,
plugins: PluginsIcon,
theming: ThemingIcon,
lightbulb: LightbulbIcon,
warning: WarningIcon,
question: QuestionIcon,
};
const iconStyles = {
blue: "[--icon-foreground:var(--color-slate-900)] [--icon-background:var(--color-white)]",
amber:
"[--icon-foreground:var(--color-amber-900)] [--icon-background:var(--color-amber-100)]",
};
export type IconColor = keyof typeof iconStyles;
export type IconProps = JSX.IntrinsicElements["svg"] & {
color?: IconColor;
icon: keyof typeof icons;
};
export function Icon(props: IconProps) {
const id = useId();
const IconComponent = icons[props.icon];
return (
<svg
{...props}
aria-hidden="true"
viewBox="0 0 32 32"
fill="none"
class={clsx(props.class, iconStyles[props.color || "blue"])}
>
<IconComponent id={id} color={props.color || "blue"} />
</svg>
);
}
const gradients = {
blue: [
{ "stop-color": "#0EA5E9" },
{ "stop-color": "#22D3EE", offset: ".527" },
{ "stop-color": "#818CF8", offset: 1 },
],
amber: [
{ "stop-color": "#FDE68A", offset: ".08" },
{ "stop-color": "#F59E0B", offset: ".837" },
],
};
type GradientProps = JSX.IntrinsicElements["radialGradient"] & {
color?: keyof typeof gradients;
};
export function Gradient(props: GradientProps) {
return (
<radialGradient
cx={0}
cy={0}
r={1}
gradientUnits="userSpaceOnUse"
{...props}
>
<For each={gradients[props.color || "blue"]}>
{(stop) => <stop {...stop} />}
</For>
</radialGradient>
);
}

23
app/components/Iframe.tsx Normal file
View File

@ -0,0 +1,23 @@
import clsx from "clsx";
type IframeProps = {
src: string;
title: string;
width?: string;
height?: string;
class?: string;
};
export default function Iframe(props: IframeProps) {
return (
<div class={clsx("max-w-full pointer-events-none w-full")}>
<iframe
src={props.src}
width={props.width}
height={props.height}
title={props.title}
class={props.class}
/>
</div>
);
}

17
app/components/Image.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { JSX } from "solid-js";
type ImageProps = JSX.IntrinsicElements["img"] & { src: string; alt: string };
export default function Image(props: ImageProps) {
const isDecorationImage = props.alt === "";
return (
<img
{...props}
src={props.src}
aria-hidden={isDecorationImage ? "true" : undefined}
alt={isDecorationImage ? undefined : props.alt}
loading="lazy"
/>
);
}

37
app/components/Link.tsx Executable file
View File

@ -0,0 +1,37 @@
import type { JSX } from "solid-js";
import { usePageContext } from "vike-solid/usePageContext";
type LinkProps = JSX.IntrinsicElements["a"] & { href: string };
export function Link(props: LinkProps) {
const { urlPathname } = usePageContext();
const isActive =
props.href === "/"
? urlPathname === props.href
: urlPathname.startsWith(props.href);
const isSameDomain = !(
props.href.startsWith("http") || props.href.startsWith("mailto")
);
const downloadExtensions = [".pdf", ".zip"];
const isDownload = downloadExtensions.some((extension) =>
props.href.endsWith(extension),
);
return (
<a
{...props}
{...(isActive && { "aria-current": "page" })}
{...(isDownload && { download: true })}
{...(!isSameDomain || isDownload
? { target: "_blank", rel: "noopener noreferrer" }
: { target: "_self" })}
>
{props.children}
</a>
);
}

42
app/components/Logo.tsx Normal file
View File

@ -0,0 +1,42 @@
import type { JSX } from "solid-js";
import { useId } from "@/hooks/useId";
function LogomarkPaths() {
const id = useId();
return (
<>
<defs>
<linearGradient
id={id}
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(12.792,-21.32,-21.32,-12.792,5.208,23.32)"
>
<stop offset="0" stop-color="rgb(43,127,255)" />
<stop offset="1" stop-color="rgb(142,81,255)" />
</linearGradient>
</defs>
<g transform="matrix(-1.76727,0,0,1.76727,49.1089,-3.53454)">
<path
d="M16.161,18.989L20.49,23.32L21.9,21.91L2.1,2.1L0.69,3.51L2.714,5.535L-4.085,11.253L-4.085,13.054L3.185,19.167L4.629,17.337L-1.61,12.165L4.397,7.219L9.588,12.412L6,16L6.01,16.01L6,16.01L6,22L18,22L18,20.83L16.161,18.989ZM14.417,17.244L16,18.83L16,20L8,20L8,16.5L10.837,13.663L14.417,17.244ZM8,4L16,4L16,7.5L13.16,10.34L14.41,11.59L18,8.01L17.99,8L18,8L18,2L6,2L6,3.17L8,5.17L8,4ZM25.294,12.164L19.071,17.34L20.542,19.164L27.788,13.075L27.788,11.274L20.597,5.22L19.158,7.075L25.294,12.164Z"
style={{ fill: `url(#${id})` }}
/>
</g>
</>
);
}
export function Logo(props: JSX.IntrinsicElements["svg"]) {
return (
<svg viewBox="0 0 58 38" {...props}>
<title>Memento Dev</title>
<LogomarkPaths />
</svg>
);
}

View File

@ -0,0 +1,130 @@
import type { NavigationSubItem } from "@/libs/navigation";
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 (
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
<path d="m9.182 13.423-1.17-1.16 3.505-3.505H3V7.065h8.517l-3.506-3.5L9.181 2.4l5.512 5.511-5.511 5.512Z" />
</svg>
);
}
type PageLinkProps = Omit<JSX.IntrinsicElements["div"], "dir" | "title"> & {
title: string;
href: string;
dir?: "previous" | "next";
};
function PageLink(props: PageLinkProps) {
const getPageCategory = () =>
navigation.find((section) => {
return section.links.some(
(link) =>
link.href === props.href ||
link.subitems.some((subitem) => subitem.href === props.href),
);
});
return (
<div {...cleanProps(props, "dir", "title", "href", "subitems")}>
<dt class="font-display text-sm font-medium text-slate-900">
{props.dir === "next" ? "Suivant" : "Précédent"}
</dt>
<dd class="mt-1">
<Link
href={props.href}
class={clsx(
"flex items-center gap-x-2 text-base font-semibold text-slate-500 hover:text-slate-600",
props.dir === "previous" && "flex-row-reverse",
)}
>
<p class="flex flex-col gap-0">
{getPageCategory() && (
<span class="text-violet-600 text-sm mb-1 leading-3">
{getPageCategory()?.title}
</span>
)}
<span class="leading-4">{props.title}</span>
</p>
<ArrowIcon
class={clsx(
"h-6 w-6 flex-none fill-current",
props.dir === "previous" && "-scale-x-100",
)}
/>
</Link>
</dd>
</div>
);
}
export function PrevNextLinks() {
const pageContext = usePageContext();
const allLinks = navigation
.sort((a, b) => {
// positions order (for sorting):
// 1. start
// 2. auto | undefined
// 3. end
if (a.position === "start" && b.position !== "start") return -1;
if (a.position !== "start" && b.position === "start") return 1;
if (a.position === "end" && b.position !== "end") return 1;
if (a.position !== "end" && b.position === "end") return -1;
if (a.position === "auto" && b.position !== "auto") return -1;
if (a.position !== "auto" && b.position === "auto") return 1;
if (a.position === undefined && b.position !== undefined) return -1;
if (a.position !== undefined && b.position === undefined) return 1;
return 0;
})
.flatMap((section) => section.links)
.flatMap((link) => {
return link.subitems ? [link, ...link.subitems] : link;
});
const getNeighboringLinks = () => {
const linkIndex = allLinks.findIndex(
(link) => link.href === pageContext.urlPathname,
);
if (linkIndex === -1) return [null, null];
const previousPage = allLinks[linkIndex - 1] || null;
let nextPage = allLinks[linkIndex + 1] || null;
if (nextPage?.href === pageContext.urlPathname) {
nextPage = allLinks[linkIndex + 2] || null;
}
return [previousPage, nextPage];
};
if (getNeighboringLinks().length === 0) return null;
return (
<dl class="mt-12 flex gap-4 border-t border-slate-200 pt-6">
{getNeighboringLinks()[0] && (
<PageLink
dir="previous"
{...(getNeighboringLinks()[0] as NavigationSubItem)}
/>
)}
{getNeighboringLinks()[1] && (
<PageLink
class="ml-auto text-right"
{...(getNeighboringLinks()[1] as NavigationSubItem)}
/>
)}
</dl>
);
}

32
app/components/Prose.tsx Normal file
View File

@ -0,0 +1,32 @@
import type { JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import clsx from "clsx";
type ProseProps = JSX.IntrinsicElements["div"] & {
class?: string;
as?: keyof JSX.IntrinsicElements;
};
export function Prose(props: ProseProps) {
const Component = props.as ?? "div";
return (
<Dynamic
component={Component}
class={clsx(
props.class,
"prose max-w-none prose-slate",
// headings
"prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-normal lg:prose-headings:scroll-mt-[8.5rem]",
// lead
"prose-lead:text-slate-500",
// links
"prose-a:font-semibold",
// link underline
"prose-a:no-underline prose-a:shadow-[inset_0_-2px_0_0_var(--tw-prose-background,#fff),inset_0_calc(-1*(var(--tw-prose-underline-size,4px)+2px))_0_0_var(--tw-prose-underline,var(--color-violet-300))] prose-a:hover:[--tw-prose-underline-size:6px]",
)}
{...props}
/>
);
}

View File

@ -0,0 +1,42 @@
import type { JSXElement } from "solid-js";
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { Link } from "./Link";
type QuickLinksProps = {
children: JSXElement;
};
export default function QuickLinks(props: QuickLinksProps) {
return (
<div class="not-prose my-12 grid grid-cols-1 gap-6 sm:grid-cols-2">
{props.children}
</div>
);
}
type QuickLinkProps = {
title: string;
description: string;
href: string;
icon: IconProps["icon"];
};
QuickLinks.QuickLink = (props: QuickLinkProps) => (
<div class="group relative rounded-xl border border-slate-200">
<div class="absolute -inset-px rounded-xl border-2 border-transparent opacity-0 [background:linear-gradient(var(--quick-links-hover-bg,var(--color-violet-50)),var(--quick-links-hover-bg,var(--color-violet-50)))_padding-box,linear-gradient(to_top,var(--color-indigo-400),var(--color-cyan-400),var(--color-violet-500))_border-box] group-hover:opacity-100" />
<div class="relative overflow-hidden rounded-xl p-6">
<Icon icon={props.icon} color="blue" class="h-8 w-8" />
<h2 class="mt-4 font-display text-base text-slate-900">
<Link href={props.href}>
<span class="absolute -inset-px rounded-xl" />
{props.title}
</Link>
</h2>
<p class="mt-1 text-sm text-slate-700">{props.description}</p>
</div>
</div>
);

351
app/components/Search.tsx Normal file
View File

@ -0,0 +1,351 @@
import type { SearchResult } from "@/services/FlexSearchService";
import type { JSX, Accessor, Setter } from "solid-js";
import {
createContext,
useContext,
For,
createEffect,
createSignal,
} from "solid-js";
import { Highlighter } from "solid-highlight-words";
import { useDebounce } from "@/hooks/useDebounce";
import { Dialog, DialogPanel } from "terracotta";
import { navigation } from "@/libs/navigation";
import { navigate } from "vike/client/router";
import { useId } from "@/hooks/useId";
import clsx from "clsx";
const SearchContext = createContext<{
query: Accessor<string>;
close: () => void;
results: Accessor<SearchResult[]>;
isLoading: Accessor<boolean>;
isOpened: Accessor<boolean>;
setQuery: Setter<string>;
setIsOpened: Setter<boolean>;
setIsLoading: Setter<boolean>;
setResults: Setter<SearchResult[]>;
}>({
query: () => "",
close: () => {},
results: () => [],
isLoading: () => false,
isOpened: () => false,
setQuery: () => {},
setIsOpened: () => {},
setIsLoading: () => {},
setResults: () => {},
});
function SearchIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
</svg>
);
}
function LoadingIcon(props: JSX.IntrinsicElements["svg"]) {
const id = useId();
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<circle cx="10" cy="10" r="5.5" stroke-linejoin="round" />
<path
stroke={`url(#${id})`}
stroke-linecap="round"
stroke-linejoin="round"
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
/>
<defs>
<linearGradient
id={id}
x1="13"
x2="9.5"
y1="9"
y2="15"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="currentColor" />
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
);
}
function SearchInput() {
const { close, setQuery, query, isLoading } = useContext(SearchContext);
return (
<div class="group relative flex h-12">
<SearchIcon class="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400" />
<input
data-autofocus
class={clsx(
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
isLoading() ? "pr-11" : "pr-4",
)}
onKeyDown={(event) => {
if (event.key === "Escape") {
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
close();
}
}}
value={query()}
onInput={(event) => {
const { value } = event.currentTarget;
setQuery(value);
}}
/>
{isLoading() && (
<div class="absolute inset-y-0 right-3 flex items-center">
<LoadingIcon class="h-6 w-6 animate-spin stroke-slate-200 text-slate-400" />
</div>
)}
</div>
);
}
function HighlightQuery(props: { text: string; query: string }) {
return (
<Highlighter
highlightClass="group-aria-selected:underline bg-transparent text-violet-600"
searchWords={[props.query]}
autoEscape={true}
textToHighlight={props.text}
/>
);
}
function SearchResultItem(props: { result: SearchResult; query: string }) {
const { close } = useContext(SearchContext);
const id = useId();
const getHierarchy = (): string[] => {
const sectionTitle = navigation.find((section) => {
return section.links.find(
(link) => link.href === props.result.url.split("#")[0],
);
})?.title;
return [sectionTitle, props.result.pageTitle].filter(
(x): x is string => typeof x === "string",
);
};
return (
<li
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
aria-labelledby={`${id}-hierarchy ${id}-title`}
tab-index={0}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigate(props.result.url);
close();
}
}}
onClick={() => {
navigate(props.result.url);
close();
}}
>
<div
id={`${id}-title`}
aria-hidden="true"
class="text-sm text-slate-700 group-aria-selected:text-violet-600"
>
<HighlightQuery text={props.result.title} query={props.query} />
</div>
{getHierarchy().length > 0 && (
<div
id={`${id}-hierarchy`}
aria-hidden="true"
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
>
<For each={getHierarchy()}>
{(item, itemIndex) => (
<>
<HighlightQuery text={item} query={props.query} />
<span
class={
itemIndex() === getHierarchy().length - 1
? "sr-only"
: "mx-2 text-slate-300 dark:text-slate-700"
}
>
/
</span>
</>
)}
</For>
</div>
)}
</li>
);
}
function SearchResults() {
const { results, query } = useContext(SearchContext);
if (results().length === 0) {
return (
<p class="px-4 py-8 text-center text-sm text-slate-700">
Aucun résultat pour &ldquo;
<span class="break-words text-slate-900">{query()}</span>
&rdquo;
</p>
);
}
return (
<ul>
<For each={results()}>
{(result) => (
<li>
<SearchResultItem result={result} query={query()} />
</li>
)}
</For>
</ul>
);
}
function SearchDialog(props: { class?: string }) {
const { close, isOpened, setIsOpened, results } = useContext(SearchContext);
createEffect(() => {
if (isOpened()) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setIsOpened(true);
}
}
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [isOpened, setIsOpened]);
const handleClickOutside = (event: MouseEvent) => {
const { target, currentTarget } = event;
if (target instanceof Node && currentTarget instanceof Node) {
if (target === currentTarget) close();
}
};
return (
<>
<Dialog
isOpen={isOpened()}
onClose={close}
class={clsx("fixed inset-0 z-50", props.class)}
>
<div class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
<div
class="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]"
onClick={handleClickOutside}
onKeyDown={(event) => {
if (event.key === "Escape") close();
}}
>
<DialogPanel class="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl">
<form onSubmit={(event) => event.preventDefault()}>
<SearchInput />
<div class="border-t border-slate-200 bg-white px-2 py-3 empty:hidden">
{results().length > 0 && <SearchResults />}
</div>
</form>
</DialogPanel>
</div>
</Dialog>
</>
);
}
export function Search() {
const [results, setResults] = createSignal<SearchResult[]>([]);
const [modifierKey, setModifierKey] = createSignal<string>();
const [isLoading, setIsLoading] = createSignal(false);
const [isOpened, setIsOpened] = createSignal(false);
const [query, setQuery] = createSignal("");
const debouncedQuery = useDebounce(query, 300);
const onSearch = async (query: string) => {
const response = await fetch(`/search?query=${query}`);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
};
createEffect(() => {
const platform = navigator.userAgentData?.platform || navigator.platform;
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl ");
}, []);
createEffect(() => {
const query = debouncedQuery();
if (query.length === 0) {
setIsLoading(false);
setResults([]);
return;
}
setIsLoading(true);
onSearch(query)
.then(setResults)
.finally(() => setIsLoading(false));
});
return (
<SearchContext.Provider
value={{
query,
close: () => setIsOpened(false),
results,
isLoading,
isOpened,
setQuery,
setIsOpened,
setIsLoading,
setResults,
}}
>
<button
type="button"
class="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96"
onClick={() => setIsOpened(true)}
>
<SearchIcon class="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400" />
<span class="sr-only md:not-sr-only md:ml-2 md:text-slate-500">
Rechercher...
</span>
{modifierKey && (
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
<kbd class="font-sans">{modifierKey()}</kbd>
<kbd class="font-sans">K</kbd>
</kbd>
)}
</button>
<SearchDialog />
</SearchContext.Provider>
);
}

View File

@ -0,0 +1,114 @@
import type { JSX } from "solid-js";
import { createSignal, onCleanup } from "solid-js";
type SmoothScrollProps = JSX.IntrinsicElements["div"] & {
children: JSX.Element;
};
export function SmoothScroll(props: SmoothScrollProps) {
const [isScrolling, setIsScrolling] = createSignal(false);
let animationFrameId: number | null = null;
const easeOutQuad = (t: number, b: number, c: number, d: number) => {
const time = t / d;
return -c * time * (time - 2) + b;
};
const smoothScroll = (deltaY: number) => {
const scrollSpeed = 3;
const currentScroll = window.scrollY;
const targetScroll = deltaY * scrollSpeed;
const duration = 300;
const startTime = performance.now();
const animateScroll = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const ease = easeOutQuad(
elapsedTime,
currentScroll,
targetScroll,
duration,
);
window.scrollTo(0, ease);
if (elapsedTime < duration) {
animationFrameId = requestAnimationFrame(animateScroll);
} else {
setIsScrolling(false);
}
};
animationFrameId = requestAnimationFrame(animateScroll);
};
const isMobile = () => {
const regex =
/Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
return regex.test(navigator.userAgent);
};
const isElementScrollable = (element: HTMLElement) => {
if (!element) return false;
return (
element.scrollHeight > element.clientHeight && element.tagName !== "HTML"
);
};
const findScrollableParent = (element: HTMLElement) => {
let currentElement: HTMLElement | null = element;
while (currentElement) {
if (isElementScrollable(currentElement)) {
return currentElement;
}
currentElement = currentElement.parentElement;
}
return null;
};
const handleWheel = (event: WheelEvent) => {
if (isMobile()) return;
const hoveredElement = document.elementFromPoint(
event.clientX,
event.clientY,
) as HTMLElement;
if (findScrollableParent(hoveredElement)) {
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
return;
}
event.preventDefault();
event.stopPropagation();
if (isScrolling()) {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
}
requestAnimationFrame(() => {
smoothScroll(event.deltaY);
setIsScrolling(false);
});
setIsScrolling(true);
};
onCleanup(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
});
return (
<div {...props} onWheel={handleWheel} style={{ "touch-action": "auto" }}>
{props.children}
</div>
);
}

156
app/components/Snippet.tsx Normal file
View File

@ -0,0 +1,156 @@
import type { JSX } from "solid-js";
import { For, createSignal } from "solid-js";
import { Highlight } from "./Highlight";
import clsx from "clsx";
function TrafficLightsIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg aria-hidden="true" viewBox="0 0 42 10" fill="none" {...props}>
<circle cx="5" cy="5" r="4.5" class="fill-red-400" />
<circle cx="21" cy="5" r="4.5" class="fill-amber-300" />
<circle cx="37" cy="5" r="4.5" class="fill-green-500" />
</svg>
);
}
type SnippetTab = {
name: string;
codeLanguage: string;
code: string;
withLineNumbers?: boolean;
children?: never;
};
type CommonTab = {
name: string;
children: JSX.Element;
codeLanguage?: never;
code?: never;
withLineNumbers?: never;
};
type SnippetProps = {
children?: JSX.Element;
class?: string;
snippets: (SnippetTab | CommonTab)[];
dark?: boolean;
};
export function Snippet(props: SnippetProps) {
let tabs: HTMLDivElement | undefined;
let nav: HTMLDivElement | undefined;
const [selectedTab, setSelectedTab] = createSignal<SnippetTab | CommonTab>(
props.snippets[0],
);
const isActive = (tab: SnippetTab | CommonTab) => {
return selectedTab()?.name === tab.name;
};
const selectTab = (name: string) => {
const tab = props.snippets.find((tab) => tab.name === name);
if (tab) setSelectedTab(tab);
if (!tabs || !nav) return;
const navWidth = nav.offsetWidth || 0;
const tabsWidth = tabs.scrollWidth;
if (tabsWidth > navWidth) {
const tabElement: HTMLDivElement | null = tabs.querySelector(
`div[data-tab="${name}"]`,
);
if (!tabElement) return;
const tabOffsetLeft = tabElement.offsetLeft;
const tabWidth = tabElement.offsetWidth;
const scrollLeft = Math.max(
0,
tabOffsetLeft - navWidth / 2 + tabWidth / 2,
);
nav.scrollTo({ left: scrollLeft, behavior: "smooth" });
}
};
const canBeSelected = (tab: SnippetTab | CommonTab) => {
return (tab.code || tab.children) !== undefined;
};
return (
<div
class={clsx(
"relative rounded-2xl ring-1 ring-white/10 backdrop-blur-sm",
props.dark ? "bg-[#0A101F]/80" : "bg-slate-50",
props.class,
)}
>
<div class="absolute -top-px right-11 left-20 h-px bg-linear-to-r from-violet-300/0 via-violet-300/70 to-violet-300/0" />
<div class="absolute right-20 -bottom-px left-11 h-px bg-linear-to-r from-purple-400/0 via-purple-400 to-purple-400/0" />
<div class="pt-4 pl-4">
<TrafficLightsIcon class="h-2.5 w-auto stroke-slate-500/30" />
<nav ref={nav} class="overflow-x-auto">
<div ref={tabs} class="mt-4 flex space-x-2 text-xs w-max mb-2">
<For each={props.snippets}>
{(tab) => (
<div
data-tab={tab.name}
class={clsx(
"flex h-6 rounded-full",
{ "cursor-pointer": canBeSelected(tab) && !isActive(tab) },
isActive(tab)
? clsx(
"bg-linear-to-r from-violet-400/30 via-violet-400 to-violet-400/30 p-px font-medium",
props.dark ? "text-violet-300" : "text-violet-600",
)
: props.dark
? "text-slate-400"
: "text-slate-500",
)}
>
<button
type="button"
class={clsx(
"flex items-center rounded-full px-2.5",
isActive(tab) && {
"bg-slate-800": props.dark,
"bg-violet-100": !props.dark,
},
)}
disabled={!canBeSelected(tab)}
onClick={() => selectTab(tab.name)}
>
{tab.name}
</button>
</div>
)}
</For>
</div>
</nav>
{selectedTab() && (
<div class="mt-6">
{selectedTab().code && (
<Highlight
class={clsx(
"!pt-0 !px-1 max-h-96 overflow-auto mb-2",
props.dark && "dark text-white",
)}
language={(selectedTab() as SnippetTab).codeLanguage}
withLineNumbers={(selectedTab() as SnippetTab).withLineNumbers}
>
{(selectedTab() as SnippetTab).code}
</Highlight>
)}
{!selectedTab().code && (
<div class="pb-1">{(selectedTab() as CommonTab).children}</div>
)}
</div>
)}
</div>
</div>
);
}

131
app/components/Tabs.tsx Normal file
View File

@ -0,0 +1,131 @@
import type { JSX, Accessor, Setter } from "solid-js";
import {
createContext,
useContext,
createSignal,
onMount,
For,
} from "solid-js";
import { Button } from "@/components/Button";
import clsx from "clsx";
type TabType = {
label: string;
value: string;
};
type TabsContextType = {
selectedTab: Accessor<string>;
setSelectedTab: Setter<string>;
tabs: Accessor<TabType[]>;
addTab: (tab: TabType) => void;
};
const TabsContext = createContext<TabsContextType>({
selectedTab: () => "",
setSelectedTab: () => {},
tabs: () => [],
addTab: () => {},
});
export default function Tabs(props: {
defaultSelectedTab?: string;
children: JSX.Element;
}) {
const [selectedTab, setSelectedTab] = createSignal(
props.defaultSelectedTab || "",
);
const [tabs, setTabs] = createSignal<TabType[]>([]);
const addTab = (tab: TabType) => {
console.log("Adding tab", tab);
setTabs((prevTabs) => {
// Append to the end of the array and make sure it's unique
if (prevTabs.some((t) => t.value === tab.value)) {
return prevTabs;
}
return [...prevTabs, tab];
});
};
return (
<TabsContext.Provider
value={{
selectedTab,
setSelectedTab,
tabs,
addTab,
}}
>
<div class="relative">
<div class="max-w-full overflow-x-auto overflow-y-hidden">
<ul
class="!p-0 w-max flex items-stretch gap-1 !m-0"
aria-orientation="horizontal"
role="tablist"
>
<For each={tabs()}>
{(tab) => (
<li class="overflow-hidden">
<TabItem
tab={tab}
isSelected={selectedTab() === tab.value}
select={() => setSelectedTab(tab.value)}
/>
</li>
)}
</For>
</ul>
</div>
<div class="-mt-1 p-2">{props.children}</div>
</div>
</TabsContext.Provider>
);
}
function TabItem(props: {
tab: TabType;
isSelected: boolean;
select: () => void;
}) {
return (
<Button
variant={props.isSelected ? "primary" : "secondary"}
class={clsx("!rounded-md", props.isSelected && "cursor-default")}
onClick={props.select}
>
{props.tab.label}
</Button>
);
}
Tabs.Item = (props: {
label: string;
value: string;
children: JSX.Element;
}) => {
const tabsContext = useContext(TabsContext);
if (!tabsContext) {
throw new Error("Tabs.Item must be used within Tabs");
}
onMount(() => {
console.log("Mounting tab", props.label);
tabsContext.addTab({ label: props.label, value: props.value });
});
return (
<div
class={clsx(
"first:!mt-0",
"last:!mb-0",
tabsContext.selectedTab() !== props.value && "hidden",
)}
>
{props.children}
</div>
);
};

View File

@ -1,25 +0,0 @@
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" };
}

View File

@ -1,192 +0,0 @@
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 (
<CookiesContext.Provider
value={{
cookies: consentCookies,
setCookie: handleUpdateCookie,
setAllCookies: handleSetAll,
isOpen,
setIsOpen,
isSelectionOpen,
setIsSelectionOpen,
}}
>
{props.children}
{isSelectionOpen && <CookieChoices />}
{isOpen && <CookieModal />}
</CookiesContext.Provider>
);
}
function CookieChoices() {
const cookiesContext = useContext(CookiesContext);
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={() => cookiesContext.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&lsquo;analyse (Umami et Google Analytics)"
checked={cookiesContext.cookies.analytics}
onChange={(checked) => cookiesContext.setCookie("analytics", checked)}
/>
<Toggle
id="cookies-customization"
label="Cookie de personnalisation (thème)"
checked={cookiesContext.cookies.customization}
onChange={(checked) => cookiesContext.setCookie("customization", checked)}
/>
</div>
</div>
</div>
);
}
function CookieModal() {
const cookiesContext = useContext(CookiesContext);
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={() => cookiesContext.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&apos;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&lsquo;embête pas longtemps, on te laisse même le choix <em>(si ça c&lsquo;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={() => cookiesContext.setAllCookies(false)}
>
Non merci
</button>
<button
className="cursor-pointer px-2 py-1 text-slate-600 dark:text-slate-300"
onClick={() => cookiesContext.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={() => cookiesContext.setAllCookies(true)}
>
Oui, j&lsquo;ai faim !
</button>
</div>
</div>
);
}

View File

@ -1,20 +0,0 @@
import React from "react";
import clsx from "clsx";
type IframeProps = {
src: string;
width?: string;
height?: string;
className?: string;
};
export function Iframe(props: IframeProps) {
return (
<iframe
src={props.src}
className={clsx("max-w-full pointer-events-none", props.className)}
width={props.width}
height={props.height}
/>
);
}

View File

@ -1,5 +0,0 @@
import React from "react";
export function Image(props: { src: string; alt: string } & React.ComponentPropsWithoutRef<"img">) {
return <img {...props} src={props.src} alt={props.alt} loading="lazy" />;
}

View File

@ -1,26 +0,0 @@
import { usePageContext } from "vike-react/usePageContext";
import { prefetch } from "vike/client/router";
import React from "react";
import clsx from "clsx";
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; className?: string }) {
const { urlPathname } = usePageContext();
const isActive = props.href === "/" ? urlPathname === props.href : urlPathname.startsWith(props.href);
const isSameDomain = !(props.href.startsWith("http") || props.href.startsWith("mailto"));
const isDownload = props.href.endsWith(".pdf") || props.href.endsWith(".zip");
const handleMouseEnter = () => prefetch(props.href);
return (
<a
{...props}
href={props.href}
className={clsx(isActive && "is-active", props.className)}
{...(isDownload ? { download: true } : {})}
{...(!isSameDomain || isDownload ? { target: "_blank", rel: "noopener noreferrer" } : {})}
{...(isSameDomain ? { onMouseEnter: handleMouseEnter } : {})}
>
{props.children}
</a>
);
}

View File

@ -1,45 +0,0 @@
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 (
<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>
);
}

View File

@ -1,95 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import React, { createContext, useContext, useEffect, useState } from "react";
import { Button } from "@syntax/Button";
import clsx from "clsx";
type TabType = {
label: string;
value: string;
};
type TabsContextType = {
selectedTab: string;
selectTab: Dispatch<SetStateAction<string>>;
tabs: TabType[];
addTab: (tab: TabType) => void;
};
const TabsContext = createContext<TabsContextType>({
selectedTab: "",
selectTab: () => {},
tabs: [],
addTab: () => {},
});
export function Tabs({
defaultSelectedTab = "",
children,
}: {
defaultSelectedTab?: string;
children: React.ReactNode;
}) {
const [selectedTab, selectTab] = useState(defaultSelectedTab);
const [tabs, setTabs] = useState<TabType[]>([]);
const addTab = (tab: TabType) =>
setTabs((prevTabs) => {
// Append to the end of the array and make sure it's unique
if (prevTabs.some((t) => t.value === tab.value)) {
return prevTabs;
}
return [...prevTabs, tab];
});
return (
<TabsContext.Provider
value={{
selectedTab,
selectTab,
tabs,
addTab,
}}
>
<div className="relative">
<div className="max-w-full overflow-x-auto overflow-y-hidden">
<ul className="!p-0 w-max flex items-stretch gap-1 !m-0" aria-orientation="horizontal" role="tablist">
{tabs.map((tab) => (
<li key={tab.value} className="overflow-hidden" role="tab" aria-selected={selectedTab === tab.value}>
<TabItem tab={tab} isSelected={selectedTab === tab.value} select={() => selectTab(tab.value)} />
</li>
))}
</ul>
</div>
<div className="-mt-1 p-2">{children}</div>
</div>
</TabsContext.Provider>
);
}
export function TabItem({ tab, isSelected, select }: { tab: TabType; isSelected: boolean; select: () => void }) {
return (
<Button
variant={isSelected ? "primary" : "secondary"}
className={clsx("!rounded-md", isSelected && "cursor-default")}
onClick={select}
>
{tab.label}
</Button>
);
}
export function TabContent({ label, value, children }: { label: string; value: string; children: React.ReactNode }) {
const { addTab, selectedTab } = useContext(TabsContext);
useEffect(() => {
addTab({ label, value });
}, []);
return (
<div className={clsx("[&>*:first-of-type]:!mt-0", "[&>*:last-of-type]:!mb-0", selectedTab !== value && "hidden")}>
{children}
</div>
);
}

View File

@ -1,33 +0,0 @@
import { Link } from "@/components/common/Link";
import React from "react";
import clsx from "clsx";
const variantStyles = {
primary:
"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 = {
sm: "rounded-md py-1 px-2 text-xs",
md: "rounded-full py-2 px-4 text-sm",
lg: "rounded-full py-3 px-6 text-base",
};
type ButtonProps = {
variant?: keyof typeof variantStyles;
size?: keyof typeof sizeStyles;
} & (React.ComponentPropsWithoutRef<typeof Link> | (React.ComponentPropsWithoutRef<"button"> & { href?: undefined }));
export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
className = clsx(variantStyles[variant], sizeStyles[size], "cursor-pointer", className);
return typeof props.href === "undefined" ? (
<button className={className} {...props} />
) : (
<Link className={className} {...props} />
);
}

View File

@ -1,82 +0,0 @@
import { ClipboardDocumentIcon } from "@heroicons/react/24/outline";
import { prismThemes } from "@/data/themes/prism";
import React, { Fragment, useMemo } from "react";
import { Highlight } from "prism-react-renderer";
import { useTheme } from "@/hooks/useTheme";
import { toast } from "react-toastify";
import { Button } from "./Button";
import Prism from "prismjs";
import clsx from "clsx";
export default function CSRSnippet({
children,
language,
label,
showLineNumbers = false,
}: {
children: string;
language: string;
label?: string;
showLineNumbers?: boolean;
}) {
const { theme } = useTheme();
const prismTheme = useMemo(() => {
return prismThemes[theme];
}, [theme]);
const copyToClipboard = () => {
navigator.clipboard.writeText(children.trimEnd());
toast.success("Code copié dans le presse-papier");
};
return (
<>
<Highlight code={children.trimEnd()} language={language} theme={prismTheme} prism={Prism}>
{({ className, style, tokens, getTokenProps }) => (
<div className="relative w-full">
{label && (
<div className="absolute px-4 py-1 left-0 text-sm text-gray-700 dark:text-gray-200 italic w-full bg-gray-200 dark:bg-gray-700 rounded-t-xl">
{label}
</div>
)}
<pre className={clsx(className, { "pt-11": label })} style={style}>
<code>
{tokens.map((line, lineIndex) => (
<Fragment key={lineIndex}>
{showLineNumbers && (
<span
className="text-gray-400 dark:text-gray-500 text-right font-mono w-8 inline-block pr-4"
style={{ userSelect: "none" }}
>
{lineIndex + 1}
</span>
)}
{line
.filter((token) => !token.empty)
.map((token, tokenIndex) => (
<span key={tokenIndex} {...getTokenProps({ token })} />
))}
{"\n"}
</Fragment>
))}
</code>
</pre>
</div>
)}
</Highlight>
<Button
className={clsx(
"absolute right-2 w-8 h-8 aspect-square opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity",
label ? "top-10" : "top-2",
)}
size="sm"
variant="secondary"
onClick={copyToClipboard}
>
<ClipboardDocumentIcon className="w-full" />
</Button>
</>
);
}

View File

@ -1,55 +0,0 @@
import { Icon } from "@syntax/Icon";
import React from "react";
import clsx from "clsx";
const styles = {
note: {
container: "bg-violet-50 dark:bg-violet-800/60 dark:ring-1 dark:ring-violet-300/10",
title: "text-violet-900 dark:text-violet-400",
body: "text-slate-800 [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900 dark:text-slate-300 dark:prose-code:text-slate-300",
},
warning: {
container: "bg-amber-50 dark:bg-amber-800/60 dark:ring-1 dark:ring-amber-300/10",
title: "text-amber-900 dark:text-amber-500",
body: "text-slate-800 [--tw-prose-underline:var(--color-slate-400)] [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900 dark:text-slate-300 dark:[--tw-prose-underline:var(--color-slate-700)] dark:prose-code:text-slate-300",
},
question: {
container: "bg-amber-50 dark:bg-amber-800/60 dark:ring-1 dark:ring-amber-300/10",
title: "text-amber-900 dark:text-amber-500",
body: "text-slate-800 [--tw-prose-underline:var(--color-slate-400)] [--tw-prose-background:var(--color-slate-50)] prose-a:text-slate-900 prose-code:text-slate-900 dark:text-slate-300 dark:[--tw-prose-underline:var(--color-slate-700)] dark:prose-code:text-slate-300",
},
};
const icons = {
note: (props: { className?: string }) => <Icon icon="lightbulb" {...props} />,
warning: (props: { className?: string }) => <Icon icon="warning" color="amber" {...props} />,
question: (props: { className?: string }) => <Icon icon="question" color="blue" {...props} />,
};
export function Callout({
title,
children,
type = "note",
collapsible = false,
}: {
title: string;
children: React.ReactNode;
type?: keyof typeof styles;
collapsible?: boolean;
}) {
const IconComponent = icons[type];
return (
<div
className={clsx("my-8 flex flex-col rounded-3xl p-6", styles[type].container, { "cursor-pointer": collapsible })}
>
<div className="flex items-center gap-6">
<IconComponent className="h-8 w-8 flex-none" />
<p className={clsx("!m-0 font-display text-xl text-balance", styles[type].title)}>{title}</p>
</div>
<div className="mt-4 flex-auto">
<div className={clsx("prose mt-2.5", styles[type].body)}>{children}</div>
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
import { usePageContext } from "vike-react/usePageContext";
import { ClockIcon } from "@heroicons/react/24/outline";
import { navigation } from "@/lib/navigation";
import React from "react";
type DocsHeaderProps = {
title?: string;
estimatedReadingTime?: string;
};
export function DocsHeader(props: DocsHeaderProps) {
const { urlPathname } = usePageContext();
const section = navigation.find((section) => section.links.find((link) => link.href === urlPathname));
if (!props.title && !section) {
return null;
}
return (
<header className="mb-9 space-y-1">
{section && <p className="font-display text-sm font-medium text-violet-500">{section.title}</p>}
{props.title && (
<h1 className="font-display text-3xl tracking-tight text-slate-900 dark:text-white">{props.title}</h1>
)}
{props.estimatedReadingTime && (
<p className="text-sm text-slate-500 dark:text-slate-400 inline-flex items-center gap-1">
<ClockIcon className="w-4" /> {props.estimatedReadingTime}
</p>
)}
</header>
);
}

View File

@ -1,36 +0,0 @@
import { type Node } from "@markdoc/markdoc";
import { TableOfContents } from "@syntax/TableOfContents";
import { PrevNextLinks } from "@syntax/PrevNextLinks";
import { collectSections } from "@/lib/sections";
import { DocsHeader } from "@syntax/DocsHeader";
import { Prose } from "@syntax/Prose";
import React from "react";
export function DocsLayout({
children,
frontmatter: { title },
estimatedReadingTime,
nodes,
}: {
children: React.ReactNode;
frontmatter: { title?: string };
estimatedReadingTime?: string;
nodes: Array<Node>;
}) {
const tableOfContents = collectSections(nodes);
return (
<>
<div className="max-w-2xl min-w-0 flex-auto px-4 py-16 lg:max-w-none lg:pr-0 lg:pl-8 xl:px-16 grow">
<article>
<DocsHeader title={title} estimatedReadingTime={estimatedReadingTime} />
<Prose>{children}</Prose>
</article>
<PrevNextLinks />
</div>
<TableOfContents tableOfContents={tableOfContents} />
</>
);
}

View File

@ -1,20 +0,0 @@
import { clientOnly } from "vike-react/clientOnly";
import { SSRSnippet } from "./SSRSnippet";
import React from "react";
const CSRSnippet = clientOnly(() => import("./CSRSnippet"));
export function Fence({ children, language }: { children: string; language: string }) {
const props = {
language,
label: undefined,
showLineNumbers: false,
children,
};
return (
<div className="relative group">
<CSRSnippet {...props} fallback={<SSRSnippet {...props} />} />
</div>
);
}

View File

@ -1,132 +0,0 @@
import { HeroBackground } from "@syntax/HeroBackground";
import blurIndigoImage from "@/images/blur-indigo.webp";
import blurCyanImage from "@/images/blur-cyan.webp";
import { Image } from "@/components/common/Image";
import { Highlight } from "prism-react-renderer";
import React, { Fragment } from "react";
import { Button } from "@syntax/Button";
import clsx from "clsx";
const codeLanguage = "javascript";
const code = `export default {
role: 'developer',
qualifications: [
'DWWM',
'CDA',
'CDUI',
]
}`;
const tabs = [
{ name: "memento-dev.config.js", isActive: true },
{ name: "package.json", isActive: false },
];
function TrafficLightsIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 42 10" fill="none" {...props}>
<circle cx="5" cy="5" r="4.5" />
<circle cx="21" cy="5" r="4.5" />
<circle cx="37" cy="5" r="4.5" />
</svg>
);
}
export function Hero() {
return (
<div className="overflow-hidden bg-slate-900 dark:mt-[-4.75rem] dark:-mb-32 dark:pt-[4.75rem] dark:pb-32">
<div className="py-16 sm:px-2 lg:relative lg:px-0 lg:py-20">
<div className="mx-auto grid max-w-2xl w-full grid-cols-1 items-center gap-x-8 gap-y-16 px-4 lg:max-w-8xl lg:grid-cols-2 lg:px-8 xl:gap-x-16 xl:px-12">
<div className="relative z-10 md:text-center lg:text-left">
<Image
className="absolute right-full bottom-full -mr-72 -mb-56 opacity-50"
src={blurCyanImage}
alt=""
width={530}
height={530}
/>
<div className="relative">
<p className="inline bg-linear-to-r from-indigo-200 via-violet-400 to-indigo-200 bg-clip-text font-display text-5xl tracking-tight text-transparent">
Souviens-toi que tu développeras.
</p>
<p className="mt-3 text-2xl tracking-tight text-slate-400">
Découvrez des ressources essentielles pour améliorer tes compétences en développement.
</p>
<div className="mt-8 flex gap-4 md:justify-center lg:justify-start">
<Button href="/docs">Accédez aux ressources</Button>
<Button href="https://github.com/GauthierWebDev/memento-dev" variant="secondary">
Voir sur Github
</Button>
</div>
</div>
</div>
<div className="relative lg:static xl:pl-10">
<div className="absolute inset-x-[-50vw] -top-32 -bottom-48 [mask-image:linear-gradient(transparent,white,white)] lg:-top-32 lg:right-0 lg:-bottom-32 lg:left-[calc(50%+14rem)] lg:[mask-image:none] dark:[mask-image:linear-gradient(transparent,white,transparent)] lg:dark:[mask-image:linear-gradient(white,white,transparent)]">
<HeroBackground className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 lg:left-0 lg:translate-x-0 lg:translate-y-[-60%]" />
</div>
<div className="relative">
<Image className="absolute -top-64 -right-64" src={blurCyanImage} alt="" width={530} height={530} />
<Image className="absolute -right-44 -bottom-40" src={blurIndigoImage} alt="" width={567} height={567} />
<div className="absolute inset-0 rounded-2xl bg-linear-to-tr from-violet-300 via-violet-300/70 to-purple-300 opacity-10 blur-lg" />
<div className="absolute inset-0 rounded-2xl bg-linear-to-tr from-violet-300 via-violet-300/70 to-purple-300 opacity-10" />
<div className="relative rounded-2xl bg-[#0A101F]/80 ring-1 ring-white/10 backdrop-blur-sm">
<div className="absolute -top-px right-11 left-20 h-px bg-linear-to-r from-violet-300/0 via-violet-300/70 to-violet-300/0" />
<div className="absolute right-20 -bottom-px left-11 h-px bg-linear-to-r from-purple-400/0 via-purple-400 to-purple-400/0" />
<div className="pt-4 pl-4">
<TrafficLightsIcon className="h-2.5 w-auto stroke-slate-500/30" />
<div className="mt-4 flex space-x-2 text-xs">
{tabs.map((tab) => (
<div
key={tab.name}
className={clsx(
"flex h-6 rounded-full",
tab.isActive
? "bg-linear-to-r from-violet-400/30 via-violet-400 to-violet-400/30 p-px font-medium text-violet-300"
: "text-slate-500",
)}
>
<div className={clsx("flex items-center rounded-full px-2.5", tab.isActive && "bg-slate-800")}>
{tab.name}
</div>
</div>
))}
</div>
<div className="mt-6 flex items-start px-1 text-sm">
<div
aria-hidden="true"
className="border-r border-slate-300/5 pr-4 font-mono text-slate-600 select-none"
>
{Array.from({
length: code.split("\n").length,
}).map((_, index) => (
<Fragment key={index}>
{(index + 1).toString().padStart(2, "0")}
<br />
</Fragment>
))}
</div>
<Highlight code={code} language={codeLanguage} theme={{ plain: {}, styles: [] }}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre className={clsx(className, "flex overflow-x-auto pb-6")} style={style}>
<code className="px-4">
{tokens.map((line, lineIndex) => (
<div key={lineIndex} {...getLineProps({ line })}>
{line.map((token, tokenIndex) => (
<span key={tokenIndex} {...getTokenProps({ token })} />
))}
</div>
))}
</code>
</pre>
)}
</Highlight>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,121 +0,0 @@
import React, { useId } from "react";
export function HeroBackground(props: React.ComponentPropsWithoutRef<"svg">) {
const id = useId();
return (
<svg aria-hidden="true" viewBox="0 0 668 1069" width={668} height={1069} fill="none" {...props}>
<defs>
<clipPath id={`${id}-clip-path`}>
<path fill="#fff" transform="rotate(-180 334 534.4)" d="M0 0h668v1068.8H0z" />
</clipPath>
</defs>
<g opacity=".4" clipPath={`url(#${id}-clip-path)`} strokeWidth={4}>
<path
opacity=".3"
d="M584.5 770.4v-474M484.5 770.4v-474M384.5 770.4v-474M283.5 769.4v-474M183.5 768.4v-474M83.5 767.4v-474"
stroke="#334155"
/>
<path
d="M83.5 221.275v6.587a50.1 50.1 0 0 0 22.309 41.686l55.581 37.054a50.102 50.102 0 0 1 22.309 41.686v6.587M83.5 716.012v6.588a50.099 50.099 0 0 0 22.309 41.685l55.581 37.054a50.102 50.102 0 0 1 22.309 41.686v6.587M183.7 584.5v6.587a50.1 50.1 0 0 0 22.31 41.686l55.581 37.054a50.097 50.097 0 0 1 22.309 41.685v6.588M384.101 277.637v6.588a50.1 50.1 0 0 0 22.309 41.685l55.581 37.054a50.1 50.1 0 0 1 22.31 41.686v6.587M384.1 770.288v6.587a50.1 50.1 0 0 1-22.309 41.686l-55.581 37.054A50.099 50.099 0 0 0 283.9 897.3v6.588"
stroke="#334155"
/>
<path
d="M384.1 770.288v6.587a50.1 50.1 0 0 1-22.309 41.686l-55.581 37.054A50.099 50.099 0 0 0 283.9 897.3v6.588M484.3 594.937v6.587a50.1 50.1 0 0 1-22.31 41.686l-55.581 37.054A50.1 50.1 0 0 0 384.1 721.95v6.587M484.3 872.575v6.587a50.1 50.1 0 0 1-22.31 41.686l-55.581 37.054a50.098 50.098 0 0 0-22.309 41.686v6.582M584.501 663.824v39.988a50.099 50.099 0 0 1-22.31 41.685l-55.581 37.054a50.102 50.102 0 0 0-22.309 41.686v6.587M283.899 945.637v6.588a50.1 50.1 0 0 1-22.309 41.685l-55.581 37.05a50.12 50.12 0 0 0-22.31 41.69v6.59M384.1 277.637c0 19.946 12.763 37.655 31.686 43.962l137.028 45.676c18.923 6.308 31.686 24.016 31.686 43.962M183.7 463.425v30.69c0 21.564 13.799 40.709 34.257 47.529l134.457 44.819c18.922 6.307 31.686 24.016 31.686 43.962M83.5 102.288c0 19.515 13.554 36.412 32.604 40.645l235.391 52.309c19.05 4.234 32.605 21.13 32.605 40.646M83.5 463.425v-58.45M183.699 542.75V396.625M283.9 1068.8V945.637M83.5 363.225v-141.95M83.5 179.524v-77.237M83.5 60.537V0M384.1 630.425V277.637M484.301 830.824V594.937M584.5 1068.8V663.825M484.301 555.275V452.988M584.5 622.075V452.988M384.1 728.537v-56.362M384.1 1068.8v-20.88M384.1 1006.17V770.287M283.9 903.888V759.85M183.699 1066.71V891.362M83.5 1068.8V716.012M83.5 674.263V505.175"
stroke="#334155"
/>
<circle cx="83.5" cy="384.1" r="10.438" transform="rotate(-180 83.5 384.1)" fill="#1E293B" stroke="#334155" />
<circle cx="83.5" cy="200.399" r="10.438" transform="rotate(-180 83.5 200.399)" stroke="#334155" />
<circle cx="83.5" cy="81.412" r="10.438" transform="rotate(-180 83.5 81.412)" stroke="#334155" />
<circle
cx="183.699"
cy="375.75"
r="10.438"
transform="rotate(-180 183.699 375.75)"
fill="#1E293B"
stroke="#334155"
/>
<circle
cx="183.699"
cy="563.625"
r="10.438"
transform="rotate(-180 183.699 563.625)"
fill="#1E293B"
stroke="#334155"
/>
<circle cx="384.1" cy="651.3" r="10.438" transform="rotate(-180 384.1 651.3)" fill="#1E293B" stroke="#334155" />
<circle
cx="484.301"
cy="574.062"
r="10.438"
transform="rotate(-180 484.301 574.062)"
fill="#0EA5E9"
fillOpacity=".42"
stroke="#0EA5E9"
/>
<circle
cx="384.1"
cy="749.412"
r="10.438"
transform="rotate(-180 384.1 749.412)"
fill="#1E293B"
stroke="#334155"
/>
<circle cx="384.1" cy="1027.05" r="10.438" transform="rotate(-180 384.1 1027.05)" stroke="#334155" />
<circle cx="283.9" cy="924.763" r="10.438" transform="rotate(-180 283.9 924.763)" stroke="#334155" />
<circle cx="183.699" cy="870.487" r="10.438" transform="rotate(-180 183.699 870.487)" stroke="#334155" />
<circle
cx="283.9"
cy="738.975"
r="10.438"
transform="rotate(-180 283.9 738.975)"
fill="#1E293B"
stroke="#334155"
/>
<circle
cx="83.5"
cy="695.138"
r="10.438"
transform="rotate(-180 83.5 695.138)"
fill="#1E293B"
stroke="#334155"
/>
<circle
cx="83.5"
cy="484.3"
r="10.438"
transform="rotate(-180 83.5 484.3)"
fill="#0EA5E9"
fillOpacity=".42"
stroke="#0EA5E9"
/>
<circle
cx="484.301"
cy="432.112"
r="10.438"
transform="rotate(-180 484.301 432.112)"
fill="#1E293B"
stroke="#334155"
/>
<circle
cx="584.5"
cy="432.112"
r="10.438"
transform="rotate(-180 584.5 432.112)"
fill="#1E293B"
stroke="#334155"
/>
<circle
cx="584.5"
cy="642.95"
r="10.438"
transform="rotate(-180 584.5 642.95)"
fill="#1E293B"
stroke="#334155"
/>
<circle cx="484.301" cy="851.699" r="10.438" transform="rotate(-180 484.301 851.699)" stroke="#334155" />
<circle cx="384.1" cy="256.763" r="10.438" transform="rotate(-180 384.1 256.763)" stroke="#334155" />
</g>
</svg>
);
}

View File

@ -1,74 +0,0 @@
import { InstallationIcon } from "@syntax/icons/InstallationIcon";
import { LightbulbIcon } from "@syntax/icons/LightbulbIcon";
import { QuestionIcon } from "@syntax/icons/QuestionIcon";
import { PluginsIcon } from "@syntax/icons/PluginsIcon";
import { PresetsIcon } from "@syntax/icons/PresetsIcon";
import { ThemingIcon } from "@syntax/icons/ThemingIcon";
import { WarningIcon } from "@syntax/icons/WarningIcon";
import React, { useId } from "react";
import clsx from "clsx";
const icons = {
installation: InstallationIcon,
presets: PresetsIcon,
plugins: PluginsIcon,
theming: ThemingIcon,
lightbulb: LightbulbIcon,
warning: WarningIcon,
question: QuestionIcon,
};
const iconStyles = {
blue: "[--icon-foreground:var(--color-slate-900)] [--icon-background:var(--color-white)]",
amber: "[--icon-foreground:var(--color-amber-900)] [--icon-background:var(--color-amber-100)]",
};
export function Icon({
icon,
color = "blue",
className,
...props
}: {
color?: keyof typeof iconStyles;
icon: keyof typeof icons;
} & Omit<React.ComponentPropsWithoutRef<"svg">, "color">) {
const id = useId();
const IconComponent = icons[icon];
return (
<svg aria-hidden="true" viewBox="0 0 32 32" fill="none" className={clsx(className, iconStyles[color])} {...props}>
<IconComponent id={id} color={color} />
</svg>
);
}
const gradients = {
blue: [{ stopColor: "#0EA5E9" }, { stopColor: "#22D3EE", offset: ".527" }, { stopColor: "#818CF8", offset: 1 }],
amber: [
{ stopColor: "#FDE68A", offset: ".08" },
{ stopColor: "#F59E0B", offset: ".837" },
],
};
export function Gradient({
color = "blue",
...props
}: {
color?: keyof typeof gradients;
} & Omit<React.ComponentPropsWithoutRef<"radialGradient">, "color">) {
return (
<radialGradient cx={0} cy={0} r={1} gradientUnits="userSpaceOnUse" {...props}>
{gradients[color].map((stop, stopIndex) => (
<stop key={stopIndex} {...stop} />
))}
</radialGradient>
);
}
export function LightMode({ className, ...props }: React.ComponentPropsWithoutRef<"g">) {
return <g className={clsx("dark:hidden", className)} {...props} />;
}
export function DarkMode({ className, ...props }: React.ComponentPropsWithoutRef<"g">) {
return <g className={clsx("hidden dark:inline", className)} {...props} />;
}

View File

@ -1,37 +0,0 @@
import React from "react";
function LogomarkPaths() {
return (
<>
<defs>
<linearGradient
id="l"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(12.792,-21.32,-21.32,-12.792,5.208,23.32)"
>
<stop offset="0" style={{ stopColor: "rgb(43,127,255)" }} />
<stop offset="1" style={{ stopColor: "rgb(142,81,255)" }} />
</linearGradient>
</defs>
<g transform="matrix(-1.76727,0,0,1.76727,49.1089,-3.53454)">
<path
d="M16.161,18.989L20.49,23.32L21.9,21.91L2.1,2.1L0.69,3.51L2.714,5.535L-4.085,11.253L-4.085,13.054L3.185,19.167L4.629,17.337L-1.61,12.165L4.397,7.219L9.588,12.412L6,16L6.01,16.01L6,16.01L6,22L18,22L18,20.83L16.161,18.989ZM14.417,17.244L16,18.83L16,20L8,20L8,16.5L10.837,13.663L14.417,17.244ZM8,4L16,4L16,7.5L13.16,10.34L14.41,11.59L18,8.01L17.99,8L18,8L18,2L6,2L6,3.17L8,5.17L8,4ZM25.294,12.164L19.071,17.34L20.542,19.164L27.788,13.075L27.788,11.274L20.597,5.22L19.158,7.075L25.294,12.164Z"
style={{ fill: "url(#l)" }}
/>
</g>
</>
);
}
export function Logo(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 58 38" {...props}>
<LogomarkPaths />
</svg>
);
}

View File

@ -1,85 +0,0 @@
import React, { Suspense, useCallback, useEffect, useState } from "react";
import { usePageContext } from "vike-react/usePageContext";
import { Dialog, DialogPanel } from "@headlessui/react";
import { Navigation } from "@syntax/Navigation";
import { Link } from "@/components/common/Link";
import { Logo } from "@syntax/Logo";
function MenuIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" strokeWidth="2" strokeLinecap="round" {...props}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
}
function CloseIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" strokeWidth="2" strokeLinecap="round" {...props}>
<path d="M5 5l14 14M19 5l-14 14" />
</svg>
);
}
function CloseOnNavigation({ close }: { close: () => void }) {
const { urlPathname } = usePageContext();
useEffect(() => {
close();
}, [urlPathname, close]);
return null;
}
export function MobileNavigation() {
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
function onLinkClick(event: React.MouseEvent<HTMLAnchorElement>) {
const link = event.currentTarget;
if (
link.pathname + link.search + link.hash ===
window.location.pathname + window.location.search + window.location.hash
) {
close();
}
}
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className="relative"
aria-label="Ouvrir le menu de navigation"
>
<MenuIcon className="h-6 w-6 stroke-slate-500" />
</button>
<Suspense fallback={null}>
<CloseOnNavigation close={close} />
</Suspense>
<Dialog
open={isOpen}
onClose={() => close()}
className="fixed inset-0 z-50 flex items-start overflow-y-auto bg-slate-900/50 pr-10 backdrop-blur-sm lg:hidden"
aria-label="Navigation"
>
<DialogPanel className="min-h-full w-full max-w-xs bg-white px-4 pt-5 pb-12 sm:px-6 dark:bg-slate-900">
<div className="flex items-center">
<button type="button" onClick={() => close()} aria-label="Fermer le menu de navigation">
<CloseIcon className="h-6 w-6 stroke-slate-500" />
</button>
<Link href="/" className="ml-6" aria-label="Page d'accueil">
<Logo className="h-6 w-auto shrink-0" />
</Link>
</div>
<Navigation className="mt-5 px-1" onLinkClick={onLinkClick} />
</DialogPanel>
</Dialog>
</>
);
}

View File

@ -1,205 +0,0 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid";
import { usePageContext } from "vike-react/usePageContext";
import React, { useEffect, useState } from "react";
import { Link } from "@/components/common/Link";
import { navigation } from "@/lib/navigation";
import clsx from "clsx";
type NavigationItemProps = {
section: (typeof navigation)[number];
onLinkClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
function NavigationItem(props: NavigationItemProps) {
const { urlPathname } = usePageContext();
const [isOpened, setIsOpened] = useState(() => {
return props.section.links.some(
(link) => link.href === urlPathname || link.subitems?.some((subitem) => subitem.href === urlPathname),
);
});
return (
<>
<h2
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpened((prev) => !prev);
e.preventDefault();
}
}}
className={clsx(
"font-display font-medium cursor-pointer",
isOpened ? "text-violet-600 dark:text-violet-200" : "text-slate-900 dark:text-white ",
)}
onClick={() => setIsOpened((prev) => !prev)}
>
{isOpened ? (
<ChevronUpIcon className="inline-block mr-2 h-5 w-5 text-slate-400" />
) : (
<ChevronDownIcon className="inline-block mr-2 h-5 w-5 text-slate-400" />
)}
<span className="sr-only">{isOpened ? "Masquer" : "Afficher"}</span>
{props.section.title}
<span className="text-slate-400 dark:text-slate-500"> ({props.section.links.length})</span>
</h2>
{isOpened && (
<ul
role="list"
className="!mt-0 ml-2 space-y-1 border-l-2 border-slate-100 lg:mt-4 lg:space-y-2 lg:border-slate-200 dark:border-slate-800 mb-4"
>
{props.section.links.map((link) => (
<li key={link.href} className="relative">
<NavigationSubItem
link={link}
onLinkClick={props.onLinkClick}
isOpened={link.href === urlPathname || link.subitems?.some((subitem) => subitem.href === urlPathname)}
/>
</li>
))}
</ul>
)}
</>
);
}
type NavigationSubItemProps = {
link: (typeof navigation)[number]["links"][number];
onLinkClick?: React.MouseEventHandler<HTMLAnchorElement>;
isOpened?: boolean;
};
function NavigationSubItem(props: NavigationSubItemProps) {
const [isOpened, setIsOpened] = useState(props.isOpened);
const { urlPathname } = usePageContext();
useEffect(() => {
setIsOpened(
props.link.href === urlPathname || props.link.subitems?.some((subitem) => subitem.href === urlPathname),
);
}, [urlPathname, props.link]);
return (
<>
<span className="pl-2 flex cursor-pointer">
{props.link.subitems.length > 0 && (
<span
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpened((prev) => !prev);
e.preventDefault();
}
}}
onClick={() => setIsOpened((prev) => !prev)}
>
{isOpened ? (
<ChevronUpIcon className="inline-block h-5 w-5 text-slate-400" />
) : (
<ChevronDownIcon className="inline-block h-5 w-5 text-slate-400" />
)}
<span className="sr-only">{isOpened ? "Masquer" : "Afficher"}</span>
</span>
)}
<Link
href={props.link.href}
onClick={props.onLinkClick}
className={clsx(
"block pl-2 w-full before:pointer-events-none before:absolute before:-left-1 before:h-1.5 before:w-1.5 before:rounded-full",
{ "before:top-1/2 before:-translate-y-1/2": !props.link.subitems },
{ "before:top-3 before:-translate-y-1/2 font-semibold": props.link.subitems },
props.link.href !== urlPathname && "before:hidden",
isOpened
? "text-violet-500 before:bg-violet-500"
: "text-slate-500 before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300",
)}
>
{props.link.title}
{props.link.subitems.length > 0 && (
<span className="text-slate-400 dark:text-slate-500"> ({props.link.subitems.length})</span>
)}
</Link>
</span>
{props.link.subitems.length > 0 && isOpened && (
<ul
role="list"
className="ml-4 border-l-2 border-slate-100 space-y-1 lg:space-y-2 lg:border-slate-200 dark:border-slate-800 mb-4"
>
{props.link.subitems.map((subitem) => (
<li key={subitem.href} className="relative">
<Link
href={subitem.href}
onClick={props.onLinkClick}
className={clsx(
"block w-full pl-3.5 before:pointer-events-none before:absolute before:top-1/2 before:-left-1 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full",
subitem.href === urlPathname
? "font-semibold text-violet-500 before:bg-violet-500"
: "text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300",
)}
>
{subitem.title}
</Link>
</li>
))}
</ul>
)}
</>
);
}
export function Navigation({
className,
onLinkClick,
}: {
className?: string;
onLinkClick?: React.MouseEventHandler<HTMLAnchorElement>;
}) {
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<string, typeof navigation>,
);
return (
<nav className={clsx("text-base lg:text-sm", className)}>
<ul role="list" className="space-y-4">
<li>
<h2 className="font-display font-bold text-base text-slate-900 dark:text-white">{firstSections[0]?.type}</h2>
{firstSections.map((section) => (
<NavigationItem key={section.title} section={section} onLinkClick={onLinkClick} />
))}
</li>
{Object.entries(filteredSections).map(([type, sections]) => (
<li key={type}>
<h2 className="font-display font-bold text-base text-slate-900 dark:text-white">{type}</h2>
{sections.map((section) => (
<NavigationItem key={section.title} section={section} onLinkClick={onLinkClick} />
))}
</li>
))}
<li>
<h2 className="font-display font-bold text-base text-slate-900 dark:text-white">{lastSections[0]?.type}</h2>
{lastSections.map((section) => (
<NavigationItem key={section.title} section={section} onLinkClick={onLinkClick} />
))}
</li>
</ul>
</nav>
);
}

View File

@ -1,89 +0,0 @@
import { usePageContext } from "vike-react/usePageContext";
import { Link } from "@/components/common/Link";
import { navigation } from "@/lib/navigation";
import React from "react";
import clsx from "clsx";
function ArrowIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
<path d="m9.182 13.423-1.17-1.16 3.505-3.505H3V7.065h8.517l-3.506-3.5L9.181 2.4l5.512 5.511-5.511 5.512Z" />
</svg>
);
}
function PageLink({
title,
href,
dir = "next",
...props
}: Omit<React.ComponentPropsWithoutRef<"div">, "dir" | "title"> & {
title: string;
href: string;
dir?: "previous" | "next";
}) {
const pageCategory = navigation.find((section) => {
return section.links.some((link) => link.href === href || link.subitems.some((subitem) => subitem.href === href));
});
return (
<div {...props}>
<dt className="font-display text-sm font-medium text-slate-900 dark:text-white">
{dir === "next" ? "Suivant" : "Précédent"}
</dt>
<dd className="mt-1">
<Link
href={href}
className={clsx(
"flex items-center gap-x-2 text-base font-semibold text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300",
dir === "previous" && "flex-row-reverse",
)}
>
<p className="flex flex-col gap-0">
{pageCategory && (
<span className="text-violet-600 dark:text-violet-400 text-sm mb-1 leading-3">{pageCategory.title}</span>
)}
<span className="leading-4">{title}</span>
</p>
<ArrowIcon className={clsx("h-6 w-6 flex-none fill-current", dir === "previous" && "-scale-x-100")} />
</Link>
</dd>
</div>
);
}
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;
// In case the next page is the same as the current page (in subitems),
// we need to skip it to get the correct next page.
if (nextPage?.href === urlPathname) {
nextPage = allLinks[linkIndex + 2] || null;
}
return [previousPage, nextPage];
};
const [previousPage, nextPage] = getNeighboringLinks();
if (!nextPage && !previousPage) return null;
return (
<dl className="mt-12 flex gap-4 border-t border-slate-200 pt-6 dark:border-slate-800">
{previousPage && <PageLink dir="previous" {...previousPage} />}
{nextPage && <PageLink className="ml-auto text-right" {...nextPage} />}
</dl>
);
}

View File

@ -1,34 +0,0 @@
import React from "react";
import clsx from "clsx";
export function Prose<T extends React.ElementType = "div">({
as,
className,
...props
}: React.ComponentPropsWithoutRef<T> & {
as?: T;
}) {
const Component = as ?? "div";
return (
<Component
className={clsx(
className,
"prose max-w-none prose-slate dark:text-slate-400 dark:prose-invert",
// headings
"prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-normal lg:prose-headings:scroll-mt-[8.5rem]",
// lead
"prose-lead:text-slate-500 dark:prose-lead:text-slate-400",
// links
"prose-a:font-semibold dark:prose-a:text-violet-400",
// link underline
"dark:[--tw-prose-background:var(--color-slate-900)] prose-a:no-underline prose-a:shadow-[inset_0_-2px_0_0_var(--tw-prose-background,#fff),inset_0_calc(-1*(var(--tw-prose-underline-size,4px)+2px))_0_0_var(--tw-prose-underline,var(--color-violet-300))] prose-a:hover:[--tw-prose-underline-size:6px] dark:prose-a:shadow-[inset_0_calc(-1*var(--tw-prose-underline-size,2px))_0_0_var(--tw-prose-underline,var(--color-violet-800))] dark:prose-a:hover:[--tw-prose-underline-size:6px]",
// pre
"prose-pre:rounded-xl prose-pre:bg-slate-900 prose-pre:shadow-lg dark:prose-pre:bg-slate-800/60 dark:prose-pre:ring-1 dark:prose-pre:shadow-none dark:prose-pre:ring-slate-300/10",
// hr
"dark:prose-hr:border-slate-800",
)}
{...props}
/>
);
}

View File

@ -1,35 +0,0 @@
import { Link } from "@/components/common/Link";
import { Icon } from "@syntax/Icon";
import React from "react";
export function QuickLinks({ children }: { children: React.ReactNode }) {
return <div className="not-prose my-12 grid grid-cols-1 gap-6 sm:grid-cols-2">{children}</div>;
}
export function QuickLink({
title,
description,
href,
icon,
}: {
title: string;
description: string;
href: string;
icon: React.ComponentProps<typeof Icon>["icon"];
}) {
return (
<div className="group relative rounded-xl border border-slate-200 dark:border-slate-800">
<div className="absolute -inset-px rounded-xl border-2 border-transparent opacity-0 [background:linear-gradient(var(--quick-links-hover-bg,var(--color-violet-50)),var(--quick-links-hover-bg,var(--color-violet-50)))_padding-box,linear-gradient(to_top,var(--color-indigo-400),var(--color-cyan-400),var(--color-violet-500))_border-box] group-hover:opacity-100 dark:[--quick-links-hover-bg:var(--color-slate-800)]" />
<div className="relative overflow-hidden rounded-xl p-6">
<Icon icon={icon} className="h-8 w-8" />
<h2 className="mt-4 font-display text-base text-slate-900 dark:text-white">
<Link href={href}>
<span className="absolute -inset-px rounded-xl" />
{title}
</Link>
</h2>
<p className="mt-1 text-sm text-slate-700 dark:text-slate-400">{description}</p>
</div>
</div>
);
}

View File

@ -1,58 +0,0 @@
import { Highlight, Prism } from "prism-react-renderer";
import { prismThemes } from "@/data/themes/prism";
import React, { Fragment, useMemo } from "react";
import { useTheme } from "@/hooks/useTheme";
import clsx from "clsx";
export function SSRSnippet({
children,
language,
label,
showLineNumbers = false,
}: {
children: string;
language: string;
label?: string;
showLineNumbers?: boolean;
}) {
const { theme } = useTheme();
const prismTheme = useMemo(() => {
return prismThemes[theme];
}, [theme]);
return (
<Highlight code={children.trimEnd()} language={language} theme={prismTheme} prism={Prism}>
{({ className, style, tokens, getTokenProps }) => (
<div className="relative w-full">
{label && (
<div className="absolute px-4 py-1 left-0 text-sm text-gray-700 dark:text-gray-200 italic w-full bg-gray-200 dark:bg-gray-700 rounded-t-xl">
{label}
</div>
)}
<pre className={clsx(className, { "pt-11": !!label })} style={style}>
<code>
{tokens.map((line, lineIndex) => (
<Fragment key={lineIndex}>
{showLineNumbers && (
<span
className="text-gray-400 dark:text-gray-500 text-right font-mono w-8 inline-block pr-4"
style={{ userSelect: "none" }}
>
{lineIndex + 1}
</span>
)}
{line
.filter((token) => !token.empty)
.map((token, tokenIndex) => (
<span key={tokenIndex} {...getTokenProps({ token })} />
))}
{"\n"}
</Fragment>
))}
</code>
</pre>
</div>
)}
</Highlight>
);
}

View File

@ -1,13 +0,0 @@
import { buildFlexSearch, type SearchResult } from "@/services/FlexSearchService";
import { docsService } from "@/services/DocsService";
export const onSearch = async (query: string, maxResults?: number): Promise<SearchResult[]> => {
const search = buildFlexSearch(await docsService.fetchDocs());
const results = search(query);
if (maxResults) {
return results.slice(0, maxResults);
}
return results;
};

View File

@ -1,278 +0,0 @@
import React, { useId, useState, useEffect, createContext, useContext, Fragment } from "react";
import { SearchResult } from "@/services/FlexSearchService";
import { Dialog, DialogPanel } from "@headlessui/react";
import { useDebounce } from "@/hooks/useDebounce";
import Highlighter from "react-highlight-words";
import { navigation } from "@/lib/navigation";
import { navigate } from "vike/client/router";
import { onSearch } from "./Search.telefunc";
import clsx from "clsx";
const SearchContext = createContext<{
query: string;
close: () => void;
results: SearchResult[];
isLoading: boolean;
isOpened: boolean;
setQuery: (query: string) => void;
setIsOpened: (isOpened: boolean) => void;
setIsLoading: (isLoading: boolean) => void;
setResults: (results: SearchResult[]) => void;
}>({
query: "",
close: () => {},
results: [],
isLoading: false,
isOpened: false,
setQuery: () => {},
setIsOpened: () => {},
setIsLoading: () => {},
setResults: () => {},
});
function SearchIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
</svg>
);
}
function LoadingIcon(props: React.ComponentPropsWithoutRef<"svg">) {
const id = useId();
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
<path stroke={`url(#${id})`} strokeLinecap="round" strokeLinejoin="round" d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5" />
<defs>
<linearGradient id={id} x1="13" x2="9.5" y1="9" y2="15" gradientUnits="userSpaceOnUse">
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}
function SearchInput() {
const { close, setQuery, query, isLoading } = useContext(SearchContext);
return (
<div className="group relative flex h-12">
<SearchIcon className="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400 dark:fill-slate-500" />
<input
data-autofocus
className={clsx(
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm dark:text-white [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
isLoading ? "pr-11" : "pr-4",
)}
onKeyDown={(event) => {
if (event.key === "Escape") {
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
close();
}
}}
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
{isLoading && (
<div className="absolute inset-y-0 right-3 flex items-center">
<LoadingIcon className="h-6 w-6 animate-spin stroke-slate-200 text-slate-400 dark:stroke-slate-700 dark:text-slate-500" />
</div>
)}
</div>
);
}
function HighlightQuery({ text, query }: { text: string; query: string }) {
return (
<Highlighter
highlightClassName="group-aria-selected:underline bg-transparent text-violet-600 dark:text-violet-400"
searchWords={[query]}
autoEscape={true}
textToHighlight={text}
/>
);
}
function SearchResultItem({ result, query }: { result: SearchResult; query: string }) {
const { close } = useContext(SearchContext);
const id = useId();
const sectionTitle = navigation.find((section) =>
section.links.find((link) => link.href === result.url.split("#")[0]),
)?.title;
const hierarchy = [sectionTitle, result.pageTitle].filter((x): x is string => typeof x === "string");
return (
<li
className="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 dark:aria-selected:bg-slate-700/30 hover:bg-slate-100 dark:hover:bg-slate-700/30"
aria-labelledby={`${id}-hierarchy ${id}-title`}
role="option"
tabIndex={0}
onClick={() => {
navigate(result.url);
close();
}}
>
<div
id={`${id}-title`}
aria-hidden="true"
className="text-sm text-slate-700 group-aria-selected:text-violet-600 dark:text-slate-300 dark:group-aria-selected:text-violet-400"
>
<HighlightQuery text={result.title} query={query} />
</div>
{hierarchy.length > 0 && (
<div
id={`${id}-hierarchy`}
aria-hidden="true"
className="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
>
{hierarchy.map((item, itemIndex, items) => (
<Fragment key={itemIndex}>
<HighlightQuery text={item} query={query} />
<span className={itemIndex === items.length - 1 ? "sr-only" : "mx-2 text-slate-300 dark:text-slate-700"}>
/
</span>
</Fragment>
))}
</div>
)}
</li>
);
}
function SearchResults() {
const { results, query } = useContext(SearchContext);
if (results.length === 0) {
return (
<p className="px-4 py-8 text-center text-sm text-slate-700 dark:text-slate-400">
Aucun résultat pour &ldquo;
<span className="break-words text-slate-900 dark:text-white">{query}</span>
&rdquo;
</p>
);
}
return (
<ul>
{results.map((result) => (
<SearchResultItem key={result.url} result={result} query={query} />
))}
</ul>
);
}
function SearchDialog({ className }: { className?: string }) {
const { close, isOpened, setIsOpened, results } = useContext(SearchContext);
useEffect(() => {
if (isOpened) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setIsOpened(true);
}
}
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [isOpened, setIsOpened]);
return (
<>
<Dialog open={isOpened} onClose={close} className={clsx("fixed inset-0 z-50", className)}>
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
<DialogPanel className="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl dark:bg-slate-800 dark:ring-1 dark:ring-slate-700">
<form onSubmit={(event) => event.preventDefault()}>
<SearchInput />
<div className="border-t border-slate-200 bg-white px-2 py-3 empty:hidden dark:border-slate-400/10 dark:bg-slate-800">
{results.length > 0 && <SearchResults />}
</div>
</form>
</DialogPanel>
</div>
</Dialog>
</>
);
}
export function Search() {
const [results, setResults] = useState<SearchResult[]>([]);
const [debouncedQuery, setDebouncedQuery] = useDebounce();
const [modifierKey, setModifierKey] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [isOpened, setIsOpened] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
const platform = navigator.userAgentData?.platform || navigator.platform;
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl ");
}, []);
useEffect(() => {
setDebouncedQuery(query);
}, [query]);
useEffect(() => {
if (debouncedQuery.length === 0) {
setIsLoading(false);
setResults([]);
return;
}
setIsLoading(true);
onSearch(debouncedQuery, 5)
.then(setResults)
.finally(() => {
setIsLoading(false);
});
}, [debouncedQuery]);
return (
<SearchContext.Provider
value={{
query,
close: () => setIsOpened(false),
results,
isLoading,
isOpened,
setQuery,
setIsOpened,
setIsLoading,
setResults,
}}
>
<button
type="button"
className="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96 dark:md:bg-slate-800/75 dark:md:ring-white/5 dark:md:ring-inset dark:md:hover:bg-slate-700/40 dark:md:hover:ring-slate-500"
onClick={() => setIsOpened(true)}
>
<SearchIcon className="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400 dark:fill-slate-500" />
<span className="sr-only md:not-sr-only md:ml-2 md:text-slate-500 md:dark:text-slate-400">Rechercher...</span>
{modifierKey && (
<kbd className="ml-auto hidden font-medium text-slate-400 md:block dark:text-slate-500">
<kbd className="font-sans">{modifierKey}</kbd>
<kbd className="font-sans">K</kbd>
</kbd>
)}
</button>
<SearchDialog />
</SearchContext.Provider>
);
}

View File

@ -1,48 +0,0 @@
import type { Data } from "@/pages/docs/+data";
import { clientOnly } from "vike-react/clientOnly";
import { useData } from "vike-react/useData";
import { SSRSnippet } from "./SSRSnippet";
import React from "react";
const CSRSnippet = clientOnly(() => import("./CSRSnippet"));
export function Snippet({
path,
language,
label,
showLineNumbers,
}: {
path: string;
language: string;
label?: string;
showLineNumbers: boolean;
}) {
const { snippets } = useData<Data>();
const snippet = snippets.find((snippet) => snippet.path === path);
if (!snippet || !snippet.content) {
return (
<div className="bg-red-600/10 p-4 rounded-md flex items-center justify-center">
<p className="text-red-500 text-center">
<b className="uppercase">Snippet introuvable</b>
<br />
<code>{path}</code>
</p>
</div>
);
}
const props = {
language,
label,
showLineNumbers,
children: snippet.content,
};
return (
<div className="relative group">
<CSRSnippet {...props} fallback={<SSRSnippet {...props} />} />
</div>
);
}

View File

@ -1,105 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link } from "@/components/common/Link";
import clsx from "clsx";
import { type Section, type Subsection } from "@/lib/sections";
export function TableOfContents({ tableOfContents }: { tableOfContents: Array<Section> }) {
const [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id);
const getHeadings = useCallback((tableOfContents: Array<Section>) => {
return tableOfContents
.flatMap((node) => [node.id, ...node.children.map((child) => child.id)])
.map((id) => {
const el = document.getElementById(id);
if (!el) return null;
const style = window.getComputedStyle(el);
const scrollMt = parseFloat(style.scrollMarginTop);
const top = window.scrollY + el.getBoundingClientRect().top - scrollMt;
return { id, top };
})
.filter((x): x is { id: string; top: number } => x !== null);
}, []);
useEffect(() => {
if (tableOfContents.length === 0) return;
const headings = getHeadings(tableOfContents);
function onScroll() {
const top = window.scrollY;
let current = headings[0]?.id;
for (const heading of headings) {
if (top < heading.top - 10) break;
current = heading.id;
}
setCurrentSection(current);
}
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [getHeadings, tableOfContents]);
function isActive(section: Section | Subsection) {
if (section.id === currentSection) return true;
if (!section.children) return false;
return section.children.findIndex(isActive) > -1;
}
return (
<div className="hidden xl:sticky xl:top-[4.75rem] xl:-mr-6 xl:block xl:h-[calc(100vh-4.75rem)] xl:flex-none xl:overflow-y-auto xl:py-16 xl:pr-6">
<nav aria-labelledby="on-this-page-title" className="w-56">
{tableOfContents.length > 0 && (
<>
<h2 id="on-this-page-title" className="font-display text-sm font-medium text-slate-900 dark:text-white">
Table des matières
</h2>
<ol role="list" className="mt-4 space-y-3 text-sm">
{tableOfContents.map((section) => (
<li key={section.id}>
<h3>
<Link
href={`#${section.id}`}
className={clsx(
isActive(section)
? "text-violet-500"
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300",
)}
>
{section.title}
</Link>
</h3>
{section.children.length > 0 && (
<ol role="list" className="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
{section.children.map((subSection) => (
<li key={subSection.id}>
<Link
href={`#${subSection.id}`}
className={
isActive(subSection)
? "text-violet-500"
: "hover:text-slate-600 dark:hover:text-slate-300"
}
>
{subSection.title}
</Link>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</>
)}
</nav>
</div>
);
}

View File

@ -1,86 +0,0 @@
import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
import React, { useEffect, useState } from "react";
import { useTheme } from "@/hooks/useTheme";
import clsx from "clsx";
const themes = [
{ name: "Clair", value: "light", icon: LightIcon },
{ name: "Sombre", value: "dark", icon: DarkIcon },
];
function LightIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 1a1 1 0 0 1 2 0v1a1 1 0 1 1-2 0V1Zm4 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm2.657-5.657a1 1 0 0 0-1.414 0l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0 0-1.414Zm-1.415 11.313-.707-.707a1 1 0 0 1 1.415-1.415l.707.708a1 1 0 0 1-1.415 1.414ZM16 7.999a1 1 0 0 0-1-1h-1a1 1 0 1 0 0 2h1a1 1 0 0 0 1-1ZM7 14a1 1 0 1 1 2 0v1a1 1 0 1 1-2 0v-1Zm-2.536-2.464a1 1 0 0 0-1.414 0l-.707.707a1 1 0 0 0 1.414 1.414l.707-.707a1 1 0 0 0 0-1.414Zm0-8.486A1 1 0 0 1 3.05 4.464l-.707-.707a1 1 0 0 1 1.414-1.414l.707.707ZM3 8a1 1 0 0 0-1-1H1a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1Z"
/>
</svg>
);
}
function DarkIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.23 3.333C7.757 2.905 7.68 2 7 2a6 6 0 1 0 0 12c.68 0 .758-.905.23-1.332A5.989 5.989 0 0 1 5 8c0-1.885.87-3.568 2.23-4.668ZM12 5a1 1 0 0 1 1 1 1 1 0 0 0 1 1 1 1 0 1 1 0 2 1 1 0 0 0-1 1 1 1 0 1 1-2 0 1 1 0 0 0-1-1 1 1 0 1 1 0-2 1 1 0 0 0 1-1 1 1 0 0 1 1-1Z"
/>
</svg>
);
}
export function ThemeSelector(props: React.ComponentPropsWithoutRef<typeof Listbox<"div">>) {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="h-6 w-6" />;
}
return (
<Listbox as="div" value={theme} onChange={setTheme} {...props}>
<Label className="sr-only">Theme</Label>
<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 cursor-pointer"
aria-label="Theme"
>
<LightIcon className={clsx("h-4 w-4 dark:hidden", "fill-violet-400")} />
<DarkIcon className={clsx("hidden h-4 w-4 dark:block", "fill-violet-400")} />
</ListboxButton>
<ListboxOptions className="absolute top-full left-1/2 mt-3 w-36 -translate-x-1/2 space-y-1 rounded-xl bg-white p-3 text-sm font-medium ring-1 shadow-md shadow-black/5 ring-black/5 dark:bg-slate-800 dark:ring-white/5">
{themes.map((theme) => (
<ListboxOption
key={theme.value}
value={theme.value}
className={({ focus, selected }) =>
clsx("flex cursor-pointer items-center rounded-[0.625rem] p-1 select-none", {
"text-violet-500": selected,
"text-slate-900 dark:text-white": focus && !selected,
"text-slate-700 dark:text-slate-400": !focus && !selected,
"bg-slate-100 dark:bg-slate-900/40": focus,
})
}
>
{({ selected }) => (
<>
<div className="rounded-md bg-white p-1 ring-1 shadow-sm ring-slate-900/5 dark:bg-slate-700 dark:ring-white/5 dark:ring-inset">
<theme.icon
className={clsx("h-4 w-4", selected ? "fill-violet-400 dark:fill-violet-400" : "fill-slate-400")}
/>
</div>
<div className="ml-3">{theme.name}</div>
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
);
}

View File

@ -1,40 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function InstallationIcon({
id,
color,
}: {
id: string;
color?: React.ComponentProps<typeof Gradient>["color"];
}) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="matrix(0 21 -21 0 12 3)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 21 -21 0 16 7)" />
</defs>
<LightMode>
<circle cx={12} cy={12} r={12} fill={`url(#${id}-gradient)`} />
<path
d="m8 8 9 21 2-10 10-2L8 8Z"
fillOpacity={0.5}
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</LightMode>
<DarkMode>
<path
d="m4 4 10.286 24 2.285-11.429L28 14.286 4 4Z"
fill={`url(#${id}-gradient-dark)`}
stroke={`url(#${id}-gradient-dark)`}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</DarkMode>
</>
);
}

View File

@ -1,39 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function LightbulbIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="matrix(0 21 -21 0 20 11)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 24.5001 -19.2498 0 16 5.5)" />
</defs>
<LightMode>
<circle cx={20} cy={20} r={12} fill={`url(#${id}-gradient)`} />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20 24.995c0-1.855 1.094-3.501 2.427-4.792C24.61 18.087 26 15.07 26 12.231 26 7.133 21.523 3 16 3S6 7.133 6 12.23c0 2.84 1.389 5.857 3.573 7.973C10.906 21.494 12 23.14 12 24.995V27a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2.005Z"
className="fill-[var(--icon-background)]"
fillOpacity={0.5}
/>
<path
d="M25 12.23c0 2.536-1.254 5.303-3.269 7.255l1.391 1.436c2.354-2.28 3.878-5.547 3.878-8.69h-2ZM16 4c5.047 0 9 3.759 9 8.23h2C27 6.508 21.998 2 16 2v2Zm-9 8.23C7 7.76 10.953 4 16 4V2C10.002 2 5 6.507 5 12.23h2Zm3.269 7.255C8.254 17.533 7 14.766 7 12.23H5c0 3.143 1.523 6.41 3.877 8.69l1.392-1.436ZM13 27v-2.005h-2V27h2Zm1 1a1 1 0 0 1-1-1h-2a3 3 0 0 0 3 3v-2Zm4 0h-4v2h4v-2Zm1-1a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2Zm0-2.005V27h2v-2.005h-2ZM8.877 20.921C10.132 22.136 11 23.538 11 24.995h2c0-2.253-1.32-4.143-2.731-5.51L8.877 20.92Zm12.854-1.436C20.32 20.852 19 22.742 19 24.995h2c0-1.457.869-2.859 2.122-4.074l-1.391-1.436Z"
className="fill-[var(--icon-foreground)]"
/>
<path
d="M20 26a1 1 0 1 0 0-2v2Zm-8-2a1 1 0 1 0 0 2v-2Zm2 0h-2v2h2v-2Zm1 1V13.5h-2V25h2Zm-5-11.5v1h2v-1h-2Zm3.5 4.5h5v-2h-5v2Zm8.5-3.5v-1h-2v1h2ZM20 24h-2v2h2v-2Zm-2 0h-4v2h4v-2Zm-1-10.5V25h2V13.5h-2Zm2.5-2.5a2.5 2.5 0 0 0-2.5 2.5h2a.5.5 0 0 1 .5-.5v-2Zm2.5 2.5a2.5 2.5 0 0 0-2.5-2.5v2a.5.5 0 0 1 .5.5h2ZM18.5 18a3.5 3.5 0 0 0 3.5-3.5h-2a1.5 1.5 0 0 1-1.5 1.5v2ZM10 14.5a3.5 3.5 0 0 0 3.5 3.5v-2a1.5 1.5 0 0 1-1.5-1.5h-2Zm2.5-3.5a2.5 2.5 0 0 0-2.5 2.5h2a.5.5 0 0 1 .5-.5v-2Zm2.5 2.5a2.5 2.5 0 0 0-2.5-2.5v2a.5.5 0 0 1 .5.5h2Z"
className="fill-[var(--icon-foreground)]"
/>
</LightMode>
<DarkMode>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 2C10.002 2 5 6.507 5 12.23c0 3.144 1.523 6.411 3.877 8.691.75.727 1.363 1.52 1.734 2.353.185.415.574.726 1.028.726H12a1 1 0 0 0 1-1v-4.5a.5.5 0 0 0-.5-.5A3.5 3.5 0 0 1 9 14.5V14a3 3 0 1 1 6 0v9a1 1 0 1 0 2 0v-9a3 3 0 1 1 6 0v.5a3.5 3.5 0 0 1-3.5 3.5.5.5 0 0 0-.5.5V23a1 1 0 0 0 1 1h.36c.455 0 .844-.311 1.03-.726.37-.833.982-1.626 1.732-2.353 2.354-2.28 3.878-5.547 3.878-8.69C27 6.507 21.998 2 16 2Zm5 25a1 1 0 0 0-1-1h-8a1 1 0 0 0-1 1 3 3 0 0 0 3 3h4a3 3 0 0 0 3-3Zm-8-13v1.5a.5.5 0 0 1-.5.5 1.5 1.5 0 0 1-1.5-1.5V14a1 1 0 1 1 2 0Zm6.5 2a.5.5 0 0 1-.5-.5V14a1 1 0 1 1 2 0v.5a1.5 1.5 0 0 1-1.5 1.5Z"
fill={`url(#${id}-gradient-dark)`}
/>
</DarkMode>
</>
);
}

View File

@ -1,48 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function PluginsIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="matrix(0 21 -21 0 20 11)" />
<Gradient id={`${id}-gradient-dark-1`} color={color} gradientTransform="matrix(0 22.75 -22.75 0 16 6.25)" />
<Gradient id={`${id}-gradient-dark-2`} color={color} gradientTransform="matrix(0 14 -14 0 16 10)" />
</defs>
<LightMode>
<circle cx={20} cy={20} r={12} fill={`url(#${id}-gradient)`} />
<g
fillOpacity={0.5}
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9v14l12 6V15L3 9Z" />
<path d="M27 9v14l-12 6V15l12-6Z" />
</g>
<path d="M11 4h8v2l6 3-10 6L5 9l6-3V4Z" fillOpacity={0.5} className="fill-[var(--icon-background)]" />
<g
className="stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 5.5 27 9l-12 6L3 9l7-3.5" />
<path d="M20 5c0 1.105-2.239 2-5 2s-5-.895-5-2m10 0c0-1.105-2.239-2-5-2s-5 .895-5 2m10 0v3c0 1.105-2.239 2-5 2s-5-.895-5-2V5" />
</g>
</LightMode>
<DarkMode strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path
d="M17.676 3.38a3.887 3.887 0 0 0-3.352 0l-9 4.288C3.907 8.342 3 9.806 3 11.416v9.168c0 1.61.907 3.073 2.324 3.748l9 4.288a3.887 3.887 0 0 0 3.352 0l9-4.288C28.093 23.657 29 22.194 29 20.584v-9.168c0-1.61-.907-3.074-2.324-3.748l-9-4.288Z"
stroke={`url(#${id}-gradient-dark-1)`}
/>
<path
d="M16.406 8.087a.989.989 0 0 0-.812 0l-7 3.598A1.012 1.012 0 0 0 8 12.61v6.78c0 .4.233.762.594.925l7 3.598a.989.989 0 0 0 .812 0l7-3.598c.361-.163.594-.525.594-.925v-6.78c0-.4-.233-.762-.594-.925l-7-3.598Z"
fill={`url(#${id}-gradient-dark-2)`}
stroke={`url(#${id}-gradient-dark-2)`}
/>
</DarkMode>
</>
);
}

View File

@ -1,36 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function PresetsIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="matrix(0 21 -21 0 20 3)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 22.75 -22.75 0 16 6.25)" />
</defs>
<LightMode>
<circle cx={20} cy={12} r={12} fill={`url(#${id}-gradient)`} />
<g
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
fillOpacity={0.5}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 5v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2Z" />
<path d="M18 17v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V17a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2Z" />
<path d="M18 5v4a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-7a2 2 0 0 0-2 2Z" />
<path d="M3 25v2a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2Z" />
</g>
</LightMode>
<DarkMode fill={`url(#${id}-gradient-dark)`}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 17V4a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Zm16 10v-9a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-6a2 2 0 0 1-2-2Zm0-23v5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-8a1 1 0 0 0-1 1ZM3 28v-3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z"
/>
<path d="M2 4v13h2V4H2Zm2-2a2 2 0 0 0-2 2h2V2Zm8 0H4v2h8V2Zm2 2a2 2 0 0 0-2-2v2h2Zm0 13V4h-2v13h2Zm-2 2a2 2 0 0 0 2-2h-2v2Zm-8 0h8v-2H4v2Zm-2-2a2 2 0 0 0 2 2v-2H2Zm16 1v9h2v-9h-2Zm3-3a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1v-2Zm6 0h-6v2h6v-2Zm3 3a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2Zm0 9v-9h-2v9h2Zm-3 3a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2Zm-6 0h6v-2h-6v2Zm-3-3a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1h-2Zm2-18V4h-2v5h2Zm0 0h-2a2 2 0 0 0 2 2V9Zm8 0h-8v2h8V9Zm0 0v2a2 2 0 0 0 2-2h-2Zm0-5v5h2V4h-2Zm0 0h2a2 2 0 0 0-2-2v2Zm-8 0h8V2h-8v2Zm0 0V2a2 2 0 0 0-2 2h2ZM2 25v3h2v-3H2Zm2-2a2 2 0 0 0-2 2h2v-2Zm9 0H4v2h9v-2Zm2 2a2 2 0 0 0-2-2v2h2Zm0 3v-3h-2v3h2Zm-2 2a2 2 0 0 0 2-2h-2v2Zm-9 0h9v-2H4v2Zm-2-2a2 2 0 0 0 2 2v-2H2Z" />
</DarkMode>
</>
);
}

View File

@ -1,48 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function QuestionIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="rotate(65.924 1.519 20.92) scale(25.7391)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 24.5 -24.5 0 16 5.5)" />
</defs>
<LightMode>
<circle cx={20} cy={20} r={12} fill={`url(#${id}-gradient)`} />
<path
d="M3 16c0 7.18 5.82 13 13 13s13-5.82 13-13S23.18 3 16 3 3 8.82 3 16Z"
fillOpacity={0.5}
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m 16.39 14.617 l 1.179 -3.999 C 17.38 9.304 16.133 9.127 15.469 10.645 C 15.306 11.269 14.71 11.12 14.71 10.537 a 1.66 1.66 5 1 1 3.808 0.217 l -1.5182 5.4314 a 0.602 0.602 5 0 1 -1.1795 -0.1032 Z"
className="fill-[var(--icon-foreground)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 23a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
fillOpacity={0.5}
stroke="currentColor"
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</LightMode>
<DarkMode>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 16C2 8.268 8.268 2 16 2s14 6.268 14 14-6.268 14-14 14S2 23.732 2 16Zm11.386-4.85a2.66 2.66 0 1 1 5.228 0l-1.039 5.543a1.602 1.602 0 0 1-3.15 0l-1.04-5.543ZM16 20a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"
fill={`url(#${id}-gradient-dark)`}
/>
</DarkMode>
</>
);
}

View File

@ -1,52 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function ThemingIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="matrix(0 21 -21 0 12 11)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 24.5 -24.5 0 16 5.5)" />
</defs>
<LightMode>
<circle cx={12} cy={20} r={12} fill={`url(#${id}-gradient)`} />
<path
d="M27 12.13 19.87 5 13 11.87v14.26l14-14Z"
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
fillOpacity={0.5}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 3h10v22a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V3Z"
className="fill-[var(--icon-background)]"
fillOpacity={0.5}
/>
<path
d="M3 9v16a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4V9M3 9V3h10v6M3 9h10M3 15h10M3 21h10"
className="stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M29 29V19h-8.5L13 26c0 1.5-2.5 3-5 3h21Z"
fillOpacity={0.5}
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</LightMode>
<DarkMode>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 2a1 1 0 0 0-1 1v21a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H3Zm16.752 3.293a1 1 0 0 0-1.593.244l-1.045 2A1 1 0 0 0 17 8v13a1 1 0 0 0 1.71.705l7.999-8.045a1 1 0 0 0-.002-1.412l-6.955-6.955ZM26 18a1 1 0 0 0-.707.293l-10 10A1 1 0 0 0 16 30h13a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-3ZM5 18a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H5Zm-1-5a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H5a1 1 0 0 1-1-1Zm1-7a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H5Z"
fill={`url(#${id}-gradient-dark)`}
/>
</DarkMode>
</>
);
}

View File

@ -1,48 +0,0 @@
import { DarkMode, Gradient, LightMode } from "@syntax/Icon";
import React from "react";
export function WarningIcon({ id, color }: { id: string; color?: React.ComponentProps<typeof Gradient>["color"] }) {
return (
<>
<defs>
<Gradient id={`${id}-gradient`} color={color} gradientTransform="rotate(65.924 1.519 20.92) scale(25.7391)" />
<Gradient id={`${id}-gradient-dark`} color={color} gradientTransform="matrix(0 24.5 -24.5 0 16 5.5)" />
</defs>
<LightMode>
<circle cx={20} cy={20} r={12} fill={`url(#${id}-gradient)`} />
<path
d="M3 16c0 7.18 5.82 13 13 13s13-5.82 13-13S23.18 3 16 3 3 8.82 3 16Z"
fillOpacity={0.5}
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m15.408 16.509-1.04-5.543a1.66 1.66 0 1 1 3.263 0l-1.039 5.543a.602.602 0 0 1-1.184 0Z"
className="fill-[var(--icon-foreground)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 23a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
fillOpacity={0.5}
stroke="currentColor"
className="fill-[var(--icon-background)] stroke-[color:var(--icon-foreground)]"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</LightMode>
<DarkMode>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 16C2 8.268 8.268 2 16 2s14 6.268 14 14-6.268 14-14 14S2 23.732 2 16Zm11.386-4.85a2.66 2.66 0 1 1 5.228 0l-1.039 5.543a1.602 1.602 0 0 1-3.15 0l-1.04-5.543ZM16 20a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"
fill={`url(#${id}-gradient-dark)`}
/>
</DarkMode>
</>
);
}

42
app/config.ts Normal file
View File

@ -0,0 +1,42 @@
function getEnvironmentVariable<T = undefined>(
key: string,
defaultValue: T,
formatter?: (data: string) => T,
): T {
const value = process.env[key];
if (value === undefined) return defaultValue;
if (formatter) return formatter(value);
return value as T;
}
function getEnvironmentVariableOrThrow<T = undefined>(
key: string,
formatter?: (data: string) => T,
): T {
const value = process.env[key];
if (value === undefined)
throw new Error(`Missing environment variable: ${key}`);
if (formatter) return formatter(value);
return value as T;
}
const PORT = getEnvironmentVariableOrThrow<number>("PORT", (data) =>
Number.parseInt(data, 10),
);
const HMR_PORT = getEnvironmentVariableOrThrow<number>("HMR_PORT", (data) =>
Number.parseInt(data, 10),
);
const BASE_URL = getEnvironmentVariable<string>(
"BASE_URL",
`http://localhost:${PORT}`,
);
const NODE_ENV = getEnvironmentVariable<string>("NODE_ENV", "development");
export const config = {
PORT,
HMR_PORT,
BASE_URL,
NODE_ENV,
};

View File

@ -1,13 +0,0 @@
import { createContext } from "react";
export type Theme = "light" | "dark";
export type ThemeContextType = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
export const ThemeContext = createContext<ThemeContextType>({
theme: "light",
setTheme: () => {},
});

View File

@ -1,23 +0,0 @@
---
title: Activité Type 1 - Développer la partie front-end d'une application web ou web mobile sécurisée
description: Synthèse et explications des attentes relatives à l'activité type 1 du titre professionnel Développeur Web et Web Mobile (DWWM-01280m04).
tags: [DWWM]
---
## 📚 Références
- REAC _(mise à jour du 02/07/2024)_, pages 15 et 16
- RE _(mise à jour du 02/07/2024)_, page 9
## 📋 En résumé
Cette activité type concerne tout ce qui est relatif à la conception _(maquettes, arborescence etc.)_ et à la création de l'interface.
Voyons un peu plus en détail ce qui est attendu pour chacune de ces compétences professionnelles ! 🚀
Elle est divisée en 4 **compétences professionnelles** _(CP)_ :
- **CP 1** : Installer et configurer son environnement de travail en fonction du projet web ou web mobile
- **CP 2** : Maquetter des interfaces utilisateur web ou web mobile
- **CP 3** : Réaliser des interfaces utilisateur statiques web ou web mobile
- **CP 4** : Développer la partie dynamique des interfaces utilisateur web ou web mobile

View File

@ -1,59 +0,0 @@
---
title: Résumé du titre professionnel DWWM
description: Découvre le résumé du titre professionnel DWWM (TP-01280m04), qui te permettra de te préparer au mieux à l'examen !
tags: [DWWM]
---
## Informations administratives
- Nom complet du titre : **Développeur Web et Web Mobile**
- Sigle : **DWWM**
- Code RNCP : **37674**
- Code titre : **01280m04**
### Documentations officielles
- [REAC - Référentiel Emploi Activités Compétences _(02/07/2024)_](/downloads/dwwm/REAC_DWWM_V04_02072024.pdf)
- [RE - Référentiel d'Évaluation _(02/07/2024)_](/downloads/dwwm/REV2_DWWM_V04_02072024.pdf)
> Provenance des documentations : [Site DGEFP Grand public](https://www.banque.di.afpa.fr/EspaceEmployeursCandidatsActeurs/titre-professionnel/01280m04)
## Activités types et compétences professionnelles
## 📚 Activité type 1 - Développer la partie front-end d'une application web ou web mobile sécurisée
- CP 1 - Installer et configurer son environnement de travail en fonction du projet web ou web mobile
- CP 2 - Maquetter des interfaces utilisateur web ou web mobile
- CP 3 - Réaliser des interfaces utilisateur statiques web ou web mobile
- CP 4 - Développer la partie dynamique des interfaces utilisateur web ou web mobile
## 📚 Activité type 2 - Développer la partie back-end d'une application web ou web mobile sécurisée
- CP 5 - Mettre en place une base de données relationnelle
- CP 6 - Développer des composants d'accès aux données SQL et NoSQL
- CP 7 - Développer des composants métier coté serveur
- CP 8 - Documenter le déploiement d'une application dynamique web ou web mobile
## Compétences transverses
- Communiquer en français et en anglais
- Mettre en oeuvre une démarche de résolution de problème
- Apprendre en continu
## Déroulé de l'examen
{% callout type="note" title="Déroulé relatif au passage de l'épreuve dans sa globalité" %}
En cas de repassage d'un CCP, se référer au Référentiel d'Évaluation pour connaître les modalités de l'épreuve :
- Pages 17 et 18 pour l'AT 1
- Pages 19 et 20 pour l'AT 2
{% /callout %}
**Durée totale de l'examen** : 2h _(dont 1h30 de soutenance face au jury)_
- Questionnaire professionnel _(30 minutes, sans présence du jury)_
- Présentation d'un projet réalisé en amont de la session _(35 minutes, face au jury)_
- Entretien technique _(40 minutes, face au jury)_
- Entretien final _(15 minutes, face au jury)_

View File

@ -1,36 +0,0 @@
---
title: Certifications Memento Dev
description: Tu te prépares à passer un examen de certification, comme DWWM, CDA ou encore CDUI ? Découvre donc de bons conseils pour t'aider à te préparer au mieux !
tags: []
---
Tu te prépares à passer un examen de certification, comme DWWM, CDA ou encore CDUI ?
Découvre donc de bons conseils pour t'aider à te préparer au mieux !
## Certifications couvertes sur le Memento
{% quick-links %}
{% quick-link
title="DWWM"
description="Titre professionnel Développeur Web et Web Mobile"
href="/certifications/dwwm"
icon="presets"
/%}
{% /quick-links %}
## Certifications en cours de rédaction
- **CDA** : Concepteur Développeur d'Applications
- **CDUI** : Concepteur Designer UI
## Besoin d'un accompagnement ?
{% callout type="note" title="Accompagnement" %}
En qualité de jury habilité sur les titres professionnels **DWWM**, **CDA** et **CDUI**, je peux t'accompagner dans ta préparation à l'examen.
Qu'il s'agisse d'une aide à la **compréhension des référentiels**, d'une **préparation à l'oral** ou d'un **accompagnement sur un projet**, je suis là pour t'aider à réussir !
Tu peux me contacter par [email _(gauthier@gauthierdaniels.fr)_](mailto:gauthier@gauthierdaniels?subject=Demande%20d'accompagnement%20pour%20le%20titre%20professionnel%20X) pour bénéficier d'un accompagnement personnalisé et de conseils adaptés à tes besoins.
{% /callout %}

View File

@ -1,45 +0,0 @@
---
title: Documentations du Memento
description: Plonge toi dans une documentation synthétique et concise, conçue pour les développeurs ou passionnés de l'information en quête de savoir !
tags: []
---
## Documentations rédigées
{% quick-links %}
{% quick-link
title="React"
description="Introduction et synthèse de la bibliothèque React"
href="/docs/react"
icon="presets"
/%}
{% /quick-links %}
## Documentations en cours de rédaction
{% quick-links %}
{% quick-link
title="Merise"
description="Introduction et synthèse de la méthode Merise"
href="/docs/merise"
icon="presets"
/%}
{% /quick-links %}
## Documentations à venir
- HTML
- CSS
- JavaScript
- PHP
- SQL
- Node.js
- Express.js
- UML
- Maquettage
Et bien d'autres encore ! 😄

View File

@ -1,317 +0,0 @@
---
title: La syntaxe JSX de React
description: Découvrons la syntaxe JSX, un langage de balisage utilisé par React pour décrire l'interface utilisateur.
tags: [Frontend, React, JavaScript, TypeScript, Bibliothèque, Interface utilisateur (UI)]
---
Avant de commencer à parler des composants React, découvrons tranquillement la syntaxe **JSX**.
Le **JSX** est un sucre syntaxique _(une syntaxe plus lisible et plus simple que le JavaScript pur)_ qui permet de décrire l'interface utilisateur _(UI)_ de notre application.
Le sigle en lui-même signifie **JavaScript XML**, dans le sens où l'on va retrouver une syntaxe proche du **XML** _(eXtensible Markup Language)_ qui est un langage de balisage _(comme le **HTML**)_.
## 🔍 Différences entre HTML et JSX
Et oui, le **JSX** ressemble beaucoup au **HTML** et c'est normal !
C'est l'objectif premier de **React** : rendre la création d'interfaces utilisateur _(UI)_ plus simple et plus intuitive.
Cependant il ne faut pas oublier que le **JSX** n'est pas du **HTML**, mais du **JavaScript**.
Pour faire plus simple, voici un élément **HTML** et son équivalent avec React _(avec et sans JSX)_ :
{% tabs defaultSelectedTab="html" %}
{% tab value="html" label="HTML" %}
```html
<button class="button">Clique moi !</button>
```
{% /tab %}
{% tab value="react-no-jsx" label="React sans JSX" %}
```js
React.createElement("button", { className: "button" }, "Clique moi !");
```
{% /tab %}
{% tab value="jsx" label="React avec JSX" %}
```jsx
<button className="button">Clique moi !</button>
```
{% /tab %}
{% /tabs %}
Comme tu peux le constater, la différence entre le **JSX** et le **HTML** est minime.
Il y a toutefois des différences, comme certains mots réservés _(comme `class` qui devient `className`)_ ou encore la manière de déclarer des événements _(comme `onclick` qui devient `onClick`)_.
Par contre si on regarde la différence entre le **JSX** et le **JavaScript pur** _(en utilisant React quand même)_, on voit bien que le **JSX** est beaucoup plus lisible et plus simple à écrire.
Là où c'est encore plus flagrant, c'est quand on commence à imbriquer des éléments _(comme des composants React par exemple)_ !
{% tabs defaultSelectedTab="react-no-jsx" %}
{% tab value="react-no-jsx" label="React sans JSX" %}
```js
React.createElement(
React.Fragment,
null,
React.createElement("h2", null, "Formulaire de contact"),
React.createElement(
"form",
{ onSubmit: handleSubmit },
React.createElement(
"fieldset",
null,
React.createElement("label", { htmlFor: "lastname" }, "Nom"),
React.createElement("input", { type: "text", name: "lastname", id: "lastname", required: true }),
),
React.createElement(
"fieldset",
null,
React.createElement("label", { htmlFor: "email" }, "Email"),
React.createElement("input", { type: "email", name: "email", id: "email", required: true }),
),
React.createElement(
"fieldset",
null,
React.createElement("label", { htmlFor: "message" }, "Message"),
React.createElement("textarea", { name: "message", id: "message", required: true }),
),
React.createElement(
"fieldset",
null,
React.createElement(
"label",
{ htmlFor: "gdpr" },
React.createElement("input", { type: "checkbox", name: "gdpr", id: "gdpr", required: true }),
"J'accepte que mes données soient utilisées pour me recontacter",
),
),
React.createElement("button", { type: "submit" }, "Envoyer"),
),
);
```
{% /tab %}
{% tab value="jsx" label="React avec JSX" %}
```jsx
<React.Fragment>
<h2>Formulaire de contact</h2>
<form onSubmit={handleSubmit}>
<fieldset>
<label htmlFor="lastname">Nom</label>
<input type="text" name="lastname" id="lastname" required>
</fieldset>
<fieldset>
<label for="email">Email</label>
<input type="email" name="email" id="email" required>
</fieldset>
<fieldset>
<label for="message">Message</label>
<textarea name="message" id="message" required></textarea>
</fieldset>
<fieldset>
<label for="gdpr">
<input type="checkbox" name="gdpr" id="gdpr" required>
J'accepte que mes données soient utilisées pour me recontacter
</label>
</fieldset>
<button type="submit">Envoyer</button>
</form>
</React.Fragment>
```
{% /tab %}
{% /tabs %}
Et bien même si le code final est **identique**, le **JSX** apporte une lisibilité et une simplicité d'écriture qui est très appréciable. Pas mal non ? 😄
Et donc oui ! En faisant du **JSX**, on fait en réalité du **JavaScript** et **pas du HTML** !
{% callout type="note" title="Importation de React et ses exports" %}
Au sein de ses pages, tu verras **toujours** que j'importe le contenu de React en intégralité _(comme `import React from 'react';`)_.
Dans la réalité, on va destructurer les exports de React pour n'importer que ce dont on a besoin.
Cependant, pour te donner l'information d'où provient chaque élément, je préfère importer React en intégralité et que tu puisses visualiser les éléments de React utilisés avec leur provenance.
{% /callout %}
## 🧩 Intégration de JavaScript dans le JSX
Mais l'un des autres avantages du **JSX** est la possibilité d'ajouter du JavaScript directement dans le code !
Pour pouvoir ajouter du JavaScript dans le **JSX**, il suffit d'entourer le code JavaScript avec des accolades `{}`.
C'est un peu comme si on "ouvrait un portail" pour insérer du JavaScript dans notre code **JSX**.
### 📦 Variables et fonctions
Par exemple, si tu veux afficher une variable dans ton JSX, tu peux le faire directement :
```jsx
const name = "Jean Dupont";
return <h1>Bonjour {name} !</h1>;
```
Et si tu veux appeler une fonction, c'est tout aussi simple :
```jsx
const sayHello = () => "Bonjour !";
return <p>{sayHello()}</p>;
```
### 📝 Expressions
Tu peux également ajouter des expressions _(comme des conditions ternaires par exemple)_ :
```jsx
const age = 18;
return <p>{age >= 18 ? "Majeur" : "Mineur"}</p>;
```
Mais tu peux aussi faire un **affichage conditionnel** de manière très simple :
```jsx
const isLogged = false;
return (
<div>
{isLogged && <p>Bienvenue sur notre site !</p>}
{!isLogged && <p>Connectez-vous pour accéder à notre site</p>}
</div>
);
```
### 🔄️ Boucles
Maintenant imagine que tu souhaites créer une interface qui liste des éléments provenant d'un tableau.
```jsx
const fruits = ["pomme", "banane", "fraise"];
```
Dans un premier temps, on va revoir très rapidement comment on peut parser un tableau en JavaScript :
- `for` :
```js
for (let i = 0; i < fruits.length; i++) {
console.log(fruits[i]);
}
```
- `forEach` :
```js
fruits.forEach((fruit) => {
console.log(fruit);
});
```
- `map` :
```js
fruits.map((fruit) => {
console.log(fruit);
});
```
En soit, toutes ces méthodes sont très bien et font ce qu'on leur demande sans souci.
Cependant, React ne va pas forcément aimer ça sauf pour `map`.
La raison est simple :
React a besoin qu'on lui **retourne un élément** _(ou un tableau d'éléments)_ pour pouvoir les afficher.
Alors avec des `console.log` on ne va pas aller loin, mais si au lieu de retourner un `console.log` on retournait un élément **JSX** ? 🤔
```jsx
const fruits = ["pomme", "banane", "fraise"];
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit}>{fruit}</li>
))}
</ul>
);
```
[Voir l'exemple sur PlayCode](https://playcode.io/1940876)
Et là : **BAM** ! 💥
Tu viens de créer une liste de fruits en utilisant un tableau de fruits.
Mais par contre...
{% callout type="question" title="C'est quoi ce `key` qui vient d'apparaître ?" %}
La `key` est une propriété spéciale que React utilise pour identifier chaque élément de manière unique.
Cela permet à React de savoir quel élément a été ajouté, modifié ou supprimé.
Il est **obligatoire** d'avoir une `key` **unique** pour chaque élément d'une liste.
Si tu listes des éléments qui ont un identifiant unique _(comme l'`id` qu'on aura dans nos données stockées dans une base de données par exemple)_, tu peux utiliser cet identifiant comme `key`.
{% /callout %}
## 📦 Les props
Les **props** _(ou propriétés)_ sont des arguments que l'on peut passer à un composant React.
Je ne vais pas trop rentrer dans les détails ici, car on va les voir dans l'article d'après !
Mais pour te donner un aperçu, voici comment on peut passer des **props** à un composant :
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
const Button = (props) => {
return <button onClick={props.onClick}>{props.children}</button>;
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
type ButtonProps = {
onClick: () => void;
children: React.ReactNode;
};
const Button = (props: ButtonProps) => {
return <button onClick={props.onClick}>{props.children}</button>;
};
```
{% /tab %}
{% /tabs %}
Ici, on a un composant `Button` qui prend deux **props** : `onClick` et `children`.
`onClick` est une fonction qui sera appelée lorsqu'on cliquera sur le bouton, et `children` est tout ce qui se trouve entre les balises ouvrante et fermante du composant.
## Conclusion
Alors, plutôt cool le **JSX** non ? 😎
Même si cette syntaxe rebute certains développeurs _(souvent ils se la jouent puristes, mais chuuuuut 🤫)_, elle est toutefois très appréciée pour sa simplicité et sa lisibilité.
Question de goût après tout !

View File

@ -1,537 +0,0 @@
---
title: Le hook useContext de React
description: Découvrez comment utiliser le hook useContext de React pour gérer les contextes dans vos applications.
tags: [Frontend, React, JavaScript, TypeScript, Bibliothèque, Interface utilisateur (UI)]
---
Les contextes sont un moyen de diffuser des données au travers des composants, sans avoir à les passer explicitement à chaque composant.
Pour faire simple, imaginons une arborescence de plusieurs composants imbriqués les uns dans les autres :
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
import { useState } from "react";
const App = () => {
const [theme, setTheme] = useState("light");
return <A theme={theme} setTheme={theme} />;
};
const A = ({ theme, setTheme }) => {
return <B theme={theme} setTheme={setTheme} />;
};
const B = ({ theme, setTheme }) => {
return <C theme={theme} setTheme={setTheme} />;
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
type Theme = "light" | "dark";
const App = () => {
const [theme, setTheme] = useState<Theme>("light");
return <A theme={theme} setTheme={theme} />;
};
const A = ({ theme, setTheme }: { theme: Theme; setTheme: Dispatch<SetStateAction<Theme>> }) => {
return <B theme={theme} setTheme={setTheme} />;
};
const B = ({ theme, setTheme }: { theme: Theme; setTheme: Dispatch<SetStateAction<Theme>> }) => {
return <C theme={theme} setTheme={setTheme} />;
};
```
{% /tab %}
{% /tabs %}
Fastidieux, n'est-ce pas ? On transmet à chaque fois les mêmes données, et ce, à chaque niveau de l'arborescence.
C'est là que les contextes entrent en jeu !
On va pouvoir alors déclarer notre contexte _(qui contiendra les données à diffuser)_ et le fournir à un niveau supérieur de l'arborescence.
## Déclaration d'un contexte
Avant de penser à notre contexte, on va réfléchir à ce que l'on veut diffuser et les valeurs par défaut.
Si on reprend notre exemple avec le thème clair et sombre, on sait que l'on va vouloir diffuser la valeur du thème et une fonction pour le changer.
On va donc préparer le terrain en créant un fichier `ThemeContext.jsx` _(ou `ThemeContext.tsx` si tu utilises TypeScript)_ :
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
import { createContext } from "react";
// On crée notre contexte, avec une valeur par défaut : un thème clair
const ThemeContext = createContext({
theme: "light",
setTheme: () => {},
});
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { Dispatch, SetStateAction } from "react";
import { createContext } from "react";
// On crée un type pour les valeurs de thème
export type Theme = "light" | "dark";
// On crée un type pour notre contexte
type ThemeContextType = {
theme: Theme;
setTheme: Dispatch<SetStateAction<Theme>>;
};
// On crée notre contexte, avec une valeur par défaut : un thème clair
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
setTheme: () => {},
});
```
{% /tab %}
{% /tabs %}
## Fournir un contexte
Maintenant on peut le dire : notre contexte est prêt à être utilisé !
Il ne reste plus qu'à le fournir à notre arborescence de composants en lui créant un `Provider`.
{% callout type="question" title="Un provider ?" %}
Un `Provider` est un composant qui va permettre de **diffuser** les données du contexte à ses enfants.
Il est important de noter que le `Provider` doit **englober** les composants qui vont utiliser le contexte.
{% /callout %}
Un contexte React est un objet qui contient deux propriétés : `Provider` et `Consumer`.
Le `Provider` est un composant qui va permettre de diffuser les données du contexte à ses enfants.
Le `Consumer` est un composant qui va permettre de récupérer les données du contexte.
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
import { useState } from "react";
const App = () => {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<A />
</ThemeContext.Provider>
);
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { Theme } from "./ThemeContext";
import { useState } from "react";
const App = () => {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<A />
</ThemeContext.Provider>
);
};
```
{% /tab %}
{% /tabs %}
Mais on peut aller encore plus loin, en créant un Provider dédié à notre contexte !
Cela permettra de simplifier l'arborescence de composants et de rendre le code plus lisible :
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
import { createContext, useState } from "react";
const ThemeContext = createContext({
theme: "light",
setTheme: () => {},
});
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};
export { ThemeContext, ThemeProvider };
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { ReactNode } from "react";
import { createContext, useState } from "react";
export type Theme = "light" | "dark";
type ThemeContextType = {
theme: Theme;
setTheme: Dispatch<SetStateAction<Theme>>;
};
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
setTheme: () => {},
});
type ThemeProviderProps = {
children: ReactNode;
};
const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [theme, setTheme] = useState<Theme>("light");
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};
export { ThemeContext, ThemeProvider };
```
{% /tab %}
{% /tabs %}
Et pour terminer, on va maintenant pouvoir directement imbriquer notre `ThemeProvider` dans notre `App` :
```jsx
import { ThemeProvider } from "./ThemeContext";
const App = () => {
return (
<ThemeProvider>
<A />
</ThemeProvider>
);
};
```
## Utilisation d'un contexte
C'est bien beau de créer un contexte, mais comment l'utiliser ?
Tu te souviens peut-être du `Consumer` que l'on a évoqué plus tôt, non ?
Et bien, il est temps de le mettre en pratique ! 😁
Pour commencer, nous allons avoir besoin du hook `useContext` de React.
Ce hook va nous permettre de récupérer les données du contexte, et ce, directement dans nos composants.
```jsx
import { ThemeContext } from "./ThemeContext";
import { useContext } from "react";
const C = () => {
const { theme, setTheme } = useContext(ThemeContext);
return <>{/** JSX */}</>;
};
```
Pas mal, non ? 😉
Fini l'arborescence de composants à rallonge, on peut maintenant récupérer les données du contexte directement dans nos composants !
## Les défauts des contextes
Seulement... Un grand pouvoir implique de grandes responsabilités. 🕷️
Bien que les contextes soient très pratiques, il faut prendre en compte quelques points :
- On ne peut pas utiliser les contextes pour tout et n'importe quoi. Ils sont plutôt adaptés pour diffuser des données qui sont utilisées par plusieurs composants.
- Les contextes peuvent rendre le code plus difficile à comprendre.
- L'utilisation de nombreux contextes va faire apparaître ce qu'on appelle le **context hell**.
### Le context hell
Dans cet article, nous avons vu comment créer un contexte et l'utiliser.
Et par chance, nous n'avons pas encore rencontré le **context hell**.
Mais maintenant, que se passe-t-il si on a besoin de plusieurs contextes _(plusieurs dizaines par exemple !)_ dans notre application ?
On va se retrouver avec une arborescence de composants qui va devenir de plus en plus difficile à comprendre et à maintenir.
Et c'est ça, le **context hell**.
```jsx
root.render(
<StrictMode>
<UserProvider>
<ThemeProvider>
<LanguageProvider>
<PostProvider>
<SettingsProvider>
<SocketProvider>
<FriendProvider>
<NotificationProvider>
<ChatProvider>
<MusicProvider>
<VideoProvider>
<GameProvider>
<WeatherProvider>
<NewsProvider>
<CalendarProvider>
<TaskProvider>
<NoteProvider>
<App />
</NoteProvider>
</TaskProvider>
</CalendarProvider>
</NewsProvider>
</WeatherProvider>
</GameProvider>
</VideoProvider>
</MusicProvider>
</ChatProvider>
</NotificationProvider>
</FriendProvider>
</SocketProvider>
</SettingsProvider>
</PostProvider>
</LanguageProvider>
</ThemeProvider>
</UserProvider>
</StrictMode>,
);
```
Maintenant, demande à un développeur d'inverser le provider `UserProvider` avec le provider `NoteProvider`.
C'est jouable sans difficulté, mais si tu entends des cris de désespoir, c'est normal. 😅
Pour éviter de tomber dans le **context hell**, il est important de bien réfléchir à l'utilisation des contextes dans notre application avec ces quelques questions :
- Est-ce que l'utilisation d'un contexte est vraiment nécessaire pour ce cas d'usage ?
- Est-ce que le contexte est utilisé par plusieurs composants ?
- Est-ce que le contexte est utilisé par des composants éloignés dans l'arborescence ?
Mais alors, si tu as besoin d'autant de contextes dans ton application, comment faire ?
Et bien, il existe des solutions pour éviter le **context hell** :
- Utiliser des bibliothèques tierces comme Redux _(solution lourde, mais très puissante)_
- Créer un nouveau composant qui va regrouper tous les contextes _(solution plus légère, mais plus difficile à maintenir)_
N'étant pas un grand fan de Redux, je vais plutôt te présenter la deuxième solution.
Mais si tu veux en savoir plus sur Redux, n'hésite pas à consulter la documentation officielle !
### Résoudre le context hell avec un composant dédié
Parlons de ce fameux composant qui va regrouper tous les contextes !
On ne parle pas ici d'un simple composant Providers qui va imbriquer tous les Provider de nos contextes, mais d'une solution plus élégante.
Après tout, nous sommes des feignants développeurs, non ? 😏
Réfléchissons à ce que l'on veut faire :
- On veut pouvoir regrouper tous les contextes dans un seul composant.
- On veut pouvoir ajouter ou supprimer des contextes facilement.
- On veut pouvoir facilement les ordonner entre eux.
- On veut éviter le **context hell**.
Et si on créait un composant Providers qui va nous permettre de faire tout ça ?
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
const Providers = ({ providers, children }) => {
return (
<>
{/** Ouverture des providers */}
{children}
{/** Fermeture des providers */}
</>
);
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { ReactNode } from "react";
type ProvidersProps = {
providers: ReactNode[];
children: ReactNode;
};
const Providers = ({ providers, children }: ProvidersProps) => {
return (
<>
{/** Ouverture des providers */}
{children}
{/** Fermeture des providers */}
</>
);
};
```
{% /tab %}
{% /tabs %}
Ici on ne va pas remettre une cascade de Provider comme on a pu le voir plus tôt.
On va chercher à créer une fonction qui va nous permettre de les imbriquer les uns dans les autres.
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
const nest = (children: ReactNode, component: ReactNode) => {
return React.cloneElement(component, {}, children);
};
```
{% /tab %}
{% /tabs %}
{% callout type="note" title="React.cloneElement" %}
`React.cloneElement` est une fonction qui va permettre de cloner un élément React en lui passant de nouvelles propriétés.
Cela va nous permettre de créer une nouvelle arborescence de composants sans modifier l'arborescence actuelle.
Le premier argument est l'élément à cloner _(le composant)_, et le deuxième argument est un objet contenant les nouvelles propriétés.
Le troisième argument est le contenu de l'élément cloné _(les enfants)_.
{% /callout %}
Et maintenant, on va pouvoir utiliser notre fonction `nest` pour imbriquer nos Provider en utilisant la méthode `reduceRight` :
{% tabs defaultSelectedTab="jsx" %}
{% tab value="jsx" label="JSX" %}
```jsx
const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};
const Providers = ({ providers, children }) => {
return providers.reduceRight(nest, children);
};
```
{% /tab %}
{% tab value="tsx" label="TSX" %}
```tsx
import type { ReactNode } from "react";
type ProvidersProps = {
providers: ReactNode[];
children: ReactNode;
};
const nest = (children: ReactNode, component: ReactNode) => {
return React.cloneElement(component, {}, children);
};
const Providers = ({ providers, children }: ProvidersProps) => {
return providers.reduceRight(nest, children);
};
```
{% /tab %}
{% /tabs %}
{% callout type="note" title="reduceRight" %}
reduceRight est une méthode qui va permettre de réduire un tableau _(ou un objet)_ en appliquant une fonction de rappel de droite à gauche.
Cela va nous permettre de réduire un tableau de `Provider` en les imbriquant les uns dans les autres sans se soucier de l'ordre _(qui est défini par le tableau)_.
Dans l'idée, on commence par le **dernier** élément du tableau, et on l'imbrique avec l'élément **précédent** du tableau et ainsi de suite jusqu'au **premier** élément du tableau.
Chaque itération va créer un nouvel élément imbriqué dans le précédent, en appelant la fonction `nest` qui est passée en argument.
{% /callout %}
Et voilà ! Il ne nous reste plus qu'à utiliser notre composant `Providers` pour regrouper tous nos `Provider` :
```jsx
root.render(
<StrictMode>
<Providers
providers={[
<UserProvider />,
<ThemeProvider />,
<LanguageProvider />,
<PostProvider />,
<SettingsProvider />,
<SocketProvider />,
<FriendProvider />,
// ...
]}
>
<App />
</Providers>
</StrictMode>,
);
```
Évidemment le fichier contiendra toujours beaucoup de lignes, mais au moins, on a évité le **context hell** !
Il sera nettement plus facile de modifier l'ordre des Provider ou d'en ajouter de nouveaux.
## Conclusion
Ça casse un peu la tête, mais les contextes sont un outil très puissant pour diffuser des données dans nos applications React.
C'est aussi une excellente solution pour éviter d'utiliser des bibliothèques tierces comme Redux _(qui est très bien, mais qui peut être un peu lourd pour des petites applications)_.
On prendra d'ailleurs le temps de parler de Redux et de Zustand dans un prochain article 😉
Et si tu as besoin de plusieurs contextes dans ton application, n'oublie pas de réfléchir à l'utilisation de notre composant Providers pour éviter le **context hell**.

View File

@ -1,13 +0,0 @@
#!/bin/bash
# Variables
DB_USER="user"
DB_NAME="database"
BACKUP_DIR="/path/to/backup"
DATE=$(date +"%Y%m%d%H%M%S")
# Création du répertoire de sauvegarde
mkdir -p $BACKUP_DIR
# Sauvegarde de la base de données
pg_dump -U $DB_USER $DB_NAME > $BACKUP_DIR/$DB_NAME-$DATE.sql

View File

@ -1,5 +0,0 @@
# Ouvrir le fichier de tâches cron
crontab -e
# Ajouter la tâche de sauvegarde, toutes les nuits à minuit
0 * * * * /path/to/backup.sh

View File

@ -1,9 +0,0 @@
<div
className="iframe-container"
data-src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
data-width="1280"
data-height="720"
>
<img src="https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" loading="lazy" />
<button type="button" className="iframe-loader">Charger la vidéo</button>
</div>

View File

@ -1,7 +0,0 @@
<img
src="clairiere.jpg"
srcset="clairiere-480w.webp 480w, clairiere-800w.webp 800w"
sizes="(max-width: 480px) 100vw, (max-width: 800px) 50vw, 800px"
alt="Une clairière verdoyante"
loading="lazy"
/>

View File

@ -1,26 +0,0 @@
document.querySelectorAll('button.iframe-loader').forEach((button: HTMLButtonElement) => {
// Pour chaque bouton qui doit charger un iframe, on écoute le clic dessus
button.addEventListener('click', () => {
// On récupère le container de l'iframe, qui dans notre exemple est la balise parente du bouton
const container: HTMLElement | null = button.closest('.iframe-container');
// Si le container n'existe pas, on arrête l'exécution de la fonction pour éviter un plantage
if (!container) return;
const { src, width, height } = container.dataset as {
src: string,
width: string,
height: string
};
// On prépare notre iframe avec les données stockées dans le container
const iframe = document.createElement('iframe');
iframe.setAttribute('src', src);
iframe.setAttribute('width', width);
iframe.setAttribute('height', height);
// On supprime le contenu du container pour y ajouter notre iframe
container.innerHTML = '';
container.appendChild(iframe);
});
});

View File

@ -1,3 +0,0 @@
fetch("https://api.exemple.com/data")
.then((response) => response.json())
.then((data) => console.log(data));

View File

@ -1,7 +0,0 @@
$.ajax({
url: "https://api.exemple.com/data",
method: "GET",
success: function (data) {
console.log(data);
},
});

View File

@ -1,8 +0,0 @@
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.exemple.com/data", true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(JSON.parse(xhr.responseText));
}
};
xhr.send();

View File

@ -1,11 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name monsite.fr;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ; # On transmet l'adresse IP du client
proxy_set_header Host $host; # On transmet le nom de domaine
proxy_pass http://localhost:3000; # On redirige les requêtes vers le port 3000, où tourne notre application
}
}

View File

@ -1,22 +0,0 @@
import { initialState, actions, reducer } from "../reducers/counterReducer";
import { useReducer } from "react";
const Counter = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch(actions.increment())}>Increment</button>
<button onClick={() => dispatch(actions.decrement())}>Decrement</button>
<button onClick={() => dispatch(actions.reset())}>Reset</button>
<button onClick={() => dispatch(actions.set(10))}>Set counter to 10</button>
</div>
);
};
export default Counter;

View File

@ -1,22 +0,0 @@
import { initialState, actions, reducer } from "../reducers/counterReducer";
import { useReducer } from "react";
const Counter = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch(actions.increment())}>Increment</button>
<button onClick={() => dispatch(actions.decrement())}>Decrement</button>
<button onClick={() => dispatch(actions.reset())}>Reset</button>
<button onClick={() => dispatch(actions.set(10))}>Set counter to 10</button>
</div>
);
};
export default Counter;

View File

@ -1,30 +0,0 @@
const CounterActionTypes = {
INCREMENT: "INCREMENT",
DECREMENT: "DECREMENT",
RESET: "RESET",
SET: "SET",
};
export const initialState = { count: 0 };
export const reducer = (state, action) => {
switch (action.type) {
case CounterActionTypes.INCREMENT:
return { ...state, count: state.count + 1 };
case CounterActionTypes.DECREMENT:
return { ...state, count: state.count - 1 };
case CounterActionTypes.RESET:
return { ...state, count: 0 };
case CounterActionTypes.SET:
return { ...state, count: action.payload };
default:
return state;
}
};
export const actions = {
increment: () => ({ type: CounterActionTypes.INCREMENT }),
decrement: () => ({ type: CounterActionTypes.DECREMENT }),
reset: () => ({ type: CounterActionTypes.RESET }),
set: (value) => ({ type: CounterActionTypes.SET, payload: value }),
};

View File

@ -1,40 +0,0 @@
const enum CounterActionTypes {
INCREMENT = "INCREMENT",
DECREMENT = "DECREMENT",
RESET = "RESET",
SET = "SET",
}
type State = {
count: number;
};
type Action =
| { type: CounterActionTypes.INCREMENT }
| { type: CounterActionTypes.DECREMENT }
| { type: CounterActionTypes.RESET }
| { type: CounterActionTypes.SET; payload: number };
export const initialState: State = { count: 0 };
export const reducer = (state: State, action: Action) => {
switch (action.type) {
case CounterActionTypes.INCREMENT:
return { ...state, count: state.count + 1 };
case CounterActionTypes.DECREMENT:
return { ...state, count: state.count - 1 };
case CounterActionTypes.RESET:
return { ...state, count: 0 };
case CounterActionTypes.SET:
return { ...state, count: action.payload };
default:
return state;
}
};
export const actions = {
increment: (): Action => ({ type: CounterActionTypes.INCREMENT }),
decrement: (): Action => ({ type: CounterActionTypes.DECREMENT }),
reset: (): Action => ({ type: CounterActionTypes.RESET }),
set: (value: number): Action => ({ type: CounterActionTypes.SET, payload: value }),
};

View File

@ -1,6 +0,0 @@
export const actions = {
increment: () => ({ type: CounterActionTypes.INCREMENT }),
decrement: () => ({ type: CounterActionTypes.DECREMENT }),
reset: () => ({ type: CounterActionTypes.RESET }),
set: (value) => ({ type: CounterActionTypes.SET, payload: value }),
};

View File

@ -1,6 +0,0 @@
export const actions = {
increment: (): CounterAction => ({ type: CounterActionTypes.INCREMENT }),
decrement: (): CounterAction => ({ type: CounterActionTypes.DECREMENT }),
reset: (): CounterAction => ({ type: CounterActionTypes.RESET }),
set: (value: number): CounterAction => ({ type: CounterActionTypes.SET, payload: value }),
};

View File

@ -1,4 +0,0 @@
export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
export const RESET = "RESET";
export const SET = "SET";

View File

@ -1,6 +0,0 @@
export const CounterActionTypes = {
INCREMENT: "INCREMENT",
DECREMENT: "DECREMENT",
RESET: "RESET",
SET: "SET",
};

View File

@ -1,6 +0,0 @@
export const enum CounterActionTypes {
INCREMENT = "INCREMENT",
DECREMENT = "DECREMENT",
RESET = "RESET",
SET = "SET",
}

View File

@ -1,14 +0,0 @@
const reducer = (state: State, action: CounterAction) => {
switch (action.type) {
case CounterActionTypes.INCREMENT:
return { ...state, count: state.count + 1 };
case CounterActionTypes.DECREMENT:
return { ...state, count: state.count - 1 };
case CounterActionTypes.RESET:
return { ...state, count: 0 };
case CounterActionTypes.SET:
return { ...state, count: action.payload };
default:
return state;
}
};

View File

@ -1,6 +0,0 @@
export type CounterAction =
| { type: CounterActionTypes.INCREMENT }
| { type: CounterActionTypes.DECREMENT }
| { type: CounterActionTypes.RESET }
| { type: CounterActionTypes.SET; payload: number };

View File

@ -1,2 +0,0 @@
dispatch(actions.increment());
dispatch(actions.set(10));

View File

@ -1 +0,0 @@
dispatch({ type: "INCREMENT" });

View File

@ -1,10 +0,0 @@
const reducer = (state, action) => {
switch (action.type) {
case "TYPE_1":
return { ...state /* Nouvel état */ };
case "TYPE_2":
return { ...state /* Nouvel état */ };
default:
return state;
}
};

View File

@ -1,10 +0,0 @@
const reducer = (state: State, action: Action) => {
switch (action.type) {
case "TYPE_1":
return { ...state /* Nouvel état */ };
case "TYPE_2":
return { ...state /* Nouvel état */ };
default:
return state;
}
};

View File

@ -1 +0,0 @@
const [state, dispatch] = useReducer(reducer, initialState);

View File

@ -1 +0,0 @@
const [state, dispatch] = useReducer<State, Action>(reducer, initialState);

View File

@ -1 +0,0 @@
const initialState = { count: 0 };

Some files were not shown because too many files have changed in this diff Show More