odoro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/client.d.ts +84 -0
- package/dist/build-SNTGJH2J.js +4 -0
- package/dist/chunk-2YDI5NKV.js +55 -0
- package/dist/chunk-G2WW7N4C.js +1872 -0
- package/dist/chunk-G5QXVBYT.js +1139 -0
- package/dist/chunk-JMEHF3KN.js +35 -0
- package/dist/chunk-PEMYUK2D.js +64 -0
- package/dist/chunk-T42X2NJN.js +98 -0
- package/dist/chunk-T6RLHSCW.js +123 -0
- package/dist/chunk-UGRSODHU.js +154 -0
- package/dist/cli.d.ts +40 -0
- package/dist/cli.js +236 -0
- package/dist/commands-FVAHVVVN.js +793 -0
- package/dist/commands-V44Y5G4F.js +245 -0
- package/dist/create-SH2M722Y.js +288 -0
- package/dist/index.d.ts +354 -0
- package/dist/index.js +7 -0
- package/dist/package-AUINPBEX.js +62 -0
- package/dist/preview-LOAO5Y6V.js +3 -0
- package/dist/registry/index.d.ts +316 -0
- package/dist/registry/index.js +2 -0
- package/dist/server-MZ76LPAG.js +3 -0
- package/package.json +57 -0
- package/templates/react-ts/README.md +52 -0
- package/templates/react-ts/_gitignore +8 -0
- package/templates/react-ts/index.html +13 -0
- package/templates/react-ts/odoro.config.ts +10 -0
- package/templates/react-ts/package.json +23 -0
- package/templates/react-ts/public/favicon.svg +4 -0
- package/templates/react-ts/src/App.tsx +66 -0
- package/templates/react-ts/src/main.tsx +18 -0
- package/templates/react-ts/src/odoro-env.d.ts +1 -0
- package/templates/react-ts/src/routes/About.tsx +19 -0
- package/templates/react-ts/src/routes/Home.tsx +80 -0
- package/templates/react-ts/src/routes/NotFound.tsx +14 -0
- package/templates/react-ts/src/styles.css +13 -0
- package/templates/react-ts/tsconfig.json +25 -0
- package/templates/react-ts-server/Dockerfile +51 -0
- package/templates/react-ts-server/README.md +87 -0
- package/templates/react-ts-server/_dockerignore +8 -0
- package/templates/react-ts-server/_env.example +78 -0
- package/templates/react-ts-server/_gitignore +8 -0
- package/templates/react-ts-server/client/index.html +13 -0
- package/templates/react-ts-server/client/public/favicon.svg +4 -0
- package/templates/react-ts-server/client/src/App.tsx +66 -0
- package/templates/react-ts-server/client/src/main.tsx +18 -0
- package/templates/react-ts-server/client/src/odoro-env.d.ts +1 -0
- package/templates/react-ts-server/client/src/routes/About.tsx +19 -0
- package/templates/react-ts-server/client/src/routes/Home.tsx +148 -0
- package/templates/react-ts-server/client/src/routes/NotFound.tsx +14 -0
- package/templates/react-ts-server/client/src/styles.css +13 -0
- package/templates/react-ts-server/odoro.config.ts +21 -0
- package/templates/react-ts-server/package.json +33 -0
- package/templates/react-ts-server/scripts/dev.mjs +74 -0
- package/templates/react-ts-server/server/src/main.ts +131 -0
- package/templates/react-ts-server/server/src/modules/health/index.ts +144 -0
- package/templates/react-ts-server/server/tsconfig.json +23 -0
- package/templates/react-ts-server/tsconfig.json +27 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Reveal, Stagger, useAnimate } from '@odoro-cli/libs/motion'
|
|
4
|
+
import { Link } from '@odoro-cli/libs/router'
|
|
5
|
+
import { Button } from '@odoro-cli/libs/ui'
|
|
6
|
+
|
|
7
|
+
/** Trois arguments mis en avant sur la page d'accueil. */
|
|
8
|
+
const FEATURES = [
|
|
9
|
+
{
|
|
10
|
+
title: 'Routeur',
|
|
11
|
+
body: 'Segments dynamiques, routes imbriquees, chargement paresseux et transitions de page.',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
title: 'Animations',
|
|
15
|
+
body: "Une couche mince sur le moteur du navigateur. Les animations reduites sont respectees d'office.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
title: 'Styles',
|
|
19
|
+
body: 'Des tokens en source de verite, une feuille statique, aucun scan a l execution.',
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** Page d'accueil. */
|
|
24
|
+
export function Home() {
|
|
25
|
+
const [ref, controls] = useAnimate<HTMLSpanElement>()
|
|
26
|
+
const [count, setCount] = useState(0)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="o-flex o-flex-col o-gap-12">
|
|
30
|
+
<Reveal>
|
|
31
|
+
<h1 className="o-text-4xl o-font-bold o-tracking-tight">
|
|
32
|
+
Un point de depart maitrise.
|
|
33
|
+
</h1>
|
|
34
|
+
<p className="o-mt-4 o-text-lg o-text-zinc-500 dark:o-text-zinc-400 o-max-w-prose">
|
|
35
|
+
Ce projet a ete genere par <code className="o-font-mono">odoro create</code>. Le
|
|
36
|
+
routeur, le moteur d animation et le systeme de style viennent tous de la meme
|
|
37
|
+
librairie.
|
|
38
|
+
</p>
|
|
39
|
+
</Reveal>
|
|
40
|
+
|
|
41
|
+
<Stagger step={80} className="o-grid o-grid-cols-1 md:o-grid-cols-3 o-gap-4">
|
|
42
|
+
{FEATURES.map((feature) => (
|
|
43
|
+
<article
|
|
44
|
+
key={feature.title}
|
|
45
|
+
className="o-rounded-lg o-border-w-1 o-border-zinc-200 dark:o-border-zinc-800 o-bg-white dark:o-bg-zinc-900 o-p-5"
|
|
46
|
+
>
|
|
47
|
+
<h2 className="o-font-semibold">{feature.title}</h2>
|
|
48
|
+
<p className="o-mt-2 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
|
|
49
|
+
{feature.body}
|
|
50
|
+
</p>
|
|
51
|
+
</article>
|
|
52
|
+
))}
|
|
53
|
+
</Stagger>
|
|
54
|
+
|
|
55
|
+
<div className="o-flex o-items-center o-gap-4">
|
|
56
|
+
<Button
|
|
57
|
+
onClick={() => {
|
|
58
|
+
setCount((value) => value + 1)
|
|
59
|
+
void controls.play(
|
|
60
|
+
[
|
|
61
|
+
{ transform: 'translateY(0)' },
|
|
62
|
+
{ transform: 'translateY(-0.4rem)' },
|
|
63
|
+
{ transform: 'translateY(0)' },
|
|
64
|
+
],
|
|
65
|
+
{ duration: 'fast', easing: 'emphasized' },
|
|
66
|
+
)
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
Compter
|
|
70
|
+
</Button>
|
|
71
|
+
<span ref={ref} className="o-inline-block o-text-lg o-tabular-nums">
|
|
72
|
+
{count}
|
|
73
|
+
</span>
|
|
74
|
+
<Link to="/a-propos" className="o-text-sm">
|
|
75
|
+
En savoir plus
|
|
76
|
+
</Link>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Link } from '@odoro-cli/libs/router'
|
|
2
|
+
|
|
3
|
+
/** Page affichee pour toute route inconnue. */
|
|
4
|
+
export function NotFound() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="o-flex o-flex-col o-gap-4" role="alert">
|
|
7
|
+
<h1 className="o-text-3xl o-font-bold">404</h1>
|
|
8
|
+
<p className="o-text-zinc-500 dark:o-text-zinc-400">Cette page n existe pas.</p>
|
|
9
|
+
<p>
|
|
10
|
+
<Link to="/">Retour a l accueil</Link>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Styles propres a l'application.
|
|
2
|
+
Tout le reste vient des tokens Odoro : surcharger une variable ci-dessous
|
|
3
|
+
suffit a rethemer l'ensemble, composants de la librairie compris. */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--o-color-primary: oklch(52.4% 0.212 275);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.app-shell {
|
|
10
|
+
min-height: 100dvh;
|
|
11
|
+
display: grid;
|
|
12
|
+
grid-template-rows: auto 1fr auto;
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"noUncheckedIndexedAccess": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"skipLibCheck": true,
|
|
19
|
+
"paths": {
|
|
20
|
+
"@/*": ["./src/*"]
|
|
21
|
+
},
|
|
22
|
+
"baseUrl": "."
|
|
23
|
+
},
|
|
24
|
+
"include": ["src", "odoro.config.ts"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# --- Etape 1 : dependances -----------------------------------------------
|
|
4
|
+
# Isolee pour que le cache de couches survive a toute modification du code :
|
|
5
|
+
# seule une modification du manifeste relance l'installation.
|
|
6
|
+
FROM node:22-alpine AS deps
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
9
|
+
RUN if [ -f pnpm-lock.yaml ]; then \
|
|
10
|
+
corepack enable && pnpm install --frozen-lockfile; \
|
|
11
|
+
elif [ -f yarn.lock ]; then \
|
|
12
|
+
corepack enable && yarn install --immutable; \
|
|
13
|
+
else \
|
|
14
|
+
npm ci; \
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# --- Etape 2 : compilation ------------------------------------------------
|
|
18
|
+
FROM node:22-alpine AS build
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
21
|
+
COPY . .
|
|
22
|
+
RUN npm run build
|
|
23
|
+
|
|
24
|
+
# --- Etape 3 : dependances de production ----------------------------------
|
|
25
|
+
# Reinstallees a part : l'image finale ne doit pas embarquer la chaine de
|
|
26
|
+
# compilation, qui pese souvent plus que l'application elle-meme.
|
|
27
|
+
FROM node:22-alpine AS runtime-deps
|
|
28
|
+
WORKDIR /app
|
|
29
|
+
COPY package.json package-lock.json* ./
|
|
30
|
+
RUN npm ci --omit=dev || npm install --omit=dev
|
|
31
|
+
|
|
32
|
+
# --- Etape 4 : image finale -----------------------------------------------
|
|
33
|
+
FROM node:22-alpine AS runtime
|
|
34
|
+
WORKDIR /app
|
|
35
|
+
ENV NODE_ENV=production
|
|
36
|
+
ENV PORT=3001
|
|
37
|
+
|
|
38
|
+
# Un processus applicatif n'a aucune raison de tourner en root.
|
|
39
|
+
RUN addgroup -S odoro && adduser -S odoro -G odoro
|
|
40
|
+
|
|
41
|
+
COPY --from=runtime-deps --chown=odoro:odoro /app/node_modules ./node_modules
|
|
42
|
+
COPY --from=build --chown=odoro:odoro /app/dist ./dist
|
|
43
|
+
COPY --chown=odoro:odoro package.json ./
|
|
44
|
+
|
|
45
|
+
USER odoro
|
|
46
|
+
EXPOSE 3001
|
|
47
|
+
|
|
48
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
49
|
+
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3001)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
50
|
+
|
|
51
|
+
CMD ["node", "dist/server/main.js"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Application Odoro — client et serveur
|
|
2
|
+
|
|
3
|
+
Une application monopage React et une API bâtie sur `@odoro-cli/server`, dans le
|
|
4
|
+
même dépôt.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
client/ interface, servie par Odoro en developpement
|
|
8
|
+
server/
|
|
9
|
+
src/main.ts assemble les modules, rien d'autre
|
|
10
|
+
src/modules/ un dossier par module — commencez par `health/`
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Demarrer
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
cp .env.example .env
|
|
17
|
+
npm install
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Le client ecoute sur <http://localhost:5180> et transmet au serveur tout appel
|
|
22
|
+
commencant par `/api`. Le navigateur ne voit donc qu'une seule origine, et
|
|
23
|
+
aucune question de CORS ne se pose en developpement.
|
|
24
|
+
|
|
25
|
+
## La base de donnees
|
|
26
|
+
|
|
27
|
+
**PostgreSQL, heberge, et rien d'autre.** Il n'y a pas de base locale : l'URL
|
|
28
|
+
pointe sur une base joignable par le reseau.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
odoro db:create # provisionne une base et ecrit .env
|
|
32
|
+
# ou collez votre propre URL dans .env :
|
|
33
|
+
DATABASE_URL=postgres://utilisateur:motdepasse@hote:5432/base?sslmode=require
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Tant qu'elle est absente, **le client demarre quand meme** et le serveur repond
|
|
37
|
+
`503` sur `/api/ready` en disant precisement ce qui manque. On voit donc
|
|
38
|
+
l'interface des la premiere minute, et on sait ce qu'il reste a faire.
|
|
39
|
+
|
|
40
|
+
## Ecrire un module
|
|
41
|
+
|
|
42
|
+
`server/src/modules/health/` est l'exemple. Un module declare son nom, ce dont
|
|
43
|
+
il a besoin, les services qu'il enregistre et les routes qu'il expose — et
|
|
44
|
+
`main.ts` ne fait que dire lesquels sont actifs.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
export const billingModule = defineModule({
|
|
48
|
+
name: 'billing',
|
|
49
|
+
requires: ['auth'],
|
|
50
|
+
register: (c) => c.register('billingService', createBillingService),
|
|
51
|
+
routes: billingRoutes,
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Le noyau resout l'ordre de chargement depuis `requires`, detecte les cycles, et
|
|
56
|
+
refuse de demarrer si une dependance manque. Activer ou desactiver un module
|
|
57
|
+
tient donc en une ligne dans `main.ts`.
|
|
58
|
+
|
|
59
|
+
## Deux points de controle, qui ne disent pas la meme chose
|
|
60
|
+
|
|
61
|
+
| Route | Question | Qui l'interroge |
|
|
62
|
+
| ------------- | ------------------------------- | ------------------------------------------------ |
|
|
63
|
+
| `/api/health` | Le processus vit-il ? | L'orchestrateur, pour decider de redemarrer |
|
|
64
|
+
| `/api/ready` | Le service peut-il travailler ? | Le repartiteur, pour decider d'envoyer du trafic |
|
|
65
|
+
|
|
66
|
+
Les confondre donne l'un des deux defauts : un service qui redemarre en boucle
|
|
67
|
+
pendant un incident de base, ou un repartiteur qui envoie du trafic a un
|
|
68
|
+
service incapable de repondre.
|
|
69
|
+
|
|
70
|
+
## Compiler et deployer
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npm run build # client dans dist/client, serveur dans dist/server
|
|
74
|
+
npm start # sert les deux depuis un seul processus
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Le `Dockerfile` est multi-etapes : les dependances de compilation ne se
|
|
78
|
+
retrouvent pas dans l'image finale, qui tourne sous un utilisateur sans
|
|
79
|
+
privileges.
|
|
80
|
+
|
|
81
|
+
## Variables d'environnement
|
|
82
|
+
|
|
83
|
+
Voir `.env.example`, commente ligne par ligne. En production, ce qui manque
|
|
84
|
+
arrete le demarrage avec un message qui **liste tout d'un coup** — plutot
|
|
85
|
+
qu'une suite de redemarrages, une variable a la fois.
|
|
86
|
+
|
|
87
|
+
`.env` n'est jamais versionne.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copier ce fichier en `.env` et ajuster les valeurs.
|
|
2
|
+
#
|
|
3
|
+
# Toute variable manquante ou invalide arrete le serveur au demarrage, avec un
|
|
4
|
+
# message qui les liste toutes d'un coup : mieux vaut un echec immediat qu'une
|
|
5
|
+
# panne a la centieme requete.
|
|
6
|
+
#
|
|
7
|
+
# Ce fichier est versionne. `.env` ne l'est jamais.
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Base de donnees
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
# PostgreSQL, et rien d'autre. Aucune base locale : l'URL pointe sur une base
|
|
14
|
+
# hebergee, la votre ou celle provisionnee par `odoro db:create`.
|
|
15
|
+
#
|
|
16
|
+
# postgres://utilisateur:motdepasse@hote:5432/base?sslmode=require
|
|
17
|
+
#
|
|
18
|
+
# Tant qu'elle est absente, le client demarre et le serveur repond 503 sur
|
|
19
|
+
# /api/ready en disant ce qui manque.
|
|
20
|
+
DATABASE_URL=
|
|
21
|
+
|
|
22
|
+
# Replica de lecture, optionnel. Le routage vers lui reste explicite : jamais
|
|
23
|
+
# de bascule automatique, le decalage de replication produit des defauts
|
|
24
|
+
# incomprehensibles.
|
|
25
|
+
# DATABASE_REPLICA_URL=
|
|
26
|
+
|
|
27
|
+
# Taille du pool de connexions. Laisser vide pour le defaut de l'environnement.
|
|
28
|
+
# DATABASE_POOL_MAX=
|
|
29
|
+
|
|
30
|
+
# Desactive TLS. A ne poser que sur une base strictement locale a la machine,
|
|
31
|
+
# jamais sur une base joignable par le reseau.
|
|
32
|
+
# DATABASE_TLS_DISABLED=false
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Session
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# Au moins 32 caracteres. Un secret court se retrouve par force brute, et le
|
|
39
|
+
# jour ou c'est arrive, toutes les sessions emises depuis le premier jour sont
|
|
40
|
+
# forgeables.
|
|
41
|
+
#
|
|
42
|
+
# node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
|
43
|
+
SESSION_SECRET=
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Serveur
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Environnement : development | test | production
|
|
50
|
+
NODE_ENV=development
|
|
51
|
+
|
|
52
|
+
# Port d'ecoute de l'API. Le client de developpement ecoute sur 5180 et lui
|
|
53
|
+
# transmet les appels commencant par /api.
|
|
54
|
+
PORT=3001
|
|
55
|
+
|
|
56
|
+
# URL publique du serveur, employee dans les liens des courriels.
|
|
57
|
+
APP_URL=http://localhost:3001
|
|
58
|
+
|
|
59
|
+
# Origines autorisees, separees par des virgules. Vide = aucune origine
|
|
60
|
+
# tierce. Jamais `*` avec des identifiants.
|
|
61
|
+
ALLOWED_ORIGINS=
|
|
62
|
+
|
|
63
|
+
# Plafond de la taille des corps de requete.
|
|
64
|
+
BODY_LIMIT=1mb
|
|
65
|
+
|
|
66
|
+
# Delai au-dela duquel l'arret propre cesse d'attendre les requetes en cours.
|
|
67
|
+
SHUTDOWN_TIMEOUT=15s
|
|
68
|
+
|
|
69
|
+
# Niveau de journalisation : fatal | error | warn | info | debug | trace
|
|
70
|
+
LOG_LEVEL=info
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Files d'attente et cache
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# Optionnel. Absente, le cache vit en memoire et les taches s'executent en
|
|
77
|
+
# ligne — ce qui convient a une instance unique, et pas au-dela.
|
|
78
|
+
# REDIS_URL=
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
|
+
<title>Odoro</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Link, Outlet, Route, Router, Routes, useLocation } from '@odoro-cli/libs/router'
|
|
2
|
+
|
|
3
|
+
import { About } from '@/routes/About'
|
|
4
|
+
import { Home } from '@/routes/Home'
|
|
5
|
+
import { NotFound } from '@/routes/NotFound'
|
|
6
|
+
|
|
7
|
+
/** Barre de navigation, avec mise en valeur de la route courante. */
|
|
8
|
+
function Nav() {
|
|
9
|
+
const { pathname } = useLocation()
|
|
10
|
+
|
|
11
|
+
const links = [
|
|
12
|
+
{ to: '/', label: 'Accueil' },
|
|
13
|
+
{ to: '/a-propos', label: 'A propos' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<nav className="o-flex o-items-center o-gap-4 o-px-6 o-py-4 o-border-b o-border-zinc-200 dark:o-border-zinc-800">
|
|
18
|
+
<span className="o-font-semibold o-text-brand-600 dark:o-text-brand-400">
|
|
19
|
+
Odoro
|
|
20
|
+
</span>
|
|
21
|
+
{links.map((link) => (
|
|
22
|
+
<Link
|
|
23
|
+
key={link.to}
|
|
24
|
+
to={link.to}
|
|
25
|
+
className={
|
|
26
|
+
pathname === link.to
|
|
27
|
+
? 'o-text-zinc-900 dark:o-text-zinc-50 o-font-medium o-no-underline'
|
|
28
|
+
: 'o-text-zinc-500 dark:o-text-zinc-400 o-no-underline hover:o-text-zinc-900 dark:hover:o-text-zinc-50 o-transition'
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
{link.label}
|
|
32
|
+
</Link>
|
|
33
|
+
))}
|
|
34
|
+
</nav>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Enveloppe commune a toutes les pages. */
|
|
39
|
+
function Layout() {
|
|
40
|
+
return (
|
|
41
|
+
<div className="app-shell">
|
|
42
|
+
<Nav />
|
|
43
|
+
<main className="o-view-transition-page o-mx-auto o-w-full o-max-w-3xl o-px-6 o-py-12">
|
|
44
|
+
<Outlet />
|
|
45
|
+
</main>
|
|
46
|
+
<footer className="o-px-6 o-py-6 o-text-sm o-text-zinc-500 dark:o-text-zinc-400 o-border-t o-border-zinc-200 dark:o-border-zinc-800">
|
|
47
|
+
Construit avec Odoro.
|
|
48
|
+
</footer>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Racine de l'application. */
|
|
54
|
+
export function App() {
|
|
55
|
+
return (
|
|
56
|
+
<Router>
|
|
57
|
+
<Routes>
|
|
58
|
+
<Route path="/" element={<Layout />}>
|
|
59
|
+
<Route index element={<Home />} />
|
|
60
|
+
<Route path="a-propos" element={<About />} />
|
|
61
|
+
<Route path="*" element={<NotFound />} />
|
|
62
|
+
</Route>
|
|
63
|
+
</Routes>
|
|
64
|
+
</Router>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
|
|
4
|
+
import { App } from '@/App'
|
|
5
|
+
|
|
6
|
+
import '@odoro-cli/libs/styles.css'
|
|
7
|
+
import '@/styles.css'
|
|
8
|
+
|
|
9
|
+
const container = document.getElementById('root')
|
|
10
|
+
if (container === null) {
|
|
11
|
+
throw new Error('Element racine "#root" introuvable dans index.html.')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createRoot(container).render(
|
|
15
|
+
<StrictMode>
|
|
16
|
+
<App />
|
|
17
|
+
</StrictMode>,
|
|
18
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="odoro/client" />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Reveal } from '@odoro-cli/libs/motion'
|
|
2
|
+
import { Link } from '@odoro-cli/libs/router'
|
|
3
|
+
|
|
4
|
+
/** Page « a propos », qui demontre la navigation entre routes. */
|
|
5
|
+
export function About() {
|
|
6
|
+
return (
|
|
7
|
+
<Reveal className="o-flex o-flex-col o-gap-6">
|
|
8
|
+
<h1 className="o-text-3xl o-font-bold o-tracking-tight">A propos</h1>
|
|
9
|
+
<p className="o-text-zinc-500 dark:o-text-zinc-400 o-max-w-prose">
|
|
10
|
+
Cette page est servie par le routeur d Odoro. La navigation ne recharge pas le
|
|
11
|
+
document : elle passe par l historique du navigateur, restaure la position de
|
|
12
|
+
defilement et joue une transition de page quand le navigateur la prend en charge.
|
|
13
|
+
</p>
|
|
14
|
+
<p>
|
|
15
|
+
<Link to="/">Retour a l accueil</Link>
|
|
16
|
+
</p>
|
|
17
|
+
</Reveal>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Reveal, Stagger } from '@odoro-cli/libs/motion'
|
|
4
|
+
import { Link } from '@odoro-cli/libs/router'
|
|
5
|
+
import { Button } from '@odoro-cli/libs/ui'
|
|
6
|
+
|
|
7
|
+
/** Reponse de la route de sante du serveur. */
|
|
8
|
+
interface Health {
|
|
9
|
+
status: string
|
|
10
|
+
environment: string
|
|
11
|
+
uptime: number
|
|
12
|
+
timestamp: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Etat de l'appel a l'API. */
|
|
16
|
+
type Fetching =
|
|
17
|
+
| { state: 'inactif' }
|
|
18
|
+
| { state: 'en-cours' }
|
|
19
|
+
| { state: 'ok'; health: Health }
|
|
20
|
+
| { state: 'echec'; message: string }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Interroge la route de sante du serveur.
|
|
24
|
+
*
|
|
25
|
+
* L'URL est relative : en developpement le moteur transmet l'appel au serveur,
|
|
26
|
+
* en production c'est le meme serveur qui repond. Il n'y a donc jamais d'URL
|
|
27
|
+
* d'API a configurer cote client.
|
|
28
|
+
*/
|
|
29
|
+
async function fetchHealth(signal: AbortSignal): Promise<Health> {
|
|
30
|
+
const response = await fetch('/api/health', { signal })
|
|
31
|
+
if (!response.ok) throw new Error(`Le serveur a repondu ${response.status}.`)
|
|
32
|
+
return (await response.json()) as Health
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Panneau montrant l'etat de la liaison avec l'API. */
|
|
36
|
+
function ApiStatus() {
|
|
37
|
+
const [result, setResult] = useState<Fetching>({ state: 'inactif' })
|
|
38
|
+
const [attempt, setAttempt] = useState(0)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const controller = new AbortController()
|
|
42
|
+
setResult({ state: 'en-cours' })
|
|
43
|
+
|
|
44
|
+
fetchHealth(controller.signal).then(
|
|
45
|
+
(health) => setResult({ state: 'ok', health }),
|
|
46
|
+
(cause: unknown) => {
|
|
47
|
+
if (controller.signal.aborted) return
|
|
48
|
+
setResult({
|
|
49
|
+
state: 'echec',
|
|
50
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return () => controller.abort()
|
|
56
|
+
}, [attempt])
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section className="o-rounded-lg o-border-w-1 o-border-zinc-200 dark:o-border-zinc-800 o-bg-white dark:o-bg-zinc-900 o-p-5">
|
|
60
|
+
<div className="o-flex o-items-center o-justify-between o-gap-4">
|
|
61
|
+
<h2 className="o-font-semibold">Liaison avec l’API</h2>
|
|
62
|
+
<Button
|
|
63
|
+
size="sm"
|
|
64
|
+
tone="secondary"
|
|
65
|
+
loading={result.state === 'en-cours'}
|
|
66
|
+
onClick={() => setAttempt((value) => value + 1)}
|
|
67
|
+
>
|
|
68
|
+
Reinterroger
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div className="o-mt-3 o-text-sm" role="status">
|
|
73
|
+
{result.state === 'inactif' || result.state === 'en-cours' ? (
|
|
74
|
+
<p className="o-text-zinc-500 dark:o-text-zinc-400">
|
|
75
|
+
Interrogation de /api/health...
|
|
76
|
+
</p>
|
|
77
|
+
) : result.state === 'echec' ? (
|
|
78
|
+
<p className="o-text-red-600 dark:o-text-red-400">
|
|
79
|
+
Serveur injoignable : {result.message}. Verifiez qu’il tourne bien sur
|
|
80
|
+
le port 3001.
|
|
81
|
+
</p>
|
|
82
|
+
) : (
|
|
83
|
+
<dl className="o-grid o-grid-cols-2 o-gap-2 o-tabular-nums">
|
|
84
|
+
<dt className="o-text-zinc-500 dark:o-text-zinc-400">Etat</dt>
|
|
85
|
+
<dd className="o-text-emerald-600 dark:o-text-emerald-400">
|
|
86
|
+
{result.health.status}
|
|
87
|
+
</dd>
|
|
88
|
+
<dt className="o-text-zinc-500 dark:o-text-zinc-400">Environnement</dt>
|
|
89
|
+
<dd>{result.health.environment}</dd>
|
|
90
|
+
<dt className="o-text-zinc-500 dark:o-text-zinc-400">En ligne depuis</dt>
|
|
91
|
+
<dd>{result.health.uptime} s</dd>
|
|
92
|
+
</dl>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</section>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Trois arguments mis en avant sur la page d'accueil. */
|
|
100
|
+
const FEATURES = [
|
|
101
|
+
{
|
|
102
|
+
title: 'Une seule origine',
|
|
103
|
+
body: 'Les appels /api sont transmis au serveur : aucune question de CORS, aucune URL a configurer.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
title: 'Un seul deploiement',
|
|
107
|
+
body: 'En production, le serveur sert l API et le client compile. Une image, un processus.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
title: 'Un environnement verifie',
|
|
111
|
+
body: 'Les variables sont validees au demarrage. Une configuration incomplete arrete le serveur tout de suite.',
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/** Page d'accueil. */
|
|
116
|
+
export function Home() {
|
|
117
|
+
return (
|
|
118
|
+
<div className="o-flex o-flex-col o-gap-12">
|
|
119
|
+
<Reveal>
|
|
120
|
+
<h1 className="o-text-4xl o-font-bold o-tracking-tight">Client et serveur.</h1>
|
|
121
|
+
<p className="o-mt-4 o-text-lg o-text-zinc-500 dark:o-text-zinc-400 o-max-w-prose">
|
|
122
|
+
Ce projet reunit une application React et une API Express dans un seul depot. Le
|
|
123
|
+
panneau ci-dessous interroge le serveur pour de vrai.
|
|
124
|
+
</p>
|
|
125
|
+
</Reveal>
|
|
126
|
+
|
|
127
|
+
<ApiStatus />
|
|
128
|
+
|
|
129
|
+
<Stagger step={80} className="o-grid o-grid-cols-1 md:o-grid-cols-3 o-gap-4">
|
|
130
|
+
{FEATURES.map((feature) => (
|
|
131
|
+
<article
|
|
132
|
+
key={feature.title}
|
|
133
|
+
className="o-rounded-lg o-border-w-1 o-border-zinc-200 dark:o-border-zinc-800 o-bg-white dark:o-bg-zinc-900 o-p-5"
|
|
134
|
+
>
|
|
135
|
+
<h2 className="o-font-semibold">{feature.title}</h2>
|
|
136
|
+
<p className="o-mt-2 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
|
|
137
|
+
{feature.body}
|
|
138
|
+
</p>
|
|
139
|
+
</article>
|
|
140
|
+
))}
|
|
141
|
+
</Stagger>
|
|
142
|
+
|
|
143
|
+
<p>
|
|
144
|
+
<Link to="/a-propos">En savoir plus</Link>
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Link } from '@odoro-cli/libs/router'
|
|
2
|
+
|
|
3
|
+
/** Page affichee pour toute route inconnue. */
|
|
4
|
+
export function NotFound() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="o-flex o-flex-col o-gap-4" role="alert">
|
|
7
|
+
<h1 className="o-text-3xl o-font-bold">404</h1>
|
|
8
|
+
<p className="o-text-zinc-500 dark:o-text-zinc-400">Cette page n existe pas.</p>
|
|
9
|
+
<p>
|
|
10
|
+
<Link to="/">Retour a l accueil</Link>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Styles propres a l'application.
|
|
2
|
+
Tout le reste vient des tokens Odoro : surcharger une variable ci-dessous
|
|
3
|
+
suffit a rethemer l'ensemble, composants de la librairie compris. */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--o-color-primary: oklch(52.4% 0.212 275);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.app-shell {
|
|
10
|
+
min-height: 100dvh;
|
|
11
|
+
display: grid;
|
|
12
|
+
grid-template-rows: auto 1fr auto;
|
|
13
|
+
}
|