Merge pull request 'rework/lightweight' (#12) from rework/lightweight into main
Some checks failed
Update Memento Dev on VPS / deploy (push) Has been cancelled
Some checks failed
Update Memento Dev on VPS / deploy (push) Has been cancelled
Reviewed-on: #12
This commit is contained in:
commit
64b36c816f
@ -1,4 +0,0 @@
|
||||
**/node_modules/
|
||||
**/.pnpm-store/
|
||||
**/dist/
|
||||
.git
|
||||
@ -1,3 +1,2 @@
|
||||
PORT=5500
|
||||
HMR_PORT=5501
|
||||
NODE_ENV=development
|
||||
HMR_PORT=5501
|
||||
@ -41,7 +41,7 @@ jobs:
|
||||
VPS_PATH: ${{ secrets.VPS_PATH }}
|
||||
VPS_PORT: ${{ secrets.VPS_PORT }}
|
||||
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."
|
||||
|
||||
- name: Start the application
|
||||
@ -51,5 +51,5 @@ jobs:
|
||||
VPS_PATH: ${{ secrets.VPS_PATH }}
|
||||
VPS_PORT: ${{ secrets.VPS_PORT }}
|
||||
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."
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,6 +1,12 @@
|
||||
/.env
|
||||
**/*~lock*
|
||||
|
||||
app-old/.pnpm-store
|
||||
app-old/node_modules
|
||||
app-old/dist
|
||||
app-old/public/sitemap.xml
|
||||
|
||||
app/.pnpm-store
|
||||
app/node_modules
|
||||
app/dist
|
||||
app/public/sitemap.xml
|
||||
**/*~lock*
|
||||
app/public/sitemap.xml
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"biome.searchInPath": false,
|
||||
"biome.lspBin": "app/node_modules/@biomejs/biome/bin/biome",
|
||||
}
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
49
README.md
Normal 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
9
app/.env
Normal file → Executable file
@ -1,7 +1,4 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
# Google Analytics
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
|
||||
# See the documentation https://support.google.com/analytics/answer/9304153?hl=en#zippy=%2Cweb
|
||||
PUBLIC_ENV__GOOGLE_ANALYTICS="G-XXXXXXXXXX"
|
||||
|
||||
149
app/.gitignore
vendored
Executable file
149
app/.gitignore
vendored
Executable 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
|
||||
@ -1 +0,0 @@
|
||||
data/**/*.md
|
||||
0
app/.prettierrc
Normal file → Executable file
0
app/.prettierrc
Normal file → Executable file
22
app/README.md
Normal file → Executable file
22
app/README.md
Normal file → Executable 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
|
||||
pnpm create vike@latest --react --tailwindcss --telefunc --fastify --eslint --prettier
|
||||
bun create vike@latest --solid --tailwindcss --telefunc --fastify --google-analytics --eslint --prettier --biome
|
||||
```
|
||||
|
||||
## 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)
|
||||
* [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).
|
||||
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`
|
||||
|
||||
|
||||
11
app/buildPublicUrl.ts
Normal file
11
app/buildPublicUrl.ts
Normal 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
1334
app/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
47
app/components/Button.tsx
Normal file
47
app/components/Button.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
68
app/components/Callout.tsx
Normal file
68
app/components/Callout.tsx
Normal 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
49
app/components/Form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
app/components/Highlight.tsx
Normal file
117
app/components/Highlight.tsx
Normal 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
84
app/components/Icon.tsx
Normal 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
23
app/components/Iframe.tsx
Normal 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
17
app/components/Image.tsx
Normal 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
37
app/components/Link.tsx
Executable 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
42
app/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
app/components/PrevNextLinks.tsx
Normal file
130
app/components/PrevNextLinks.tsx
Normal 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
32
app/components/Prose.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
app/components/QuickLinks.tsx
Normal file
42
app/components/QuickLinks.tsx
Normal 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
351
app/components/Search.tsx
Normal 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 “
|
||||
<span class="break-words text-slate-900">{query()}</span>
|
||||
”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
114
app/components/SmoothScroll.tsx
Normal file
114
app/components/SmoothScroll.tsx
Normal 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
156
app/components/Snippet.tsx
Normal 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
131
app/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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" };
|
||||
}
|
||||
@ -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‘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'est nous...</span>
|
||||
<br />
|
||||
<span className="font-bold text-lg">les cookies ! 🍪</span>
|
||||
</p>
|
||||
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
On ne t‘embête pas longtemps, on te laisse même le choix <em>(si ça c‘est pas la classe 😎)</em>.
|
||||
</p>
|
||||
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Si tu veux en savoir plus, tu peux consulter la page{" "}
|
||||
<Link href="/politique-de-confidentialite" className="font-bold">
|
||||
Politique de confidentialité
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-center grid-cols-3 justify-between bg-slate-100 dark:bg-slate-700">
|
||||
<button
|
||||
className="cursor-pointer px-2 py-1 text-slate-600 dark:text-slate-300"
|
||||
onClick={() => 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‘ai faim !
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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" />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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 “
|
||||
<span className="break-words text-slate-900 dark:text-white">{query}</span>
|
||||
”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
42
app/config.ts
Normal 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,
|
||||
};
|
||||
@ -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: () => {},
|
||||
});
|
||||
@ -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
|
||||
@ -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)_
|
||||
@ -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 %}
|
||||
@ -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 ! 😄
|
||||
@ -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 !
|
||||
@ -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**.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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"
|
||||
/>
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
fetch("https://api.exemple.com/data")
|
||||
.then((response) => response.json())
|
||||
.then((data) => console.log(data));
|
||||
7
app/data/snippets/js/xhr/jquery-ajax.js
vendored
7
app/data/snippets/js/xhr/jquery-ajax.js
vendored
@ -1,7 +0,0 @@
|
||||
$.ajax({
|
||||
url: "https://api.exemple.com/data",
|
||||
method: "GET",
|
||||
success: function (data) {
|
||||
console.log(data);
|
||||
},
|
||||
});
|
||||
@ -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();
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 }),
|
||||
};
|
||||
@ -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 }),
|
||||
};
|
||||
@ -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 }),
|
||||
};
|
||||
@ -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 }),
|
||||
};
|
||||
@ -1,4 +0,0 @@
|
||||
export const INCREMENT = "INCREMENT";
|
||||
export const DECREMENT = "DECREMENT";
|
||||
export const RESET = "RESET";
|
||||
export const SET = "SET";
|
||||
@ -1,6 +0,0 @@
|
||||
export const CounterActionTypes = {
|
||||
INCREMENT: "INCREMENT",
|
||||
DECREMENT: "DECREMENT",
|
||||
RESET: "RESET",
|
||||
SET: "SET",
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
export const enum CounterActionTypes {
|
||||
INCREMENT = "INCREMENT",
|
||||
DECREMENT = "DECREMENT",
|
||||
RESET = "RESET",
|
||||
SET = "SET",
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
|
||||
export type CounterAction =
|
||||
| { type: CounterActionTypes.INCREMENT }
|
||||
| { type: CounterActionTypes.DECREMENT }
|
||||
| { type: CounterActionTypes.RESET }
|
||||
| { type: CounterActionTypes.SET; payload: number };
|
||||
@ -1,2 +0,0 @@
|
||||
dispatch(actions.increment());
|
||||
dispatch(actions.set(10));
|
||||
@ -1 +0,0 @@
|
||||
dispatch({ type: "INCREMENT" });
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
@ -1 +0,0 @@
|
||||
const [state, dispatch] = useReducer<State, Action>(reducer, initialState);
|
||||
@ -1 +0,0 @@
|
||||
const initialState = { count: 0 };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user