Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d139034044 | ||
|
|
5b049c9cad | ||
|
|
8ad9c5b320 | ||
|
|
0951b48fba | ||
|
|
ba0ade6983 | ||
|
|
4f5cf0292c | ||
|
|
a298b1bbeb | ||
|
|
ac990d9adc | ||
|
|
5a48ba98c3 | ||
|
|
ed3f934b61 | ||
|
|
eae9333307 | ||
|
|
4c136b0f09 | ||
|
|
b532c6cd7f | ||
|
|
27bfdd3deb | ||
|
|
77eee8f4cc | ||
|
|
4245de0231 | ||
|
|
c14ed521ab | ||
|
|
ad4374384a | ||
|
|
17331a5792 | ||
|
|
7f07a563b6 | ||
|
|
8494c18fa9 | ||
|
|
b6c4fc6165 | ||
|
|
93f6dcd29d | ||
|
|
5c92dab16c | ||
|
|
c912996b4b |
@@ -0,0 +1,22 @@
|
||||
# Local-only defaults. Copy this file to .env.local; never commit .env.local.
|
||||
BOTSU_DEV_HOST=127.0.0.1
|
||||
BOTSU_DEV_PORT=5173
|
||||
BOTSU_DEV_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
BOTSU_PRESENCE_PROXY_URL=http://127.0.0.1:8091
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=8091
|
||||
PRESENCE_ALLOWED_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
MATRIX_HOMESERVER=botsu.net
|
||||
|
||||
# Local filesystem stores. Relative paths are resolved by scripts/dev.mjs.
|
||||
BOTSU_DRAWING_STORE_ROOT=.data/drawings
|
||||
BOTSU_DOCUMENT_STORE_ROOT=.data/documents
|
||||
BOTSU_COOKIE_STORE_ROOT=.data/cookies
|
||||
BOTSU_TCG_STORE_ROOT=.data/tcg
|
||||
BOTSU_MOODBOARD_STORE_ROOT=.data/moodboard
|
||||
TCG_CATALOG_INDEX_PATH=.data/tcg/catalog-index.json
|
||||
EMOJI_KITCHEN_ROOT=.data/emoji-kitchen/stickers
|
||||
|
||||
# Optional: point image search to a reachable SearXNG instance.
|
||||
# BOTSU_SEARXNG_URL=http://127.0.0.1:8081
|
||||
@@ -8,3 +8,5 @@ dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
coverage/
|
||||
.data/
|
||||
*.timestamp-*.mjs
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.13.1
|
||||
@@ -0,0 +1,56 @@
|
||||
# AGENTS.md — BOTSU local development
|
||||
|
||||
## Mission
|
||||
|
||||
Work on the BOTSU collaborative suite from this repository. Keep development local and reversible. Production is never a development target.
|
||||
|
||||
## Start here
|
||||
|
||||
1. Read `README.md`, `DESIGN.md`, and `docs/adr/0001-development-isolation.md`.
|
||||
2. Use Node.js from `.nvmrc` / `.node-version`.
|
||||
3. Run `npm ci` from the repository root. Do not run separate installs in workspaces.
|
||||
4. Copy `.env.example` to `.env.local` if it does not exist.
|
||||
5. Use `npm run dev` for the local client and presence API.
|
||||
|
||||
Local endpoints:
|
||||
|
||||
- client: `http://127.0.0.1:5173`
|
||||
- presence API: `http://127.0.0.1:8091`
|
||||
- generated local state: `.data/` (ignored by Git)
|
||||
|
||||
## Repository map
|
||||
|
||||
- `apps/client`: Cinny-based Matrix client and BOTSU UI
|
||||
- `apps/client/src/botsu`: BOTSU-only frontend features
|
||||
- `apps/presence-api`: local/realtime HTTP and WebSocket service
|
||||
- `packages/protocol`: validated shared wire protocol
|
||||
- `packages/ui`: BOTSU design tokens and primitives
|
||||
- `docs`: architecture, ADRs, and security notes
|
||||
- `scripts`: repeatable setup, development, and verification commands
|
||||
|
||||
## Required workflow
|
||||
|
||||
- Inspect existing code and tests before editing.
|
||||
- For behavior changes, write or update a focused test first.
|
||||
- Keep BOTSU code isolated from inherited Cinny code where practical.
|
||||
- Run the narrow test while iterating, then `npm run check` before committing.
|
||||
- Never hide inherited lint/type errors by weakening global rules.
|
||||
- Do not commit `.env.local`, `.data/`, credentials, Matrix tokens, generated `dist/`, or `node_modules/`.
|
||||
- Use conventional commits on a dedicated branch.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
Do not deploy, edit `/opt/services`, change DNS, Traefik, firewall, SSH, NAT, or expose ports. Do not push to or merge `main` unless the user explicitly asks. A production promotion always requires separate explicit approval.
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm test
|
||||
npm run typecheck:botsu
|
||||
npm run typecheck:presence
|
||||
npm run build:client
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run typecheck:client` and `npm run lint:client` include inherited Cinny debt; see the ADR. `npm run check` contains the BOTSU-owned green gates plus a production client build.
|
||||
@@ -14,19 +14,51 @@ Monorepo de la suite collaborative BOTSU.
|
||||
- `infra/` : modèles de déploiement et migrations sans secrets
|
||||
- `scripts/` : vérifications reproductibles du dépôt
|
||||
|
||||
## Commandes actuelles
|
||||
## Développement local sur Mac avec Codex
|
||||
|
||||
Prérequis : macOS, les outils en ligne de commande Apple, Git et `nvm`.
|
||||
Le dépôt fixe Node.js `24.13.1` dans `.nvmrc` et `.node-version`.
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck:ui
|
||||
npm run build:client
|
||||
npm run typecheck:client
|
||||
npm run lint:client
|
||||
xcode-select --install # une seule fois si nécessaire
|
||||
git clone https://git.botsu.net/botsu/botsu.app.git
|
||||
cd botsu.app
|
||||
./scripts/bootstrap-macos.sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm test` vérifie actuellement la structure du monorepo. Le build Cinny est
|
||||
vert. Les échecs hérités de `typecheck:client` et `lint:client` sont documentés
|
||||
dans `docs/adr/0001-development-isolation.md` et ne doivent pas être masqués.
|
||||
Ouvre ensuite `http://127.0.0.1:5173`. Le client et l'API de présence restent
|
||||
liés à la boucle locale. Les données de développement sont écrites dans
|
||||
`.data/`, qui est ignoré par Git. Arrête les deux services avec `Ctrl-C`.
|
||||
|
||||
Le bootstrap installe la bonne version de Node via `nvm` lorsqu'il est déjà
|
||||
présent, crée `.env.local`, exécute `npm ci`, puis les tests. Si `nvm` manque,
|
||||
il affiche le lien d'installation officiel sans modifier automatiquement le
|
||||
profil du shell.
|
||||
|
||||
Pour travailler avec Codex, lance simplement `codex` à la racine du dépôt.
|
||||
`AGENTS.md` lui fournit la carte du monorepo, les gates de validation et les
|
||||
limites de sécurité (aucun déploiement ni changement d'infrastructure).
|
||||
|
||||
Configuration optionnelle : copie et adapte `.env.example`. Le fichier
|
||||
`.env.local` reste local et ne doit jamais contenir de secret de production.
|
||||
|
||||
## Commandes de développement
|
||||
|
||||
```bash
|
||||
npm run dev # client + API locale
|
||||
npm test # tests de tous les workspaces
|
||||
npm run typecheck:botsu
|
||||
npm run typecheck:presence
|
||||
npm run build:client
|
||||
npm run check # toutes les gates BOTSU + build
|
||||
```
|
||||
|
||||
`npm run check` vérifie la structure du monorepo, les packages BOTSU, l'API de
|
||||
présence, le client BOTSU et un build complet. Les échecs hérités de
|
||||
`typecheck:client` et `lint:client` sont documentés dans
|
||||
`docs/adr/0001-development-isolation.md` et ne doivent pas être masqués.
|
||||
|
||||
Les futurs packages BOTSU auront leurs propres gates vertes dès leur création.
|
||||
|
||||
La direction visuelle et ses tokens normatifs sont décrits dans `DESIGN.md`.
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
<link
|
||||
id="favicon"
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/public/favicon-32x32.png"
|
||||
/>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -34,52 +40,52 @@
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="./public/res/apple/apple-touch-icon-57x57.png"
|
||||
href="/public/res/apple/apple-touch-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="./public/res/apple/apple-touch-icon-60x60.png"
|
||||
href="/public/res/apple/apple-touch-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="./public/res/apple/apple-touch-icon-72x72.png"
|
||||
href="/public/res/apple/apple-touch-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="./public/res/apple/apple-touch-icon-76x76.png"
|
||||
href="/public/res/apple/apple-touch-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="./public/res/apple/apple-touch-icon-114x114.png"
|
||||
href="/public/res/apple/apple-touch-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="./public/res/apple/apple-touch-icon-120x120.png"
|
||||
href="/public/res/apple/apple-touch-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="./public/res/apple/apple-touch-icon-144x144.png"
|
||||
href="/public/res/apple/apple-touch-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="./public/res/apple/apple-touch-icon-152x152.png"
|
||||
href="/public/res/apple/apple-touch-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="167x167"
|
||||
href="./public/res/apple/apple-touch-icon-167x167.png"
|
||||
href="/public/res/apple/apple-touch-icon-167x167.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="./public/res/apple/apple-touch-icon-180x180.png"
|
||||
href="/public/res/apple/apple-touch-icon-180x180.png"
|
||||
/>
|
||||
</head>
|
||||
<body id="appBody">
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.1",
|
||||
"hls.js": "^1.6.17",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
@@ -8949,6 +8950,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.17",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
|
||||
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
|
||||
"test": "npm run test:botsu",
|
||||
"test:botsu": "node --experimental-strip-types --test src/botsu/storyboard/model.test.ts src/botsu/storyboard/storyboard-ui.test.ts src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/moodboard/model.test.ts src/botsu/moodboard/controller.test.ts src/botsu/moodboard/moodboard-ui.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/kanban/model.test.ts src/botsu/kanban/integration.test.ts src/botsu/drawing/drawing-bridge.test.ts src/botsu/drawing/drawing-export.test.ts src/botsu/drawing/drawing-model.test.ts src/botsu/drawing/drawing-ui.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/voxel/model.test.ts src/botsu/voxel/voxel-color.test.ts src/botsu/voxel/voxel-ui.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/watch/jellyfin.test.ts src/botsu/watch/model.test.ts src/botsu/watch/providers.test.ts src/botsu/watch/watch-ui.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
|
||||
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
@@ -65,6 +66,7 @@
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.1",
|
||||
"hls.js": "^1.6.17",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
||||
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 516 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -95,6 +95,131 @@ export function CreateRoomTypeSelector({
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.DrawingRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.DrawingRoom}
|
||||
onClick={() => onSelect(CreateRoomType.DrawingRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.DrawingRoom)} />}
|
||||
after={value === CreateRoomType.DrawingRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Drawing Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Shared Paint canvas.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.KanbanRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.KanbanRoom}
|
||||
onClick={() => onSelect(CreateRoomType.KanbanRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.KanbanRoom)} />}
|
||||
after={value === CreateRoomType.KanbanRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Kanban Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Shared columns, cards, and tasks.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.VoxelRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.VoxelRoom}
|
||||
onClick={() => onSelect(CreateRoomType.VoxelRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.VoxelRoom)} />}
|
||||
after={value === CreateRoomType.VoxelRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Voxel Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Collaborative voxel and Minecraft blockstate editor.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.WatchRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.WatchRoom}
|
||||
onClick={() => onSelect(CreateRoomType.WatchRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.WatchRoom)} />}
|
||||
after={value === CreateRoomType.WatchRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Watch Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Synchronized theater with Matrix chat.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant={value === CreateRoomType.StoryboardRoom ? 'Primary' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
as="button"
|
||||
type="button"
|
||||
aria-pressed={value === CreateRoomType.StoryboardRoom}
|
||||
onClick={() => onSelect(CreateRoomType.StoryboardRoom)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingTile
|
||||
before={<Icon size="400" src={getIcon(CreateRoomType.StoryboardRoom)} />}
|
||||
after={value === CreateRoomType.StoryboardRoom && <Icon src={Icons.Check} />}
|
||||
>
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||
Story Room
|
||||
</Text>
|
||||
<Text size="T300" priority="300" truncate>
|
||||
- Scènes, dialogues, images et sons à réarranger ensemble.
|
||||
</Text>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ export enum CreateRoomType {
|
||||
TextRoom = 'text',
|
||||
VoiceRoom = 'voice',
|
||||
DocumentRoom = 'document',
|
||||
DrawingRoom = 'drawing',
|
||||
VoxelRoom = 'voxel',
|
||||
WatchRoom = 'watch',
|
||||
StoryboardRoom = 'storyboard',
|
||||
KanbanRoom = 'kanban',
|
||||
}
|
||||
|
||||
export enum CreateRoomAccess {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { CreateRoomAccess } from './types';
|
||||
import { createEmptyBotsuWatchSession } from '@botsu/protocol';
|
||||
import { KANBAN_DEFAULT_COLUMNS } from '../../../botsu/kanban/model';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
@@ -98,6 +100,38 @@ export const createVoiceRoomPowerLevelsOverride = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const createWatchRoomPowerLevelsOverride = () => ({
|
||||
events: {
|
||||
[StateEvent.BotsuWatchSession]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const createStoryboardRoomPowerLevelsOverride = () => ({
|
||||
events: {
|
||||
[StateEvent.BotsuStoryboardCard]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const createKanbanRoomPowerLevelsOverride = () => ({
|
||||
events: {
|
||||
[StateEvent.BotsuKanbanColumn]: 0,
|
||||
[StateEvent.BotsuKanbanCard]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const createKanbanRoomState = (): ICreateRoomStateEvent[] =>
|
||||
KANBAN_DEFAULT_COLUMNS.map((column) => ({
|
||||
type: StateEvent.BotsuKanbanColumn,
|
||||
state_key: column.id,
|
||||
content: column,
|
||||
}));
|
||||
|
||||
export const createWatchRoomState = () => ({
|
||||
type: StateEvent.BotsuWatchSession,
|
||||
state_key: '',
|
||||
content: createEmptyBotsuWatchSession(),
|
||||
});
|
||||
|
||||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
@@ -110,6 +144,7 @@ export type CreateRoomData = {
|
||||
knock: boolean;
|
||||
allowFederation: boolean;
|
||||
additionalCreators?: string[];
|
||||
drawingResourceId?: string;
|
||||
};
|
||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
@@ -118,6 +153,14 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
initialState.push(createRoomEncryptionState());
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Drawing && data.drawingResourceId) {
|
||||
initialState.push({
|
||||
type: StateEvent.BotsuDrawing,
|
||||
state_key: '',
|
||||
content: { version: 1, resourceId: data.drawingResourceId },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.parent) {
|
||||
initialState.push(createRoomParentState(data.parent));
|
||||
}
|
||||
@@ -126,6 +169,14 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
initialState.push(createRoomCallState());
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Watch) {
|
||||
initialState.push(createWatchRoomState());
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Kanban) {
|
||||
initialState.push(...createKanbanRoomState());
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
@@ -139,7 +190,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.additionalCreators
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
data.type === RoomType.Call
|
||||
? createVoiceRoomPowerLevelsOverride()
|
||||
: data.type === RoomType.Watch
|
||||
? createWatchRoomPowerLevelsOverride()
|
||||
: data.type === RoomType.Storyboard
|
||||
? createStoryboardRoomPowerLevelsOverride()
|
||||
: data.type === RoomType.Kanban
|
||||
? createKanbanRoomPowerLevelsOverride()
|
||||
: undefined,
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { createBotsuDrawingResourceId, parseBotsuDocumentObject } from '@botsu/protocol';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
@@ -40,28 +41,43 @@ import {
|
||||
CreateRoomType,
|
||||
} from '../../components/create-room';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { StoryRoomIcon } from '../../../botsu/storyboard/StoryRoomIcon';
|
||||
import { VoxelRoomIcon } from '../../../botsu/voxel/VoxelRoomIcon';
|
||||
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { parseBotsuDocumentObject } from '@botsu/protocol';
|
||||
|
||||
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
|
||||
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
|
||||
const isDocumentRoom = type === CreateRoomType.DocumentRoom;
|
||||
const isDrawingRoom = type === CreateRoomType.DrawingRoom;
|
||||
const isVoxelRoom = type === CreateRoomType.VoxelRoom;
|
||||
const isWatchRoom = type === CreateRoomType.WatchRoom;
|
||||
const isStoryboardRoom = type === CreateRoomType.StoryboardRoom;
|
||||
const isKanbanRoom = type === CreateRoomType.KanbanRoom;
|
||||
|
||||
let joinRule: JoinRule = JoinRule.Public;
|
||||
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
|
||||
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
|
||||
|
||||
return getRoomIconSrc(
|
||||
Icons,
|
||||
isVoiceRoom ? RoomType.Call : isDocumentRoom ? RoomType.Document : undefined,
|
||||
joinRule
|
||||
);
|
||||
let roomType: RoomType | undefined;
|
||||
if (isVoiceRoom) roomType = RoomType.Call;
|
||||
if (isDocumentRoom) roomType = RoomType.Document;
|
||||
if (isDrawingRoom) roomType = RoomType.Drawing;
|
||||
if (isVoxelRoom) roomType = RoomType.Voxel;
|
||||
if (isWatchRoom) roomType = RoomType.Watch;
|
||||
if (isStoryboardRoom) roomType = RoomType.Storyboard;
|
||||
if (isKanbanRoom) roomType = RoomType.Kanban;
|
||||
return getRoomIconSrc(Icons, roomType, joinRule);
|
||||
};
|
||||
|
||||
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
|
||||
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
|
||||
if (type === CreateRoomType.DocumentRoom) return Icons.File;
|
||||
if (type === CreateRoomType.DrawingRoom) return Icons.Pencil;
|
||||
if (type === CreateRoomType.VoxelRoom) return VoxelRoomIcon;
|
||||
if (type === CreateRoomType.WatchRoom) return Icons.Play;
|
||||
if (type === CreateRoomType.StoryboardRoom) return StoryRoomIcon;
|
||||
if (type === CreateRoomType.KanbanRoom) return Icons.Category;
|
||||
return Icons.Hash;
|
||||
};
|
||||
|
||||
@@ -146,6 +162,11 @@ export function CreateRoomForm({
|
||||
let roomType: RoomType | undefined;
|
||||
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
|
||||
if (type === CreateRoomType.DocumentRoom) roomType = RoomType.Document;
|
||||
if (type === CreateRoomType.DrawingRoom) roomType = RoomType.Drawing;
|
||||
if (type === CreateRoomType.VoxelRoom) roomType = RoomType.Voxel;
|
||||
if (type === CreateRoomType.WatchRoom) roomType = RoomType.Watch;
|
||||
if (type === CreateRoomType.StoryboardRoom) roomType = RoomType.Storyboard;
|
||||
if (type === CreateRoomType.KanbanRoom) roomType = RoomType.Kanban;
|
||||
|
||||
create({
|
||||
version: selectedRoomVersion,
|
||||
@@ -159,12 +180,16 @@ export function CreateRoomForm({
|
||||
knock: roomKnock,
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
drawingResourceId:
|
||||
type === CreateRoomType.DrawingRoom
|
||||
? createBotsuDrawingResourceId(crypto.randomUUID())
|
||||
: undefined,
|
||||
}).then(async (roomId) => {
|
||||
if (type === CreateRoomType.DocumentRoom) {
|
||||
if (type === CreateRoomType.DocumentRoom || type === CreateRoomType.VoxelRoom) {
|
||||
try {
|
||||
const token = await mx.getOpenIdToken();
|
||||
const draftId = `doc_${crypto.randomUUID()}`;
|
||||
console.log('[BotsuDocumentRoom] creating shared document, draftId=', draftId);
|
||||
const voxelRoom = type === CreateRoomType.VoxelRoom;
|
||||
const draftId = `doc_${voxelRoom ? 'voxel_' : ''}${crypto.randomUUID()}`;
|
||||
const response = await fetch('/presence/documents/import-local', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -174,26 +199,28 @@ export function CreateRoomForm({
|
||||
body: JSON.stringify({
|
||||
localDraftId: draftId,
|
||||
title: roomName,
|
||||
icon: 'file-text',
|
||||
icon: voxelRoom ? 'house' : 'file-text',
|
||||
body: '',
|
||||
}),
|
||||
});
|
||||
console.log('[BotsuDocumentRoom] import-local response:', response.status);
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
console.log('[BotsuDocumentRoom] payload:', payload);
|
||||
const object = parseBotsuDocumentObject(payload.object);
|
||||
await mx.sendStateEvent(roomId, StateEvent.BotsuDocument, {
|
||||
version: 1,
|
||||
objectId: object.objectId,
|
||||
}, '');
|
||||
console.log('[BotsuDocumentRoom] state event sent, objectId=', object.objectId);
|
||||
await mx.sendStateEvent(
|
||||
roomId,
|
||||
voxelRoom ? StateEvent.BotsuVoxel : StateEvent.BotsuDocument,
|
||||
{
|
||||
version: 1,
|
||||
objectId: object.objectId,
|
||||
},
|
||||
''
|
||||
);
|
||||
} else {
|
||||
const text = await response.text().catch(() => '?');
|
||||
console.error('[BotsuDocumentRoom] import-local failed:', response.status, text);
|
||||
console.error('[BotsuRoom] shared object creation failed:', response.status, text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BotsuDocumentRoom] failed to create shared document:', err);
|
||||
console.error('[BotsuRoom] failed to create shared object:', err);
|
||||
}
|
||||
}
|
||||
if (alive()) {
|
||||
@@ -204,17 +231,15 @@ export function CreateRoomForm({
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
||||
{!space && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Type</Text>
|
||||
<CreateRoomTypeSelector
|
||||
value={type}
|
||||
onSelect={setType}
|
||||
disabled={disabled}
|
||||
getIcon={getCreateRoomTypeToIcon}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Type</Text>
|
||||
<CreateRoomTypeSelector
|
||||
value={type}
|
||||
onSelect={setType}
|
||||
disabled={disabled}
|
||||
getIcon={getCreateRoomTypeToIcon}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Access</Text>
|
||||
<CreateRoomAccessSelector
|
||||
|
||||
@@ -62,8 +62,18 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||
{type === CreateRoomType.VoiceRoom
|
||||
? 'New Voice Room'
|
||||
: type === CreateRoomType.DocumentRoom
|
||||
? 'New Document room'
|
||||
: 'New Chat room'}
|
||||
? 'New Document room'
|
||||
: type === CreateRoomType.DrawingRoom
|
||||
? 'New Drawing room'
|
||||
: type === CreateRoomType.VoxelRoom
|
||||
? 'New Voxel room'
|
||||
: type === CreateRoomType.WatchRoom
|
||||
? 'New Watch room'
|
||||
: type === CreateRoomType.StoryboardRoom
|
||||
? 'New Story Room'
|
||||
: type === CreateRoomType.KanbanRoom
|
||||
? 'New Kanban room'
|
||||
: 'New Chat room'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -297,6 +297,33 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||
>
|
||||
<Text size="T300">Voice Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.DocumentRoom)}
|
||||
>
|
||||
<Text size="T300">Document Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.DrawingRoom)}
|
||||
>
|
||||
<Text size="T300">Drawing Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.KanbanRoom)}
|
||||
>
|
||||
<Text size="T300">Kanban Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||
<Text size="T300">Existing Room</Text>
|
||||
</MenuItem>
|
||||
@@ -364,6 +391,33 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
||||
>
|
||||
<Text size="T300">New Space</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.DocumentRoom)}
|
||||
>
|
||||
<Text size="T300">Document Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.DrawingRoom)}
|
||||
>
|
||||
<Text size="T300">Drawing Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={() => handleCreateRoom(CreateRoomType.KanbanRoom)}
|
||||
>
|
||||
<Text size="T300">Kanban Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||
<Text size="T300">Existing Space</Text>
|
||||
</MenuItem>
|
||||
|
||||
@@ -7,12 +7,18 @@ import { callChatAtom } from '../../state/callEmbed';
|
||||
import { RoomView } from './RoomView';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
|
||||
export function CallChatView() {
|
||||
type CallChatViewProps = {
|
||||
onClose?: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export function CallChatView({ onClose, title = 'Chat', subtitle }: CallChatViewProps) {
|
||||
const { eventId } = useParams();
|
||||
const setChat = useSetAtom(callChatAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const handleClose = () => setChat(false);
|
||||
const handleClose = () => (onClose ? onClose() : setChat(false));
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -24,10 +30,15 @@ export function CallChatView() {
|
||||
>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="H5" truncate>
|
||||
Chat
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Room as MatrixRoom } from 'matrix-js-sdk/lib/models/room';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
@@ -16,14 +17,68 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { callChatAtom, drawingChatAtom, watchChatAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { BotsuDocumentRoomView } from '../../../botsu/documents/BotsuDocumentRoomView';
|
||||
import { BotsuDocumentTransport } from '../../../botsu/documents/BotsuDocumentTransport';
|
||||
import { DocumentEditorProvider } from '../../../botsu/documents/DocumentEditorContext';
|
||||
import { isDocumentRoom } from '../../utils/room';
|
||||
import { BotsuDrawingRoomView } from '../../../botsu/drawing/BotsuDrawingRoomView';
|
||||
import { BotsuDrawingTransport } from '../../../botsu/drawing/BotsuDrawingTransport';
|
||||
import { DrawingProvider } from '../../../botsu/drawing/DrawingContext';
|
||||
import { DrawingToolProvider } from '../../../botsu/drawing/DrawingToolContext';
|
||||
import { BotsuWatchRoomView } from '../../../botsu/watch';
|
||||
import { BotsuStoryboardRoomView } from '../../../botsu/storyboard';
|
||||
import { BotsuKanbanRoomView } from '../../../botsu/kanban/BotsuKanbanRoomView';
|
||||
import {
|
||||
BotsuVoxelRoomView,
|
||||
VoxelEditorProvider,
|
||||
VoxelMaterialsDrawer,
|
||||
useVoxelEditor,
|
||||
} from '../../../botsu/voxel';
|
||||
import {
|
||||
isDocumentRoom,
|
||||
isDrawingRoom,
|
||||
isKanbanRoom,
|
||||
isStoryboardRoom,
|
||||
isVoxelRoom,
|
||||
isWatchRoom,
|
||||
} from '../../utils/room';
|
||||
|
||||
function VoxelMaterialsPanel() {
|
||||
const { materialsOpen } = useVoxelEditor();
|
||||
if (!materialsOpen) return null;
|
||||
return (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<VoxelMaterialsDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VoxelRoomContent({ room }: { room: MatrixRoom }) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { materialsOpen } = useVoxelEditor();
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={
|
||||
screenSize === ScreenSize.Desktop || !materialsOpen ? undefined : { display: 'none' }
|
||||
}
|
||||
>
|
||||
<BotsuDocumentTransport />
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuVoxelRoomView room={room} />
|
||||
</Box>
|
||||
</Box>
|
||||
<VoxelMaterialsPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
@@ -40,6 +95,20 @@ export function Room() {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const [drawingChat, setDrawingChat] = useAtom(drawingChatAtom);
|
||||
const watchChat = useAtomValue(watchChatAtom);
|
||||
const setWatchChat = useSetAtom(watchChatAtom);
|
||||
const watchChatRoomRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
setDrawingChat(false);
|
||||
}, [room.roomId, setDrawingChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (watchChatRoomRef.current === room.roomId) return;
|
||||
watchChatRoomRef.current = room.roomId;
|
||||
setWatchChat(false);
|
||||
}, [room.roomId, setWatchChat]);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
@@ -55,6 +124,11 @@ export function Room() {
|
||||
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
const documentView = isDocumentRoom(room);
|
||||
const drawingView = isDrawingRoom(room);
|
||||
const voxelView = isVoxelRoom(room);
|
||||
const watchView = isWatchRoom(room);
|
||||
const storyboardView = isStoryboardRoom(room);
|
||||
const kanbanView = isKanbanRoom(room);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
@@ -67,14 +141,20 @@ export function Room() {
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && !documentView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<RoomView eventId={eventId} />
|
||||
{!callView &&
|
||||
!documentView &&
|
||||
!drawingView &&
|
||||
!voxelView &&
|
||||
!watchView &&
|
||||
!storyboardView &&
|
||||
!kanbanView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<RoomView eventId={eventId} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
{!callView && documentView && (
|
||||
<DocumentEditorProvider>
|
||||
<Box grow="Yes" direction="Column">
|
||||
@@ -86,6 +166,77 @@ export function Room() {
|
||||
</Box>
|
||||
</DocumentEditorProvider>
|
||||
)}
|
||||
{!callView && watchView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuWatchRoomView
|
||||
chatOpen={watchChat}
|
||||
chatContent={
|
||||
<CallChatView
|
||||
onClose={() => setWatchChat(false)}
|
||||
subtitle="Watch Room"
|
||||
title="Discussion"
|
||||
/>
|
||||
}
|
||||
onChatOpenChange={setWatchChat}
|
||||
room={room}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && storyboardView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuStoryboardRoomView room={room} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && kanbanView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuKanbanRoomView key={room.roomId} room={room} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && voxelView && (
|
||||
<VoxelEditorProvider key={room.roomId}>
|
||||
<VoxelRoomContent room={room} />
|
||||
</VoxelEditorProvider>
|
||||
)}
|
||||
|
||||
{!callView && drawingView && (
|
||||
<DrawingProvider key={room.roomId}>
|
||||
<DrawingToolProvider>
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={
|
||||
screenSize === ScreenSize.Desktop || !drawingChat
|
||||
? undefined
|
||||
: { display: 'none' }
|
||||
}
|
||||
>
|
||||
<BotsuDrawingTransport />
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuDrawingRoomView />
|
||||
</Box>
|
||||
</Box>
|
||||
</DrawingToolProvider>
|
||||
</DrawingProvider>
|
||||
)}
|
||||
|
||||
{!callView && drawingView && drawingChat && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<CallChatView onClose={() => setDrawingChat(false)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{callView && chat && (
|
||||
<>
|
||||
@@ -95,7 +246,7 @@ export function Room() {
|
||||
<CallChatView />
|
||||
</>
|
||||
)}
|
||||
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
{!callView && !watchView && !voxelView && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
@@ -38,7 +39,13 @@ import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { isDocumentRoom } from '../../utils/room';
|
||||
import {
|
||||
isDocumentRoom,
|
||||
isDrawingRoom,
|
||||
isKanbanRoom,
|
||||
isVoxelRoom,
|
||||
isWatchRoom,
|
||||
} from '../../utils/room';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
@@ -59,6 +66,8 @@ import { RoomPinMenu } from './room-pin-menu';
|
||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { DocumentRoomToolbar } from '../../../botsu/documents/DocumentRoomToolbar';
|
||||
import { DrawingRoomToolbar } from '../../../botsu/drawing/DrawingRoomToolbar';
|
||||
import { VoxelRoomToolbar } from '../../../botsu/voxel';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
getRoomNotificationModeIcon,
|
||||
@@ -74,6 +83,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { watchChatAtom } from '../../state/callEmbed';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -385,6 +395,34 @@ function CallButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function WatchChatButton() {
|
||||
const [watchChat, setWatchChat] = useAtom(watchChatAtom);
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{watchChat ? 'Fermer la discussion' : 'Ouvrir la discussion'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
aria-label={watchChat ? 'Fermer la discussion' : 'Ouvrir la discussion'}
|
||||
aria-pressed={watchChat}
|
||||
onClick={() => setWatchChat(!watchChat)}
|
||||
>
|
||||
<Icon size="400" src={Icons.Message} filled={watchChat} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
@@ -541,6 +579,10 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
)}
|
||||
{isDocumentRoom(room) ? (
|
||||
<DocumentRoomToolbar />
|
||||
) : isDrawingRoom(room) ? (
|
||||
<DrawingRoomToolbar />
|
||||
) : isVoxelRoom(room) ? (
|
||||
<VoxelRoomToolbar />
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider
|
||||
@@ -602,9 +644,15 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!room.isCallRoom() && !isDocumentRoom(room) && livekitSupported && rtcSupported && hasCallPermission && (
|
||||
<CallButton />
|
||||
)}
|
||||
{!room.isCallRoom() &&
|
||||
!isDocumentRoom(room) &&
|
||||
!isDrawingRoom(room) &&
|
||||
!isVoxelRoom(room) &&
|
||||
!isKanbanRoom(room) &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
hasCallPermission && <CallButton />}
|
||||
{isWatchRoom(room) && <WatchChatButton />}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../../public/res/botsu-b.png';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -38,8 +38,8 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
src={BotsuLogoImage}
|
||||
alt="BOTSU logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
|
||||
@@ -38,6 +38,16 @@ import {
|
||||
AppFont,
|
||||
BotsuBackground,
|
||||
BotsuShader,
|
||||
BotsuShaderOptions,
|
||||
BotsuBubblesOptions,
|
||||
BotsuParticlesOptions,
|
||||
BotsuDustOptions,
|
||||
BotsuFractalOptions,
|
||||
BotsuIsolayersOptions,
|
||||
BotsuVortexOptions,
|
||||
BotsuWavesOptions,
|
||||
BotsuCellsOptions,
|
||||
getDefaultShaderOptions,
|
||||
BotsuTranslateLang,
|
||||
DateFormat,
|
||||
MessageLayout,
|
||||
@@ -516,6 +526,7 @@ function BotsuBackgroundPicker() {
|
||||
const [botsuShader, setBotsuShader] = useSetting(settingsAtom, 'botsuShader');
|
||||
const [botsuShaderSpeed, setBotsuShaderSpeed] = useSetting(settingsAtom, 'botsuShaderSpeed');
|
||||
const [botsuShaderIntensity, setBotsuShaderIntensity] = useSetting(settingsAtom, 'botsuShaderIntensity');
|
||||
const [botsuShaderOptions, setBotsuShaderOptions] = useSetting(settingsAtom, 'botsuShaderOptions');
|
||||
|
||||
const handleImageChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
@@ -540,8 +551,24 @@ function BotsuBackgroundPicker() {
|
||||
{botsuBackground === 'imported' && <input aria-label="Importer une image de fond" accept="image/png,image/jpeg,image/webp" type="file" onChange={handleImageChange} />}
|
||||
{botsuBackground === 'shaders' && (
|
||||
<Box direction="Column" gap="200" alignItems="End">
|
||||
<select aria-label="Shader" value={botsuShader} onChange={(event) => setBotsuShader(event.currentTarget.value as BotsuShader)}>
|
||||
<select
|
||||
aria-label="Shader"
|
||||
value={botsuShader}
|
||||
onChange={(event) => {
|
||||
const nextShader = event.currentTarget.value as BotsuShader;
|
||||
setBotsuShader(nextShader);
|
||||
setBotsuShaderOptions(getDefaultShaderOptions(nextShader));
|
||||
}}
|
||||
>
|
||||
<option value="plasma">Plasma</option>
|
||||
<option value="bubbles">Bulles</option>
|
||||
<option value="particles">Particules</option>
|
||||
<option value="dust">Poussière</option>
|
||||
<option value="fractal">Fractale</option>
|
||||
<option value="isolayers">Isolayers</option>
|
||||
<option value="vortex">Vortex</option>
|
||||
<option value="waves">Vagues</option>
|
||||
<option value="cells">Cellules</option>
|
||||
</select>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse</Text>
|
||||
@@ -551,6 +578,261 @@ function BotsuBackgroundPicker() {
|
||||
<Text size="T200">Intensité</Text>
|
||||
<input aria-label="Intensité du shader" type="range" min="0.1" max="2.0" step="0.1" value={botsuShaderIntensity} onChange={(event) => setBotsuShaderIntensity(parseFloat(event.currentTarget.value))} />
|
||||
</Box>
|
||||
{botsuShader === 'plasma' && (() => {
|
||||
const opts = botsuShaderOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Profondeur</Text>
|
||||
<input aria-label="Profondeur des bandes" type="range" min="0.5" max="20.0" step="0.1" value={opts.bandDepth} onChange={(event) => setBotsuShaderOptions({ ...opts, bandDepth: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Amplitude</Text>
|
||||
<input aria-label="Amplitude des bandes" type="range" min="0.1" max="10.0" step="0.1" value={opts.bandAmplitude} onChange={(event) => setBotsuShaderOptions({ ...opts, bandAmplitude: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Fréquence X</Text>
|
||||
<input aria-label="Fréquence horizontale" type="range" min="0.5" max="30.0" step="0.1" value={opts.bandFrequencyX} onChange={(event) => setBotsuShaderOptions({ ...opts, bandFrequencyX: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Fréquence Y</Text>
|
||||
<input aria-label="Fréquence verticale" type="range" min="0.5" max="30.0" step="0.1" value={opts.bandFrequencyY} onChange={(event) => setBotsuShaderOptions({ ...opts, bandFrequencyY: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Caméra Z</Text>
|
||||
<input aria-label="Distance caméra" type="range" min="5.0" max="100.0" step="0.5" value={opts.cameraZ} onChange={(event) => setBotsuShaderOptions({ ...opts, cameraZ: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">FOV</Text>
|
||||
<input aria-label="Champ de vision" type="range" min="0.5" max="3.0" step="0.05" value={opts.fov} onChange={(event) => setBotsuShaderOptions({ ...opts, fov: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 1</Text>
|
||||
<input aria-label="Couleur 1" type="color" value={opts.color1} onChange={(event) => setBotsuShaderOptions({ ...opts, color1: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 2</Text>
|
||||
<input aria-label="Couleur 2" type="color" value={opts.color2} onChange={(event) => setBotsuShaderOptions({ ...opts, color2: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 3</Text>
|
||||
<input aria-label="Couleur 3" type="color" value={opts.color3} onChange={(event) => setBotsuShaderOptions({ ...opts, color3: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'bubbles' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'bubbles' } & BotsuBubblesOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Nombre</Text>
|
||||
<input aria-label="Nombre de bulles" type="range" min="1" max="200" step="1" value={opts.bubbleCount} onChange={(event) => setBotsuShaderOptions({ ...opts, bubbleCount: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Taille</Text>
|
||||
<input aria-label="Taille des bulles" type="range" min="0.1" max="2.0" step="0.05" value={opts.bubbleSize} onChange={(event) => setBotsuShaderOptions({ ...opts, bubbleSize: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse</Text>
|
||||
<input aria-label="Vitesse des bulles" type="range" min="0.1" max="5.0" step="0.1" value={opts.speed} onChange={(event) => setBotsuShaderOptions({ ...opts, speed: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 1</Text>
|
||||
<input aria-label="Couleur 1" type="color" value={opts.color1} onChange={(event) => setBotsuShaderOptions({ ...opts, color1: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 2</Text>
|
||||
<input aria-label="Couleur 2" type="color" value={opts.color2} onChange={(event) => setBotsuShaderOptions({ ...opts, color2: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'particles' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'particles' } & BotsuParticlesOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Nombre</Text>
|
||||
<input aria-label="Nombre de particules" type="range" min="10" max="500" step="10" value={opts.particleCount} onChange={(event) => setBotsuShaderOptions({ ...opts, particleCount: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Taille</Text>
|
||||
<input aria-label="Taille des particules" type="range" min="1.0" max="30.0" step="0.5" value={opts.particleSize} onChange={(event) => setBotsuShaderOptions({ ...opts, particleSize: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Durée de vie</Text>
|
||||
<input aria-label="Durée de vie" type="range" min="2.0" max="30.0" step="0.5" value={opts.particleLifetime} onChange={(event) => setBotsuShaderOptions({ ...opts, particleLifetime: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse</Text>
|
||||
<input aria-label="Vitesse des particules" type="range" min="0.1" max="5.0" step="0.1" value={opts.speed} onChange={(event) => setBotsuShaderOptions({ ...opts, speed: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur</Text>
|
||||
<input aria-label="Couleur" type="color" value={opts.color} onChange={(event) => setBotsuShaderOptions({ ...opts, color: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'dust' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'dust' } & BotsuDustOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Échelle poussière</Text>
|
||||
<input aria-label="Échelle de la poussière" type="range" min="0.2" max="5.0" step="0.1" value={opts.dustScale} onChange={(event) => setBotsuShaderOptions({ ...opts, dustScale: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Densité</Text>
|
||||
<input aria-label="Densité de poussière" type="range" min="0.0" max="2.0" step="0.05" value={opts.dustDensity} onChange={(event) => setBotsuShaderOptions({ ...opts, dustDensity: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'fractal' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'fractal' } & BotsuFractalOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Itérations</Text>
|
||||
<input aria-label="Itérations du fractal" type="range" min="1" max="16" step="1" value={opts.iterations} onChange={(event) => setBotsuShaderOptions({ ...opts, iterations: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Zoom</Text>
|
||||
<input aria-label="Zoom du fractal" type="range" min="0.2" max="5.0" step="0.1" value={opts.zoom} onChange={(event) => setBotsuShaderOptions({ ...opts, zoom: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse couleur</Text>
|
||||
<input aria-label="Vitesse des couleurs du fractal" type="range" min="0.1" max="5.0" step="0.1" value={opts.colorSpeed} onChange={(event) => setBotsuShaderOptions({ ...opts, colorSpeed: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 1</Text>
|
||||
<input aria-label="Couleur 1" type="color" value={opts.color1} onChange={(event) => setBotsuShaderOptions({ ...opts, color1: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 2</Text>
|
||||
<input aria-label="Couleur 2" type="color" value={opts.color2} onChange={(event) => setBotsuShaderOptions({ ...opts, color2: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 3</Text>
|
||||
<input aria-label="Couleur 3" type="color" value={opts.color3} onChange={(event) => setBotsuShaderOptions({ ...opts, color3: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'isolayers' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'isolayers' } & BotsuIsolayersOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Échelle</Text>
|
||||
<input aria-label="Échelle des isolayers" type="range" min="0.2" max="5.0" step="0.1" value={opts.scale} onChange={(event) => setBotsuShaderOptions({ ...opts, scale: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Flux</Text>
|
||||
<input aria-label="Flux des isolayers" type="range" min="0.0" max="5.0" step="0.1" value={opts.flow} onChange={(event) => setBotsuShaderOptions({ ...opts, flow: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couches</Text>
|
||||
<input aria-label="Nombre de couches" type="range" min="2" max="30" step="1" value={opts.layers} onChange={(event) => setBotsuShaderOptions({ ...opts, layers: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 1</Text>
|
||||
<input aria-label="Couleur 1" type="color" value={opts.color1} onChange={(event) => setBotsuShaderOptions({ ...opts, color1: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 2</Text>
|
||||
<input aria-label="Couleur 2" type="color" value={opts.color2} onChange={(event) => setBotsuShaderOptions({ ...opts, color2: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur 3</Text>
|
||||
<input aria-label="Couleur 3" type="color" value={opts.color3} onChange={(event) => setBotsuShaderOptions({ ...opts, color3: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'vortex' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'vortex' } & BotsuVortexOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse vortex</Text>
|
||||
<input aria-label="Vitesse du vortex" type="range" min="0.1" max="3.0" step="0.1" value={opts.speed} onChange={(event) => setBotsuShaderOptions({ ...opts, speed: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Taille tourbillon</Text>
|
||||
<input aria-label="Taille du tourbillon" type="range" min="5.0" max="100.0" step="1.0" value={opts.swirlSize} onChange={(event) => setBotsuShaderOptions({ ...opts, swirlSize: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Pixels</Text>
|
||||
<input aria-label="Taille des pixels" type="range" min="200.0" max="2000.0" step="10.0" value={opts.pixelSize} onChange={(event) => setBotsuShaderOptions({ ...opts, pixelSize: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Détail</Text>
|
||||
<input aria-label="Détail du vortex" type="range" min="1" max="12" step="1" value={opts.detail} onChange={(event) => setBotsuShaderOptions({ ...opts, detail: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Rouge</Text>
|
||||
<input aria-label="Couleur rouge" type="color" value={opts.red} onChange={(event) => setBotsuShaderOptions({ ...opts, red: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Bleu</Text>
|
||||
<input aria-label="Couleur bleue" type="color" value={opts.blue} onChange={(event) => setBotsuShaderOptions({ ...opts, blue: event.currentTarget.value })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Noir</Text>
|
||||
<input aria-label="Couleur noire" type="color" value={opts.black} onChange={(event) => setBotsuShaderOptions({ ...opts, black: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'waves' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'waves' } & BotsuWavesOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Échelle</Text>
|
||||
<input aria-label="Échelle des vagues" type="range" min="100.0" max="5000.0" step="50.0" value={opts.scale} onChange={(event) => setBotsuShaderOptions({ ...opts, scale: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Vitesse</Text>
|
||||
<input aria-label="Vitesse des vagues" type="range" min="0.1" max="5.0" step="0.1" value={opts.speed} onChange={(event) => setBotsuShaderOptions({ ...opts, speed: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Amplitude</Text>
|
||||
<input aria-label="Amplitude des vagues" type="range" min="0.1" max="5.0" step="0.1" value={opts.amplitude} onChange={(event) => setBotsuShaderOptions({ ...opts, amplitude: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur</Text>
|
||||
<input aria-label="Couleur des vagues" type="color" value={opts.color} onChange={(event) => setBotsuShaderOptions({ ...opts, color: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{botsuShader === 'cells' && (() => {
|
||||
const opts = botsuShaderOptions as { type: 'cells' } & BotsuCellsOptions;
|
||||
return (
|
||||
<>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Taille</Text>
|
||||
<input aria-label="Taille des cellules" type="range" min="5.0" max="200.0" step="1.0" value={opts.size} onChange={(event) => setBotsuShaderOptions({ ...opts, size: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Marge</Text>
|
||||
<input aria-label="Marge des cellules" type="range" min="-100.0" max="100.0" step="1.0" value={opts.margin} onChange={(event) => setBotsuShaderOptions({ ...opts, margin: parseFloat(event.currentTarget.value) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Rayon</Text>
|
||||
<input aria-label="Rayon des cellules" type="range" min="1" max="8" step="1" value={opts.n} onChange={(event) => setBotsuShaderOptions({ ...opts, n: parseInt(event.currentTarget.value, 10) })} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200">Couleur</Text>
|
||||
<input aria-label="Couleur des cellules" type="color" value={opts.color} onChange={(event) => setBotsuShaderOptions({ ...opts, color: event.currentTarget.value })} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -32,12 +32,15 @@ import {
|
||||
BOTSU_PATH,
|
||||
_BOTSU_APPS_PATH,
|
||||
_BOTSU_EMBED_PATH,
|
||||
_BOTSU_GODOT_PATH,
|
||||
_BOTSU_DOCUMENT_PATH,
|
||||
_BOTSU_DOCUMENTS_PATH,
|
||||
_BOTSU_SERVICES_PATH,
|
||||
_BOTSU_TCG_PATH,
|
||||
_BOTSU_WIKIPEDIA_PATH,
|
||||
_BOTSU_WIKIPEDIA_ARTICLE_PATH,
|
||||
_BOTSU_OPENSTREETMAP_PATH,
|
||||
_BOTSU_MOODBOARD_PATH,
|
||||
} from './paths';
|
||||
import {
|
||||
getAppPathFromHref,
|
||||
@@ -79,12 +82,14 @@ import { SearchModalRenderer } from '../features/search';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||
import { BotsuEmbed, BotsuFrame, BotsuLauncher, BotsuNav, BotsuServices } from '../../botsu/shell';
|
||||
import { BotsuEmbed, BotsuFrame, BotsuGodot, BotsuLauncher, BotsuNav, BotsuServices } from '../../botsu/shell';
|
||||
import { BotsuDocumentEditor, BotsuDocuments } from '../../botsu/documents';
|
||||
import { DocumentSyncProvider } from '../../botsu/documents/DocumentSyncContext';
|
||||
import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start';
|
||||
import { BotsuTcgPage } from '../../botsu/tcg/BotsuTcgPage';
|
||||
import { BotsuWikipediaHome, BotsuWikipediaArticle } from '../../botsu/wikipedia';
|
||||
import { BotsuOpenStreetMap } from '../../botsu/openstreetmap/BotsuOpenStreetMap';
|
||||
import { BotsuMoodboard } from '../../botsu/moodboard';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
@@ -192,8 +197,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
<Route path={_BOTSU_TCG_PATH} element={<BotsuTcgPage />} />
|
||||
<Route path={_BOTSU_WIKIPEDIA_PATH} element={<BotsuWikipediaHome />} />
|
||||
<Route path={_BOTSU_WIKIPEDIA_ARTICLE_PATH} element={<BotsuWikipediaArticle />} />
|
||||
<Route path={_BOTSU_OPENSTREETMAP_PATH} element={<BotsuOpenStreetMap />} />
|
||||
<Route path={_BOTSU_MOODBOARD_PATH} element={<BotsuMoodboard />} />
|
||||
<Route path={_BOTSU_SERVICES_PATH} element={<BotsuServices />} />
|
||||
<Route path={_BOTSU_EMBED_PATH} element={<BotsuEmbed />} />
|
||||
<Route path={_BOTSU_GODOT_PATH} element={<BotsuGodot />} />
|
||||
</Route>
|
||||
<Route
|
||||
path={HOME_PATH}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../public/res/botsu-b.png';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -134,7 +134,7 @@ export function AuthLayout() {
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<img className={css.AuthLogo} src={BotsuLogoImage} alt="BOTSU Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { color, config } from 'folds';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/svg/cinny.svg';
|
||||
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
|
||||
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
|
||||
import LogoSVG from '../../../../public/res/botsu-b.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/botsu-b-unread.png';
|
||||
import LogoHighlightSVG from '../../../../public/res/botsu-b-highlight.png';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../public/res/botsu-b.png';
|
||||
|
||||
export function WelcomePage() {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ export function WelcomePage() {
|
||||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
|
||||
icon={<img width="70" height="70" src={BotsuLogoImage} alt="BOTSU Logo" />}
|
||||
title="Welcome to Cinny"
|
||||
subTitle={
|
||||
<span>
|
||||
|
||||
@@ -12,9 +12,14 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { CreateRoomForm } from '../../../features/create-room';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CreateRoomType } from '../../../components/create-room';
|
||||
|
||||
export function HomeCreateRoom() {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const watchRoom = searchParams.get('type') === CreateRoomType.WatchRoom;
|
||||
const kanbanRoom = searchParams.get('type') === CreateRoomType.KanbanRoom;
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
@@ -40,11 +45,39 @@ export function HomeCreateRoom() {
|
||||
<PageHeroSection>
|
||||
<Box direction="Column" gap="700">
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title="Create Room"
|
||||
subTitle="Build a Room for Real-Time Conversations."
|
||||
icon={
|
||||
<Icon
|
||||
size="600"
|
||||
src={watchRoom ? Icons.Play : kanbanRoom ? Icons.Category : Icons.Hash}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
watchRoom
|
||||
? 'Create Watch Room'
|
||||
: kanbanRoom
|
||||
? 'Create Kanban Room'
|
||||
: 'Create Room'
|
||||
}
|
||||
subTitle={
|
||||
watchRoom
|
||||
? 'Watch one stream together with synchronized playback and Matrix chat.'
|
||||
: kanbanRoom
|
||||
? 'Organize shared tasks into live Matrix columns.'
|
||||
: 'Build a Room for Real-Time Conversations.'
|
||||
}
|
||||
/>
|
||||
<CreateRoomForm
|
||||
onCreate={navigateRoom}
|
||||
defaultType={
|
||||
searchParams.get('type') === CreateRoomType.WatchRoom
|
||||
? CreateRoomType.WatchRoom
|
||||
: searchParams.get('type') === CreateRoomType.VoxelRoom
|
||||
? CreateRoomType.VoxelRoom
|
||||
: searchParams.get('type') === CreateRoomType.KanbanRoom
|
||||
? CreateRoomType.KanbanRoom
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<CreateRoomForm onCreate={navigateRoom} />
|
||||
</Box>
|
||||
</PageHeroSection>
|
||||
</PageContentCenter>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function HomeTab() {
|
||||
|
||||
return (
|
||||
<SidebarItem active={homeSelected}>
|
||||
<SidebarItemTooltip tooltip="Discussions">
|
||||
<SidebarItemTooltip tooltip="Reseau">
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
@@ -104,7 +104,7 @@ export function HomeTab() {
|
||||
onClick={handleHomeClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Icon src={Icons.Pencil} filled={homeSelected} />
|
||||
<Icon src={Icons.Hash} filled={homeSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
|
||||
@@ -76,6 +76,7 @@ export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
|
||||
|
||||
export const CREATE_PATH = '/create';
|
||||
|
||||
export const _BOTSU_GODOT_PATH = 'godot/';
|
||||
export const _BOTSU_SERVICES_PATH = 'services/';
|
||||
export const _BOTSU_APPS_PATH = 'apps/';
|
||||
export const _BOTSU_EMBED_PATH = 'embed/:appId/';
|
||||
@@ -84,7 +85,10 @@ export const _BOTSU_DOCUMENT_PATH = 'documents/:documentId/';
|
||||
export const _BOTSU_TCG_PATH = 'tcg/';
|
||||
export const _BOTSU_WIKIPEDIA_PATH = 'wikipedia/';
|
||||
export const _BOTSU_WIKIPEDIA_ARTICLE_PATH = 'wikipedia/:slug/';
|
||||
export const _BOTSU_OPENSTREETMAP_PATH = 'openstreetmap/';
|
||||
export const _BOTSU_MOODBOARD_PATH = 'moodboard/';
|
||||
export const BOTSU_PATH = '/botsu/';
|
||||
export const BOTSU_GODOT_PATH = `/botsu/${_BOTSU_GODOT_PATH}`;
|
||||
export const BOTSU_APPS_PATH = `/botsu/${_BOTSU_APPS_PATH}`;
|
||||
export const BOTSU_SERVICES_PATH = `/botsu/${_BOTSU_SERVICES_PATH}`;
|
||||
export const BOTSU_EMBED_PATH = `/botsu/${_BOTSU_EMBED_PATH}`;
|
||||
|
||||
@@ -18,3 +18,5 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
||||
);
|
||||
|
||||
export const callChatAtom = atom<boolean>(false);
|
||||
export const drawingChatAtom = atom<boolean>(false);
|
||||
export const watchChatAtom = atom<boolean>(false);
|
||||
|
||||
@@ -11,10 +11,231 @@ export type DateFormat =
|
||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||
export type BotsuBackground = 'none' | 'grid' | 'colors' | 'gradient' | 'shaders' | 'imported';
|
||||
|
||||
export type BotsuShader = 'plasma';
|
||||
export type BotsuShader = 'plasma' | 'bubbles' | 'particles' | 'dust' | 'fractal' | 'isolayers' | 'vortex' | 'waves' | 'cells';
|
||||
|
||||
const isBotsuShader = (value: unknown): value is BotsuShader =>
|
||||
value === 'plasma';
|
||||
value === 'plasma' || value === 'bubbles' || value === 'particles' || value === 'dust' || value === 'fractal' || value === 'isolayers' || value === 'vortex' || value === 'waves' || value === 'cells';
|
||||
|
||||
export interface BotsuPlasmaOptions {
|
||||
bandDepth: number;
|
||||
bandAmplitude: number;
|
||||
bandFrequencyX: number;
|
||||
bandFrequencyY: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
cameraZ: number;
|
||||
fov: number;
|
||||
}
|
||||
|
||||
export interface BotsuBubblesOptions {
|
||||
bubbleCount: number;
|
||||
bubbleSize: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface BotsuParticlesOptions {
|
||||
particleCount: number;
|
||||
particleSize: number;
|
||||
particleLifetime: number;
|
||||
color: string;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface BotsuDustOptions {
|
||||
dustScale: number;
|
||||
dustDensity: number;
|
||||
}
|
||||
|
||||
export interface BotsuFractalOptions {
|
||||
iterations: number;
|
||||
zoom: number;
|
||||
colorSpeed: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
}
|
||||
|
||||
export interface BotsuIsolayersOptions {
|
||||
scale: number;
|
||||
flow: number;
|
||||
layers: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
color3: string;
|
||||
}
|
||||
|
||||
export interface BotsuVortexOptions {
|
||||
speed: number;
|
||||
swirlSize: number;
|
||||
pixelSize: number;
|
||||
detail: number;
|
||||
red: string;
|
||||
blue: string;
|
||||
black: string;
|
||||
}
|
||||
|
||||
export interface BotsuWavesOptions {
|
||||
scale: number;
|
||||
speed: number;
|
||||
amplitude: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BotsuCellsOptions {
|
||||
size: number;
|
||||
margin: number;
|
||||
n: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type BotsuShaderOptions =
|
||||
| ({ type: 'plasma' } & BotsuPlasmaOptions)
|
||||
| ({ type: 'bubbles' } & BotsuBubblesOptions)
|
||||
| ({ type: 'particles' } & BotsuParticlesOptions)
|
||||
| ({ type: 'dust' } & BotsuDustOptions)
|
||||
| ({ type: 'fractal' } & BotsuFractalOptions)
|
||||
| ({ type: 'isolayers' } & BotsuIsolayersOptions)
|
||||
| ({ type: 'vortex' } & BotsuVortexOptions)
|
||||
| ({ type: 'waves' } & BotsuWavesOptions)
|
||||
| ({ type: 'cells' } & BotsuCellsOptions);
|
||||
|
||||
const isBotsuShaderColor = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
|
||||
|
||||
export const defaultPlasmaOptions: BotsuPlasmaOptions = {
|
||||
bandDepth: 5.5,
|
||||
bandAmplitude: 2.25,
|
||||
bandFrequencyX: 7.0,
|
||||
bandFrequencyY: 3.0,
|
||||
color1: '#0066cc',
|
||||
color2: '#66ccff',
|
||||
color3: '#ffffff',
|
||||
cameraZ: 30.0,
|
||||
fov: 1.366,
|
||||
};
|
||||
|
||||
export const defaultBubblesOptions: BotsuBubblesOptions = {
|
||||
bubbleCount: 64,
|
||||
bubbleSize: 0.5,
|
||||
color1: '#004d33',
|
||||
color2: '#cc66ff',
|
||||
speed: 1.0,
|
||||
};
|
||||
|
||||
export const defaultParticlesOptions: BotsuParticlesOptions = {
|
||||
particleCount: 200,
|
||||
particleSize: 7.5,
|
||||
particleLifetime: 10.0,
|
||||
color: '#ffffff',
|
||||
speed: 1.0,
|
||||
};
|
||||
|
||||
export const defaultDustOptions: BotsuDustOptions = {
|
||||
dustScale: 1.0,
|
||||
dustDensity: 1.0,
|
||||
};
|
||||
|
||||
export const defaultFractalOptions: BotsuFractalOptions = {
|
||||
iterations: 8,
|
||||
zoom: 1.0,
|
||||
colorSpeed: 1.0,
|
||||
color1: '#ff3366',
|
||||
color2: '#33ccff',
|
||||
color3: '#ffcc33',
|
||||
};
|
||||
|
||||
export const defaultIsolayersOptions: BotsuIsolayersOptions = {
|
||||
scale: 1.0,
|
||||
flow: 1.0,
|
||||
layers: 10,
|
||||
color1: '#4d6bff',
|
||||
color2: '#ff6b4d',
|
||||
color3: '#4dffb0',
|
||||
};
|
||||
|
||||
export const defaultVortexOptions: BotsuVortexOptions = {
|
||||
speed: 0.8,
|
||||
swirlSize: 30.0,
|
||||
pixelSize: 700.0,
|
||||
detail: 5,
|
||||
red: '#fe5f55',
|
||||
blue: '#009dff',
|
||||
black: '#4f6367',
|
||||
};
|
||||
|
||||
export const defaultWavesOptions: BotsuWavesOptions = {
|
||||
scale: 1000.0,
|
||||
speed: 1.0,
|
||||
amplitude: 1.0,
|
||||
color: '#33801a',
|
||||
};
|
||||
|
||||
export const defaultCellsOptions: BotsuCellsOptions = {
|
||||
size: 30.0,
|
||||
margin: 0.0,
|
||||
n: 3,
|
||||
color: '#33ff66',
|
||||
};
|
||||
|
||||
export const getDefaultShaderOptions = (shader: BotsuShader): BotsuShaderOptions => {
|
||||
if (shader === 'bubbles') {
|
||||
return {
|
||||
type: 'bubbles',
|
||||
...defaultBubblesOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'particles') {
|
||||
return {
|
||||
type: 'particles',
|
||||
...defaultParticlesOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'dust') {
|
||||
return {
|
||||
type: 'dust',
|
||||
...defaultDustOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'fractal') {
|
||||
return {
|
||||
type: 'fractal',
|
||||
...defaultFractalOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'isolayers') {
|
||||
return {
|
||||
type: 'isolayers',
|
||||
...defaultIsolayersOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'vortex') {
|
||||
return {
|
||||
type: 'vortex',
|
||||
...defaultVortexOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'waves') {
|
||||
return {
|
||||
type: 'waves',
|
||||
...defaultWavesOptions,
|
||||
};
|
||||
}
|
||||
if (shader === 'cells') {
|
||||
return {
|
||||
type: 'cells',
|
||||
...defaultCellsOptions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'plasma',
|
||||
...defaultPlasmaOptions,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultShaderOptions = getDefaultShaderOptions('plasma');
|
||||
|
||||
export type BotsuTranslateLang = 'auto' | 'en' | 'nl' | 'fr' | 'de' | 'it' | 'pt' | 'es' | 'ru';
|
||||
|
||||
@@ -67,6 +288,7 @@ export interface Settings {
|
||||
botsuShader: BotsuShader;
|
||||
botsuShaderSpeed: number;
|
||||
botsuShaderIntensity: number;
|
||||
botsuShaderOptions: BotsuShaderOptions;
|
||||
botsuTranslateEnabled: boolean;
|
||||
botsuTranslateSourceLang: BotsuTranslateLang;
|
||||
botsuTranslateTargetLang: BotsuTranslateLang;
|
||||
@@ -118,6 +340,7 @@ const defaultSettings: Settings = {
|
||||
botsuShader: 'plasma',
|
||||
botsuShaderSpeed: 1.0,
|
||||
botsuShaderIntensity: 1.0,
|
||||
botsuShaderOptions: defaultShaderOptions,
|
||||
botsuTranslateEnabled: false,
|
||||
botsuTranslateSourceLang: 'auto',
|
||||
botsuTranslateTargetLang: 'en',
|
||||
@@ -150,6 +373,132 @@ const defaultSettings: Settings = {
|
||||
developerTools: false,
|
||||
};
|
||||
|
||||
function validatePlasmaOptions(opts: Partial<BotsuPlasmaOptions>): BotsuPlasmaOptions {
|
||||
return {
|
||||
bandDepth: typeof opts.bandDepth === 'number' && opts.bandDepth >= 0.5 && opts.bandDepth <= 20.0 ? opts.bandDepth : defaultPlasmaOptions.bandDepth,
|
||||
bandAmplitude: typeof opts.bandAmplitude === 'number' && opts.bandAmplitude >= 0.1 && opts.bandAmplitude <= 10.0 ? opts.bandAmplitude : defaultPlasmaOptions.bandAmplitude,
|
||||
bandFrequencyX: typeof opts.bandFrequencyX === 'number' && opts.bandFrequencyX >= 0.5 && opts.bandFrequencyX <= 30.0 ? opts.bandFrequencyX : defaultPlasmaOptions.bandFrequencyX,
|
||||
bandFrequencyY: typeof opts.bandFrequencyY === 'number' && opts.bandFrequencyY >= 0.5 && opts.bandFrequencyY <= 30.0 ? opts.bandFrequencyY : defaultPlasmaOptions.bandFrequencyY,
|
||||
color1: isBotsuShaderColor(opts.color1) ? opts.color1 : defaultPlasmaOptions.color1,
|
||||
color2: isBotsuShaderColor(opts.color2) ? opts.color2 : defaultPlasmaOptions.color2,
|
||||
color3: isBotsuShaderColor(opts.color3) ? opts.color3 : defaultPlasmaOptions.color3,
|
||||
cameraZ: typeof opts.cameraZ === 'number' && opts.cameraZ >= 5.0 && opts.cameraZ <= 100.0 ? opts.cameraZ : defaultPlasmaOptions.cameraZ,
|
||||
fov: typeof opts.fov === 'number' && opts.fov >= 0.5 && opts.fov <= 3.0 ? opts.fov : defaultPlasmaOptions.fov,
|
||||
};
|
||||
}
|
||||
|
||||
function validateBubblesOptions(opts: Partial<BotsuBubblesOptions>): BotsuBubblesOptions {
|
||||
return {
|
||||
bubbleCount: typeof opts.bubbleCount === 'number' && opts.bubbleCount >= 1 && opts.bubbleCount <= 200 ? opts.bubbleCount : defaultBubblesOptions.bubbleCount,
|
||||
bubbleSize: typeof opts.bubbleSize === 'number' && opts.bubbleSize >= 0.1 && opts.bubbleSize <= 2.0 ? opts.bubbleSize : defaultBubblesOptions.bubbleSize,
|
||||
color1: isBotsuShaderColor(opts.color1) ? opts.color1 : defaultBubblesOptions.color1,
|
||||
color2: isBotsuShaderColor(opts.color2) ? opts.color2 : defaultBubblesOptions.color2,
|
||||
speed: typeof opts.speed === 'number' && opts.speed >= 0.1 && opts.speed <= 5.0 ? opts.speed : defaultBubblesOptions.speed,
|
||||
};
|
||||
}
|
||||
|
||||
function validateParticlesOptions(opts: Partial<BotsuParticlesOptions>): BotsuParticlesOptions {
|
||||
return {
|
||||
particleCount: typeof opts.particleCount === 'number' && opts.particleCount >= 10 && opts.particleCount <= 500 ? opts.particleCount : defaultParticlesOptions.particleCount,
|
||||
particleSize: typeof opts.particleSize === 'number' && opts.particleSize >= 1.0 && opts.particleSize <= 30.0 ? opts.particleSize : defaultParticlesOptions.particleSize,
|
||||
particleLifetime: typeof opts.particleLifetime === 'number' && opts.particleLifetime >= 2.0 && opts.particleLifetime <= 30.0 ? opts.particleLifetime : defaultParticlesOptions.particleLifetime,
|
||||
color: isBotsuShaderColor(opts.color) ? opts.color : defaultParticlesOptions.color,
|
||||
speed: typeof opts.speed === 'number' && opts.speed >= 0.1 && opts.speed <= 5.0 ? opts.speed : defaultParticlesOptions.speed,
|
||||
};
|
||||
}
|
||||
|
||||
function validateDustOptions(opts: Partial<BotsuDustOptions>): BotsuDustOptions {
|
||||
return {
|
||||
dustScale: typeof opts.dustScale === 'number' && opts.dustScale >= 0.2 && opts.dustScale <= 5.0 ? opts.dustScale : defaultDustOptions.dustScale,
|
||||
dustDensity: typeof opts.dustDensity === 'number' && opts.dustDensity >= 0.0 && opts.dustDensity <= 2.0 ? opts.dustDensity : defaultDustOptions.dustDensity,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFractalOptions(opts: Partial<BotsuFractalOptions>): BotsuFractalOptions {
|
||||
return {
|
||||
iterations: typeof opts.iterations === 'number' && opts.iterations >= 1 && opts.iterations <= 16 ? opts.iterations : defaultFractalOptions.iterations,
|
||||
zoom: typeof opts.zoom === 'number' && opts.zoom >= 0.2 && opts.zoom <= 5.0 ? opts.zoom : defaultFractalOptions.zoom,
|
||||
colorSpeed: typeof opts.colorSpeed === 'number' && opts.colorSpeed >= 0.1 && opts.colorSpeed <= 5.0 ? opts.colorSpeed : defaultFractalOptions.colorSpeed,
|
||||
color1: isBotsuShaderColor(opts.color1) ? opts.color1 : defaultFractalOptions.color1,
|
||||
color2: isBotsuShaderColor(opts.color2) ? opts.color2 : defaultFractalOptions.color2,
|
||||
color3: isBotsuShaderColor(opts.color3) ? opts.color3 : defaultFractalOptions.color3,
|
||||
};
|
||||
}
|
||||
|
||||
function validateIsolayersOptions(opts: Partial<BotsuIsolayersOptions>): BotsuIsolayersOptions {
|
||||
return {
|
||||
scale: typeof opts.scale === 'number' && opts.scale >= 0.2 && opts.scale <= 5.0 ? opts.scale : defaultIsolayersOptions.scale,
|
||||
flow: typeof opts.flow === 'number' && opts.flow >= 0.0 && opts.flow <= 5.0 ? opts.flow : defaultIsolayersOptions.flow,
|
||||
layers: typeof opts.layers === 'number' && opts.layers >= 2 && opts.layers <= 30 ? opts.layers : defaultIsolayersOptions.layers,
|
||||
color1: isBotsuShaderColor(opts.color1) ? opts.color1 : defaultIsolayersOptions.color1,
|
||||
color2: isBotsuShaderColor(opts.color2) ? opts.color2 : defaultIsolayersOptions.color2,
|
||||
color3: isBotsuShaderColor(opts.color3) ? opts.color3 : defaultIsolayersOptions.color3,
|
||||
};
|
||||
}
|
||||
|
||||
function validateVortexOptions(opts: Partial<BotsuVortexOptions>): BotsuVortexOptions {
|
||||
return {
|
||||
speed: typeof opts.speed === 'number' && opts.speed >= 0.1 && opts.speed <= 3.0 ? opts.speed : defaultVortexOptions.speed,
|
||||
swirlSize: typeof opts.swirlSize === 'number' && opts.swirlSize >= 5.0 && opts.swirlSize <= 100.0 ? opts.swirlSize : defaultVortexOptions.swirlSize,
|
||||
pixelSize: typeof opts.pixelSize === 'number' && opts.pixelSize >= 200.0 && opts.pixelSize <= 2000.0 ? opts.pixelSize : defaultVortexOptions.pixelSize,
|
||||
detail: typeof opts.detail === 'number' && opts.detail >= 1 && opts.detail <= 12 ? opts.detail : defaultVortexOptions.detail,
|
||||
red: isBotsuShaderColor(opts.red) ? opts.red : defaultVortexOptions.red,
|
||||
blue: isBotsuShaderColor(opts.blue) ? opts.blue : defaultVortexOptions.blue,
|
||||
black: isBotsuShaderColor(opts.black) ? opts.black : defaultVortexOptions.black,
|
||||
};
|
||||
}
|
||||
|
||||
function validateWavesOptions(opts: Partial<BotsuWavesOptions>): BotsuWavesOptions {
|
||||
return {
|
||||
scale: typeof opts.scale === 'number' && opts.scale >= 100.0 && opts.scale <= 5000.0 ? opts.scale : defaultWavesOptions.scale,
|
||||
speed: typeof opts.speed === 'number' && opts.speed >= 0.1 && opts.speed <= 5.0 ? opts.speed : defaultWavesOptions.speed,
|
||||
amplitude: typeof opts.amplitude === 'number' && opts.amplitude >= 0.1 && opts.amplitude <= 5.0 ? opts.amplitude : defaultWavesOptions.amplitude,
|
||||
color: isBotsuShaderColor(opts.color) ? opts.color : defaultWavesOptions.color,
|
||||
};
|
||||
}
|
||||
|
||||
function validateCellsOptions(opts: Partial<BotsuCellsOptions>): BotsuCellsOptions {
|
||||
return {
|
||||
size: typeof opts.size === 'number' && opts.size >= 5.0 && opts.size <= 200.0 ? opts.size : defaultCellsOptions.size,
|
||||
margin: typeof opts.margin === 'number' && opts.margin >= -100.0 && opts.margin <= 100.0 ? opts.margin : defaultCellsOptions.margin,
|
||||
n: typeof opts.n === 'number' && opts.n >= 1 && opts.n <= 8 ? opts.n : defaultCellsOptions.n,
|
||||
color: isBotsuShaderColor(opts.color) ? opts.color : defaultCellsOptions.color,
|
||||
};
|
||||
}
|
||||
|
||||
function validateShaderOptions(raw: unknown): BotsuShaderOptions {
|
||||
if (!raw || typeof raw !== 'object') return defaultShaderOptions;
|
||||
const opts = raw as Record<string, unknown>;
|
||||
if (opts.type === 'plasma') {
|
||||
return { type: 'plasma', ...validatePlasmaOptions(opts as Partial<BotsuPlasmaOptions>) };
|
||||
}
|
||||
if (opts.type === 'bubbles') {
|
||||
return { type: 'bubbles', ...validateBubblesOptions(opts as Partial<BotsuBubblesOptions>) };
|
||||
}
|
||||
if (opts.type === 'particles') {
|
||||
return { type: 'particles', ...validateParticlesOptions(opts as Partial<BotsuParticlesOptions>) };
|
||||
}
|
||||
if (opts.type === 'dust') {
|
||||
return { type: 'dust', ...validateDustOptions(opts as Partial<BotsuDustOptions>) };
|
||||
}
|
||||
if (opts.type === 'fractal') {
|
||||
return { type: 'fractal', ...validateFractalOptions(opts as Partial<BotsuFractalOptions>) };
|
||||
}
|
||||
if (opts.type === 'isolayers') {
|
||||
return { type: 'isolayers', ...validateIsolayersOptions(opts as Partial<BotsuIsolayersOptions>) };
|
||||
}
|
||||
if (opts.type === 'vortex') {
|
||||
return { type: 'vortex', ...validateVortexOptions(opts as Partial<BotsuVortexOptions>) };
|
||||
}
|
||||
if (opts.type === 'waves') {
|
||||
return { type: 'waves', ...validateWavesOptions(opts as Partial<BotsuWavesOptions>) };
|
||||
}
|
||||
if (opts.type === 'cells') {
|
||||
return { type: 'cells', ...validateCellsOptions(opts as Partial<BotsuCellsOptions>) };
|
||||
}
|
||||
return defaultShaderOptions;
|
||||
}
|
||||
|
||||
export const getSettings = (): Settings => {
|
||||
const settings = localStorage.getItem(STORAGE_KEY);
|
||||
if (settings === null) return defaultSettings;
|
||||
@@ -160,8 +509,13 @@ export const getSettings = (): Settings => {
|
||||
botsuShaderIntensity: _botsuShaderIntensity,
|
||||
botsuShaderOptions: _botsuShaderOptions,
|
||||
botsuShaderProfiles: _botsuShaderProfiles,
|
||||
botsuPlasmaOptions: _botsuPlasmaOptions,
|
||||
...stored
|
||||
} = JSON.parse(settings) as Partial<Settings> & Record<string, unknown>;
|
||||
const botsuShader = isBotsuShader(stored.botsuShader) ? stored.botsuShader : defaultSettings.botsuShader;
|
||||
const botsuShaderOptions = validateShaderOptions(stored.botsuShaderOptions);
|
||||
const normalizedShaderOptions =
|
||||
botsuShaderOptions.type === botsuShader ? botsuShaderOptions : getDefaultShaderOptions(botsuShader);
|
||||
return {
|
||||
...defaultSettings,
|
||||
...stored,
|
||||
@@ -170,9 +524,10 @@ export const getSettings = (): Settings => {
|
||||
botsuBackgroundGradientStart: isBotsuColor(stored.botsuBackgroundGradientStart) ? stored.botsuBackgroundGradientStart : defaultSettings.botsuBackgroundGradientStart,
|
||||
botsuBackgroundGradientEnd: isBotsuColor(stored.botsuBackgroundGradientEnd) ? stored.botsuBackgroundGradientEnd : defaultSettings.botsuBackgroundGradientEnd,
|
||||
botsuBackgroundImage: isBotsuBackgroundImage(stored.botsuBackgroundImage) ? stored.botsuBackgroundImage : '',
|
||||
botsuShader: isBotsuShader(stored.botsuShader) ? stored.botsuShader : defaultSettings.botsuShader,
|
||||
botsuShader,
|
||||
botsuShaderSpeed: typeof stored.botsuShaderSpeed === 'number' && stored.botsuShaderSpeed >= 0.1 && stored.botsuShaderSpeed <= 3.0 ? stored.botsuShaderSpeed : defaultSettings.botsuShaderSpeed,
|
||||
botsuShaderIntensity: typeof stored.botsuShaderIntensity === 'number' && stored.botsuShaderIntensity >= 0.1 && stored.botsuShaderIntensity <= 2.0 ? stored.botsuShaderIntensity : defaultSettings.botsuShaderIntensity,
|
||||
botsuShaderOptions: normalizedShaderOptions,
|
||||
botsuTranslateEnabled: typeof stored.botsuTranslateEnabled === 'boolean' ? stored.botsuTranslateEnabled : defaultSettings.botsuTranslateEnabled,
|
||||
botsuTranslateSourceLang: isBotsuTranslateLang(stored.botsuTranslateSourceLang) ? stored.botsuTranslateSourceLang : defaultSettings.botsuTranslateSourceLang,
|
||||
botsuTranslateTargetLang: isBotsuTranslateLang(stored.botsuTranslateTargetLang) ? stored.botsuTranslateTargetLang : defaultSettings.botsuTranslateTargetLang,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
RoomMember,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import { StoryRoomIcon } from '../../botsu/storyboard/StoryRoomIcon';
|
||||
import { VoxelRoomIcon } from '../../botsu/voxel/VoxelRoomIcon';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import {
|
||||
IRoomCreateContent,
|
||||
@@ -91,7 +93,16 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
|
||||
const type = event.getContent().type;
|
||||
return type !== undefined && type !== RoomType.Space && type !== RoomType.Document;
|
||||
return (
|
||||
type !== undefined &&
|
||||
type !== RoomType.Space &&
|
||||
type !== RoomType.Document &&
|
||||
type !== RoomType.Drawing &&
|
||||
type !== RoomType.Voxel &&
|
||||
type !== RoomType.Watch &&
|
||||
type !== RoomType.Storyboard &&
|
||||
type !== RoomType.Kanban
|
||||
);
|
||||
};
|
||||
|
||||
export const isDocumentRoom = (room: Room | null): boolean => {
|
||||
@@ -101,6 +112,41 @@ export const isDocumentRoom = (room: Room | null): boolean => {
|
||||
return event.getContent().type === RoomType.Document;
|
||||
};
|
||||
|
||||
export const isDrawingRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Drawing;
|
||||
};
|
||||
|
||||
export const isVoxelRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Voxel;
|
||||
};
|
||||
|
||||
export const isWatchRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Watch;
|
||||
};
|
||||
|
||||
export const isStoryboardRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Storyboard;
|
||||
};
|
||||
|
||||
export const isKanbanRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Kanban;
|
||||
};
|
||||
|
||||
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
mEvent.getType() === StateEvent.SpaceChild &&
|
||||
@@ -307,6 +353,26 @@ export const getRoomIconSrc = (
|
||||
return icons.File;
|
||||
}
|
||||
|
||||
if (roomType === RoomType.Drawing) {
|
||||
if (joinRule === JoinRule.Public) return icons.Pencil;
|
||||
if (
|
||||
joinRule === JoinRule.Invite ||
|
||||
joinRule === JoinRule.Knock ||
|
||||
joinRule === JoinRule.Private
|
||||
) {
|
||||
return icons.Pencil;
|
||||
}
|
||||
return icons.Pencil;
|
||||
}
|
||||
|
||||
if (roomType === RoomType.Voxel) return VoxelRoomIcon;
|
||||
|
||||
if (roomType === RoomType.Watch) return icons.Play;
|
||||
|
||||
if (roomType === RoomType.Storyboard) return StoryRoomIcon;
|
||||
|
||||
if (roomType === RoomType.Kanban) return icons.Category;
|
||||
|
||||
if (joinRule === JoinRule.Public) return icons.HashGlobe;
|
||||
if (
|
||||
joinRule === JoinRule.Invite ||
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { botsuApps, getApp, getVisibleApps } from './catalog.ts';
|
||||
import { botsuApps, getApp, getBotsuApplicationApps, getVisibleApps } from './catalog.ts';
|
||||
|
||||
test('member catalogue exposes the collaborative suite without administration', () => {
|
||||
const apps = getVisibleApps(new Set(['member']));
|
||||
|
||||
assert.deepEqual(
|
||||
apps.map((app) => app.id),
|
||||
['discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel', 'emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'peertube', 'wikipedia', 'pets', 'openstreetmap']
|
||||
[
|
||||
'godot',
|
||||
'moodboard',
|
||||
'discussions',
|
||||
'documents',
|
||||
'tables',
|
||||
'files',
|
||||
'transfers',
|
||||
'services',
|
||||
'generations-audiovisuel',
|
||||
'generations-vision',
|
||||
'generations-textuel',
|
||||
'emoji-tcg',
|
||||
'radio',
|
||||
'shop',
|
||||
'voxel',
|
||||
'dessin',
|
||||
'cinema',
|
||||
'casino',
|
||||
'bourse',
|
||||
'peertube',
|
||||
'wikipedia',
|
||||
'pets',
|
||||
'openstreetmap',
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
apps.some((app) => app.id === 'administration'),
|
||||
@@ -25,6 +49,32 @@ test('administrator catalogue includes administration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('BOTSU applications page hides room/stream experiments and keeps Pinterest', () => {
|
||||
const apps = getBotsuApplicationApps(new Set(['member']));
|
||||
const ids = apps.map((app) => app.id);
|
||||
|
||||
assert.ok(ids.includes('moodboard'));
|
||||
assert.equal(getApp('moodboard')?.label, 'Pinterest');
|
||||
assert.equal(ids.includes('dessin'), false);
|
||||
assert.equal(ids.includes('voxel'), false);
|
||||
assert.equal(ids.includes('cinema'), false);
|
||||
assert.equal(ids.includes('radio'), false);
|
||||
|
||||
assert.equal(getApp('dessin')?.label, 'Dessin');
|
||||
assert.equal(getApp('voxel')?.label, 'Voxel Room');
|
||||
assert.equal(getApp('cinema')?.label, 'Watch Room');
|
||||
assert.equal(getApp('radio')?.label, 'Radio');
|
||||
});
|
||||
|
||||
test('BOTSU applications page keeps administration hidden from members', () => {
|
||||
const apps = getBotsuApplicationApps(new Set(['member']));
|
||||
|
||||
assert.equal(
|
||||
apps.some((app) => app.id === 'administration'),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('catalogue identifiers and native paths are unique', () => {
|
||||
const ids = botsuApps.map((app) => app.id);
|
||||
const nativePaths = botsuApps.flatMap((app) =>
|
||||
@@ -37,19 +87,31 @@ test('catalogue identifiers and native paths are unique', () => {
|
||||
|
||||
test('catalogue records live and planned applications explicitly', () => {
|
||||
assert.equal(getApp('discussions')?.availability, 'available');
|
||||
assert.deepEqual(getApp('moodboard')?.launch, {
|
||||
mode: 'native',
|
||||
path: '/botsu/moodboard/',
|
||||
});
|
||||
assert.equal(getApp('documents')?.availability, 'planned');
|
||||
assert.equal(getApp('tables')?.launch.mode, 'iframe');
|
||||
assert.equal(getApp('transfers')?.launch.mode, 'external');
|
||||
assert.equal(getApp('radio')?.availability, 'planned');
|
||||
assert.equal(getApp('shop')?.availability, 'planned');
|
||||
assert.equal(getApp('voxel')?.availability, 'planned');
|
||||
assert.equal(getApp('voxel')?.availability, 'available');
|
||||
assert.deepEqual(getApp('voxel')?.launch, {
|
||||
mode: 'native',
|
||||
path: '/home/create/?type=voxel',
|
||||
});
|
||||
assert.equal(getApp('dessin')?.availability, 'planned');
|
||||
assert.equal(getApp('cinema')?.availability, 'planned');
|
||||
assert.equal(getApp('cinema')?.availability, 'available');
|
||||
assert.deepEqual(getApp('cinema')?.launch, {
|
||||
mode: 'native',
|
||||
path: '/home/create/?type=watch',
|
||||
});
|
||||
assert.equal(getApp('casino')?.availability, 'planned');
|
||||
assert.equal(getApp('bourse')?.availability, 'planned');
|
||||
assert.equal(getApp('peertube')?.availability, 'planned');
|
||||
assert.equal(getApp('wikipedia')?.availability, 'available');
|
||||
assert.equal(getApp('pets')?.availability, 'planned');
|
||||
assert.equal(getApp('openstreetmap')?.availability, 'planned');
|
||||
assert.equal(getApp('openstreetmap')?.availability, 'available');
|
||||
assert.equal(getApp('missing'), undefined);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type BotsuRole = 'member' | 'admin';
|
||||
export type BotsuAppId =
|
||||
| 'godot'
|
||||
| 'moodboard'
|
||||
| 'discussions'
|
||||
| 'documents'
|
||||
| 'tables'
|
||||
@@ -38,6 +40,22 @@ export type BotsuApp = {
|
||||
};
|
||||
|
||||
export const botsuApps: readonly BotsuApp[] = [
|
||||
{
|
||||
id: 'godot',
|
||||
label: 'Godot',
|
||||
description: 'IDE Godot personnalisé BOTSU — création de jeux et applications.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/botsu/godot/' },
|
||||
},
|
||||
{
|
||||
id: 'moodboard',
|
||||
label: 'Pinterest',
|
||||
description: 'Moodboard partagé — images, couleurs, textes, fichiers et liens.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/botsu/moodboard/' },
|
||||
},
|
||||
{
|
||||
id: 'discussions',
|
||||
label: 'Discussions',
|
||||
@@ -105,7 +123,7 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
{
|
||||
id: 'generations-vision',
|
||||
label: 'Vision',
|
||||
description: 'Génération et analyse d\'images.',
|
||||
description: "Génération et analyse d'images.",
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/generations/vision/' },
|
||||
@@ -144,11 +162,11 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
},
|
||||
{
|
||||
id: 'voxel',
|
||||
label: 'Voxel',
|
||||
description: 'Édition et création voxel.',
|
||||
label: 'Voxel Room',
|
||||
description: 'Atelier voxel et Minecraft collaboratif en temps réel.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/voxel/' },
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/home/create/?type=voxel' },
|
||||
},
|
||||
{
|
||||
id: 'dessin',
|
||||
@@ -160,11 +178,11 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
},
|
||||
{
|
||||
id: 'cinema',
|
||||
label: 'Cinéma',
|
||||
description: 'Films et séries.',
|
||||
label: 'Watch Room',
|
||||
description: 'Théâtre partagé avec lecture synchronisée et discussion Matrix.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/cinema/' },
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/home/create/?type=watch' },
|
||||
},
|
||||
{
|
||||
id: 'casino',
|
||||
@@ -211,15 +229,22 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
label: 'OpenStreetMap',
|
||||
description: 'Cartographie libre et collaborative.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'planned',
|
||||
availability: 'available',
|
||||
launch: { mode: 'native', path: '/botsu/openstreetmap/' },
|
||||
},
|
||||
];
|
||||
|
||||
const HIDDEN_APPLICATION_IDS = new Set<string>(['dessin', 'voxel', 'cinema', 'radio']);
|
||||
|
||||
export const getVisibleApps = (
|
||||
roles: ReadonlySet<BotsuRole>,
|
||||
apps: readonly BotsuApp[] = botsuApps
|
||||
): readonly BotsuApp[] => apps.filter((app) => app.roles.some((role) => roles.has(role)));
|
||||
|
||||
export const getBotsuApplicationApps = (
|
||||
roles: ReadonlySet<BotsuRole>,
|
||||
apps: readonly BotsuApp[] = botsuApps
|
||||
): readonly BotsuApp[] => getVisibleApps(roles, apps).filter((app) => !HIDDEN_APPLICATION_IDS.has(app.id));
|
||||
|
||||
export const getApp = (id: string, apps: readonly BotsuApp[] = botsuApps): BotsuApp | undefined =>
|
||||
apps.find((app) => app.id === id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BotsuApp } from './catalog';
|
||||
|
||||
const trustedIframeOrigins = new Set(['https://tabs.botsu.net']);
|
||||
const trustedExternalOrigins = new Set(['https://share.botsu.net', 'https://home.botsu.net']);
|
||||
const trustedIframeOrigins = new Set(['https://tabs.botsu.net', 'https://test.botsu.net']);
|
||||
const trustedExternalOrigins = new Set(['https://share.botsu.net', 'https://home.botsu.net', 'https://test.botsu.net']);
|
||||
|
||||
export type LaunchPlan =
|
||||
| { kind: 'navigate'; to: string }
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
BOTSU_DRAWING_SIZE,
|
||||
type DrawingElement,
|
||||
type DrawingPoint,
|
||||
type DrawingShape,
|
||||
} from '@botsu/protocol';
|
||||
import { useDrawing } from './DrawingContext';
|
||||
import { useDrawingTool } from './DrawingToolContext';
|
||||
import {
|
||||
applyDrawingElement,
|
||||
createDrawingFillRuns,
|
||||
findDrawingElementAt,
|
||||
getDrawingElementBounds,
|
||||
moveDrawingElement,
|
||||
sampleDrawingPoint,
|
||||
} from './drawing-model';
|
||||
import { downloadDrawingBlob, encodeDrawingGif } from './drawing-export';
|
||||
|
||||
const createElementId = (): string => `draw_${crypto.randomUUID()}`;
|
||||
const clamp = (value: number): number => Math.max(0, Math.min(BOTSU_DRAWING_SIZE - 1, Math.round(value)));
|
||||
|
||||
const getPoint = (canvas: HTMLCanvasElement, clientX: number, clientY: number): DrawingPoint => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: clamp(((clientX - bounds.left) / bounds.width) * BOTSU_DRAWING_SIZE),
|
||||
y: clamp(((clientY - bounds.top) / bounds.height) * BOTSU_DRAWING_SIZE),
|
||||
};
|
||||
};
|
||||
|
||||
const fontFamily = (font: string, siteFont: string): string => {
|
||||
if (font === 'inter') return 'Inter, ui-sans-serif, sans-serif';
|
||||
if (font === 'velvelyne') return '"Botsu Velvelyne", ui-sans-serif, sans-serif';
|
||||
if (font === 'minecraft') return '"Botsu Minecraft", monospace';
|
||||
if (font === 'monospace') return 'ui-monospace, SFMono-Regular, Consolas, monospace';
|
||||
return siteFont || 'Inter, ui-sans-serif, sans-serif';
|
||||
};
|
||||
|
||||
const pathPoints = (context: CanvasRenderingContext2D, points: readonly DrawingPoint[]): void => {
|
||||
const first = points[0];
|
||||
if (!first) return;
|
||||
context.beginPath();
|
||||
context.moveTo(first.x, first.y);
|
||||
points.slice(1).forEach((point) => context.lineTo(point.x, point.y));
|
||||
};
|
||||
|
||||
const drawElement = (
|
||||
context: CanvasRenderingContext2D,
|
||||
element: DrawingElement,
|
||||
siteFont: string
|
||||
): void => {
|
||||
context.save();
|
||||
if (element.kind === 'fill') {
|
||||
context.fillStyle = element.color;
|
||||
element.runs.forEach((run) =>
|
||||
context.fillRect(run.startX, run.y, run.endX - run.startX + 1, 1)
|
||||
);
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
if (element.kind === 'text') {
|
||||
context.fillStyle = element.color;
|
||||
context.font = `${element.size}px ${fontFamily(element.font, siteFont)}`;
|
||||
context.textBaseline = 'alphabetic';
|
||||
context.fillText(element.text, element.x, element.y, BOTSU_DRAWING_SIZE - element.x);
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.lineWidth = element.size;
|
||||
context.strokeStyle = element.kind === 'stroke' && element.mode === 'erase' ? '#ffffff' : element.color;
|
||||
context.fillStyle = element.color;
|
||||
if (element.kind === 'stroke') {
|
||||
pathPoints(context, element.points);
|
||||
if (element.points.length === 1) {
|
||||
const point = element.points[0]!;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, element.size / 2, 0, Math.PI * 2);
|
||||
context.fillStyle = element.mode === 'erase' ? '#ffffff' : element.color;
|
||||
context.fill();
|
||||
} else {
|
||||
context.stroke();
|
||||
}
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
const [start, end] = element.points;
|
||||
if (!start || !end) { context.restore(); return; }
|
||||
if (element.shape === 'line') {
|
||||
pathPoints(context, element.points);
|
||||
context.stroke();
|
||||
} else if (element.shape === 'polygon') {
|
||||
pathPoints(context, element.points);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
} else {
|
||||
const left = Math.min(start.x, end.x);
|
||||
const top = Math.min(start.y, end.y);
|
||||
const width = Math.abs(end.x - start.x);
|
||||
const height = Math.abs(end.y - start.y);
|
||||
if (element.shape === 'rectangle' || element.shape === 'rectangle-filled') {
|
||||
if (element.shape === 'rectangle-filled') context.fillRect(left, top, width, height);
|
||||
else context.strokeRect(left, top, width, height);
|
||||
} else {
|
||||
context.beginPath();
|
||||
context.ellipse(left + width / 2, top + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
if (element.shape === 'circle-filled') context.fill();
|
||||
else context.stroke();
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderDrawing = (
|
||||
canvas: HTMLCanvasElement,
|
||||
elements: readonly DrawingElement[],
|
||||
selectedId?: string
|
||||
): void => {
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!context) return;
|
||||
context.clearRect(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
|
||||
const siteFont = getComputedStyle(canvas).fontFamily;
|
||||
elements.forEach((element) => drawElement(context, element, siteFont));
|
||||
const selected = elements.find((element) => element.id === selectedId);
|
||||
if (selected) {
|
||||
const bounds = getDrawingElementBounds(selected);
|
||||
context.save();
|
||||
context.strokeStyle = '#3578e5';
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([8, 6]);
|
||||
context.strokeRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top);
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const shapeForTool: Partial<Record<string, DrawingShape>> = {
|
||||
rectangle: 'rectangle',
|
||||
'rectangle-filled': 'rectangle-filled',
|
||||
circle: 'circle',
|
||||
'circle-filled': 'circle-filled',
|
||||
line: 'line',
|
||||
};
|
||||
|
||||
export function BotsuDrawingCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const activeRef = useRef<DrawingElement>();
|
||||
const dragRef = useRef<{ element: DrawingElement; start: DrawingPoint }>();
|
||||
const polygonRef = useRef<DrawingPoint[]>([]);
|
||||
const lastPublishRef = useRef(0);
|
||||
const [preview, setPreview] = useState<DrawingElement>();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const { state, upsert, remove } = useDrawing();
|
||||
const {
|
||||
tool,
|
||||
setTool,
|
||||
color,
|
||||
setColor,
|
||||
size,
|
||||
font,
|
||||
fontSize,
|
||||
registerExporters,
|
||||
} = useDrawingTool();
|
||||
|
||||
const elements = useMemo(
|
||||
() => (preview ? applyDrawingElement(state.elements, preview) : state.elements),
|
||||
[preview, state.elements]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) renderDrawing(canvas, elements, selectedId);
|
||||
}, [elements, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const exportPng = () => {
|
||||
canvasRef.current?.toBlob((blob) => {
|
||||
if (blob) downloadDrawingBlob(blob, 'dessin-botsu.png');
|
||||
}, 'image/png');
|
||||
};
|
||||
const exportGif = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext('2d', { willReadFrequently: true });
|
||||
if (!canvas || !context) return;
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const bytes = encodeDrawingGif(canvas.width, canvas.height, image.data);
|
||||
const buffer = Uint8Array.from(bytes).buffer;
|
||||
downloadDrawingBlob(new Blob([buffer], { type: 'image/gif' }), 'dessin-botsu.gif');
|
||||
};
|
||||
return registerExporters(exportPng, exportGif);
|
||||
}, [registerExporters]);
|
||||
|
||||
const publishActive = (force = false): boolean => {
|
||||
const active = activeRef.current;
|
||||
if (!active) return false;
|
||||
const now = performance.now();
|
||||
if (!force && now - lastPublishRef.current < 125) return true;
|
||||
lastPublishRef.current = now;
|
||||
return upsert(active);
|
||||
};
|
||||
|
||||
const commitPolygon = (): void => {
|
||||
if (polygonRef.current.length < 2) return;
|
||||
const element: DrawingElement = {
|
||||
id: createElementId(),
|
||||
kind: 'shape',
|
||||
shape: 'polygon',
|
||||
color,
|
||||
size,
|
||||
points: polygonRef.current.slice(0, 64),
|
||||
};
|
||||
polygonRef.current = [];
|
||||
setPreview(undefined);
|
||||
upsert(element);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>): void => {
|
||||
if (state.status !== 'online') return;
|
||||
const point = getPoint(event.currentTarget, event.clientX, event.clientY);
|
||||
if (tool === 'select') {
|
||||
const selected = findDrawingElementAt(state.elements, point);
|
||||
setSelectedId(selected?.id);
|
||||
if (selected) {
|
||||
dragRef.current = { element: selected, start: point };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tool === 'eyedropper') {
|
||||
const context = event.currentTarget.getContext('2d', { willReadFrequently: true });
|
||||
const pixel = context?.getImageData(point.x, point.y, 1, 1).data;
|
||||
if (pixel) {
|
||||
setColor(`#${[pixel[0], pixel[1], pixel[2]].map((value) => (value ?? 0).toString(16).padStart(2, '0')).join('')}`);
|
||||
setTool('pencil');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tool === 'bucket') {
|
||||
const context = event.currentTarget.getContext('2d', { willReadFrequently: true });
|
||||
const image = context?.getImageData(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
|
||||
const runs = image
|
||||
? createDrawingFillRuns(
|
||||
image.data,
|
||||
BOTSU_DRAWING_SIZE,
|
||||
BOTSU_DRAWING_SIZE,
|
||||
point,
|
||||
color
|
||||
)
|
||||
: undefined;
|
||||
if (runs?.length) upsert({ id: createElementId(), kind: 'fill', color, runs });
|
||||
return;
|
||||
}
|
||||
if (tool === 'text') {
|
||||
const text = window.prompt('Texte');
|
||||
if (text?.trim()) {
|
||||
upsert({
|
||||
id: createElementId(),
|
||||
kind: 'text',
|
||||
color,
|
||||
font,
|
||||
size: fontSize,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
text: text.slice(0, 512),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tool === 'polygon') {
|
||||
polygonRef.current = [...polygonRef.current, point].slice(0, 64);
|
||||
if (event.detail >= 2) {
|
||||
polygonRef.current = polygonRef.current.slice(0, -1);
|
||||
commitPolygon();
|
||||
} else if (polygonRef.current.length >= 2) {
|
||||
setPreview({
|
||||
id: 'draw_00000000-0000-4000-8000-000000000000',
|
||||
kind: 'shape',
|
||||
shape: 'polygon',
|
||||
color,
|
||||
size,
|
||||
points: polygonRef.current,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const shape = shapeForTool[tool];
|
||||
const element: DrawingElement = shape
|
||||
? { id: createElementId(), kind: 'shape', shape, color, size, points: [point, point] }
|
||||
: {
|
||||
id: createElementId(),
|
||||
kind: 'stroke',
|
||||
mode: tool === 'eraser' ? 'erase' : 'paint',
|
||||
color,
|
||||
size,
|
||||
points: [point],
|
||||
};
|
||||
activeRef.current = element;
|
||||
setPreview(element);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>): void => {
|
||||
const point = getPoint(event.currentTarget, event.clientX, event.clientY);
|
||||
if (dragRef.current) {
|
||||
const moved = moveDrawingElement(
|
||||
dragRef.current.element,
|
||||
point.x - dragRef.current.start.x,
|
||||
point.y - dragRef.current.start.y
|
||||
);
|
||||
setPreview(moved);
|
||||
return;
|
||||
}
|
||||
const active = activeRef.current;
|
||||
if (!active) return;
|
||||
if (active.kind === 'stroke') {
|
||||
if (active.points.length >= 128) {
|
||||
if (!publishActive(true)) {
|
||||
setPreview(active);
|
||||
return;
|
||||
}
|
||||
const last = active.points.at(-1)!;
|
||||
activeRef.current = { ...active, id: createElementId(), points: [last, point] };
|
||||
setPreview(activeRef.current);
|
||||
return;
|
||||
}
|
||||
const sampled = sampleDrawingPoint(active.points, point, Math.max(2, active.size / 3));
|
||||
if (sampled === active.points) return;
|
||||
if (sampled.length >= 128) {
|
||||
activeRef.current = { ...active, points: [...sampled] };
|
||||
if (!publishActive(true)) {
|
||||
setPreview(activeRef.current);
|
||||
return;
|
||||
}
|
||||
const last = sampled.at(-1)!;
|
||||
activeRef.current = { ...active, id: createElementId(), points: [last] };
|
||||
} else {
|
||||
activeRef.current = { ...active, points: [...sampled] };
|
||||
}
|
||||
} else if (active.kind === 'shape') {
|
||||
activeRef.current = { ...active, points: [active.points[0]!, point] };
|
||||
}
|
||||
setPreview(activeRef.current);
|
||||
publishActive();
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: React.PointerEvent<HTMLCanvasElement>): void => {
|
||||
if (dragRef.current && preview) upsert(preview);
|
||||
else publishActive(true);
|
||||
dragRef.current = undefined;
|
||||
activeRef.current = undefined;
|
||||
setPreview(undefined);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>): void => {
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedId) {
|
||||
event.preventDefault();
|
||||
if (remove(selectedId)) setSelectedId(undefined);
|
||||
} else if (event.key === 'Enter' && tool === 'polygon') {
|
||||
event.preventDefault();
|
||||
commitPolygon();
|
||||
} else if (event.key === 'Escape') {
|
||||
polygonRef.current = [];
|
||||
activeRef.current = undefined;
|
||||
dragRef.current = undefined;
|
||||
setPreview(undefined);
|
||||
setSelectedId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const status = {
|
||||
idle: 'Inactif',
|
||||
syncing: 'Synchronisation…',
|
||||
online: `Temps réel · révision ${state.revision}`,
|
||||
offline: 'Hors ligne',
|
||||
}[state.status];
|
||||
|
||||
return (
|
||||
<div className="botsu-drawing-canvas-frame">
|
||||
<div className="botsu-drawing-status" role="status" aria-live="polite">
|
||||
<span className={`botsu-presence-dot is-${state.status === 'online' ? 'online' : 'offline'}`} />
|
||||
{status}
|
||||
</div>
|
||||
<canvas
|
||||
aria-label="Page de dessin collaborative 1024 par 1024"
|
||||
className="botsu-drawing-canvas"
|
||||
data-tool={tool}
|
||||
height={BOTSU_DRAWING_SIZE}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
width={BOTSU_DRAWING_SIZE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { BotsuDrawingCanvas } from './BotsuDrawingCanvas';
|
||||
import './drawing.css';
|
||||
|
||||
export function BotsuDrawingRoomView() {
|
||||
return (
|
||||
<section className="botsu-drawing-room" aria-label="Salle de dessin">
|
||||
<div className="botsu-drawing-room__canvas">
|
||||
<BotsuDrawingCanvas />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { createBotsuDrawingResourceId, parseDrawingReference } from '@botsu/protocol';
|
||||
import { useRoom } from '../../app/hooks/useRoom';
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { useStateEvent } from '../../app/hooks/useStateEvent';
|
||||
import { readPowerLevel, usePowerLevels } from '../../app/hooks/usePowerLevels';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from '../presence/socket-message';
|
||||
import { useDrawingBridge } from './DrawingContext';
|
||||
|
||||
export function BotsuDrawingTransport() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const drawingBridge = useDrawingBridge();
|
||||
const drawingEvent = useStateEvent(room, StateEvent.BotsuDrawing);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const provisioningRoomRef = useRef<string>();
|
||||
const canProvision =
|
||||
readPowerLevel.user(powerLevels, mx.getSafeUserId()) >=
|
||||
readPowerLevel.state(powerLevels, StateEvent.BotsuDrawing);
|
||||
const resourceId = useMemo(() => {
|
||||
try {
|
||||
return parseDrawingReference(drawingEvent?.getContent()).resourceId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [drawingEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawingEvent || !canProvision || provisioningRoomRef.current === room.roomId) {
|
||||
return undefined;
|
||||
}
|
||||
provisioningRoomRef.current = room.roomId;
|
||||
let active = true;
|
||||
const resourceId = createBotsuDrawingResourceId(crypto.randomUUID());
|
||||
void mx
|
||||
.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.BotsuDrawing as any,
|
||||
{ version: 1, resourceId },
|
||||
''
|
||||
)
|
||||
.catch(() => {
|
||||
if (active && provisioningRoomRef.current === room.roomId) {
|
||||
provisioningRoomRef.current = undefined;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [canProvision, drawingEvent, mx, room.roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
drawingBridge.reset();
|
||||
if (!resourceId) return undefined;
|
||||
let stopped = false;
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: number | undefined;
|
||||
let heartbeatTimer: number | undefined;
|
||||
let detachDrawingSender: (() => void) | undefined;
|
||||
|
||||
const clearHeartbeat = () => {
|
||||
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = undefined;
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
const currentSocket = new WebSocket(createPresenceWebSocketUrl(window.location.origin));
|
||||
socket = currentSocket;
|
||||
|
||||
currentSocket.addEventListener('open', async () => {
|
||||
try {
|
||||
const token = await mx.getOpenIdToken();
|
||||
if (stopped || currentSocket !== socket || currentSocket.readyState !== WebSocket.OPEN) return;
|
||||
currentSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'presence.hello',
|
||||
protocolVersion: 1,
|
||||
appId: 'client',
|
||||
openIdToken: {
|
||||
accessToken: token.access_token,
|
||||
matrixServerName: token.matrix_server_name,
|
||||
expiresIn: token.expires_in,
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
currentSocket.close();
|
||||
}
|
||||
});
|
||||
|
||||
currentSocket.addEventListener('message', (event) => {
|
||||
if (stopped || currentSocket !== socket) return;
|
||||
try {
|
||||
const message = parsePresenceSocketMessage(event.data, (code, reason) =>
|
||||
currentSocket.close(code, reason)
|
||||
);
|
||||
if (message.type === 'presence.ready') {
|
||||
currentSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'presence.join',
|
||||
protocolVersion: 1,
|
||||
visibility: 'resource',
|
||||
workspaceId: 'drawing',
|
||||
resourceId,
|
||||
})
|
||||
);
|
||||
detachDrawingSender?.();
|
||||
detachDrawingSender = drawingBridge.attach(
|
||||
(drawingMessage) => {
|
||||
if (
|
||||
!stopped &&
|
||||
currentSocket === socket &&
|
||||
currentSocket.readyState === WebSocket.OPEN
|
||||
) {
|
||||
currentSocket.send(JSON.stringify(drawingMessage));
|
||||
}
|
||||
},
|
||||
room.roomId
|
||||
);
|
||||
clearHeartbeat();
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
if (currentSocket.readyState === WebSocket.OPEN) {
|
||||
currentSocket.send(
|
||||
JSON.stringify({ type: 'presence.heartbeat', protocolVersion: 1 })
|
||||
);
|
||||
}
|
||||
}, message.heartbeatIntervalMs);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === 'drawing.snapshot' ||
|
||||
message.type === 'drawing.upsert' ||
|
||||
message.type === 'drawing.remove' ||
|
||||
message.type === 'drawing.error'
|
||||
) {
|
||||
drawingBridge.receive(message);
|
||||
}
|
||||
} catch {
|
||||
currentSocket.close();
|
||||
}
|
||||
});
|
||||
|
||||
currentSocket.addEventListener('close', () => {
|
||||
if (stopped || currentSocket !== socket) return;
|
||||
detachDrawingSender?.();
|
||||
detachDrawingSender = undefined;
|
||||
clearHeartbeat();
|
||||
if (!stopped) reconnectTimer = window.setTimeout(connect, 5_000);
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
stopped = true;
|
||||
detachDrawingSender?.();
|
||||
clearHeartbeat();
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [drawingBridge, mx, resourceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { DrawingElement } from '@botsu/protocol';
|
||||
import {
|
||||
createDrawingBridge,
|
||||
type DrawingBridge,
|
||||
type DrawingClientState,
|
||||
} from './drawing-bridge';
|
||||
|
||||
const DrawingContext = createContext<DrawingBridge | undefined>(undefined);
|
||||
|
||||
export function DrawingProvider({ children }: React.PropsWithChildren) {
|
||||
const bridgeRef = useRef<DrawingBridge>();
|
||||
if (!bridgeRef.current) bridgeRef.current = createDrawingBridge();
|
||||
return <DrawingContext.Provider value={bridgeRef.current}>{children}</DrawingContext.Provider>;
|
||||
}
|
||||
|
||||
export const useDrawingBridge = (): DrawingBridge => {
|
||||
const bridge = useContext(DrawingContext);
|
||||
if (!bridge) throw new Error('Drawing provider is missing');
|
||||
return bridge;
|
||||
};
|
||||
|
||||
export const useDrawing = (): {
|
||||
state: DrawingClientState;
|
||||
upsert: (element: DrawingElement) => boolean;
|
||||
remove: (id: string) => boolean;
|
||||
} => {
|
||||
const bridge = useDrawingBridge();
|
||||
const [state, setState] = useState<DrawingClientState>(bridge.getState());
|
||||
useEffect(() => bridge.activate(setState), [bridge]);
|
||||
return { state, upsert: bridge.upsert, remove: bridge.remove };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ChatCircleIcon,
|
||||
CircleIcon,
|
||||
CursorIcon,
|
||||
EraserIcon,
|
||||
EyedropperIcon,
|
||||
FilePngIcon,
|
||||
GifIcon,
|
||||
LineSegmentIcon,
|
||||
PaintBucketIcon,
|
||||
PencilSimpleIcon,
|
||||
PolygonIcon,
|
||||
RectangleIcon,
|
||||
TextTIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { drawingChatAtom } from '../../app/state/callEmbed';
|
||||
import { useDrawingTool, type DrawingTool } from './DrawingToolContext';
|
||||
|
||||
const tools: Array<{ id: DrawingTool; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'select', label: 'Déplacer', icon: <CursorIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'eyedropper', label: 'Pipette', icon: <EyedropperIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'pencil', label: 'Crayon', icon: <PencilSimpleIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'eraser', label: 'Gomme', icon: <EraserIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'bucket', label: 'Remplir', icon: <PaintBucketIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'rectangle', label: 'Rectangle', icon: <RectangleIcon aria-hidden="true" size={18} /> },
|
||||
{
|
||||
id: 'rectangle-filled',
|
||||
label: 'Rectangle plein',
|
||||
icon: <RectangleIcon aria-hidden="true" size={18} weight="fill" />,
|
||||
},
|
||||
{ id: 'circle', label: 'Cercle', icon: <CircleIcon aria-hidden="true" size={18} /> },
|
||||
{
|
||||
id: 'circle-filled',
|
||||
label: 'Cercle plein',
|
||||
icon: <CircleIcon aria-hidden="true" size={18} weight="fill" />,
|
||||
},
|
||||
{ id: 'text', label: 'Texte', icon: <TextTIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'line', label: 'Ligne', icon: <LineSegmentIcon aria-hidden="true" size={18} /> },
|
||||
{ id: 'polygon', label: 'Polygone', icon: <PolygonIcon aria-hidden="true" size={18} /> },
|
||||
];
|
||||
|
||||
export function DrawingRoomToolbar() {
|
||||
const {
|
||||
tool,
|
||||
setTool,
|
||||
color,
|
||||
setColor,
|
||||
size,
|
||||
setSize,
|
||||
font,
|
||||
setFont,
|
||||
fontSize,
|
||||
setFontSize,
|
||||
exportPng,
|
||||
exportGif,
|
||||
} = useDrawingTool();
|
||||
const [chat, setChat] = useAtom(drawingChatAtom);
|
||||
return (
|
||||
<div className="botsu-drawing-toolbar" aria-label="Outils de dessin">
|
||||
{tools.map((item) => (
|
||||
<button
|
||||
aria-label={item.label}
|
||||
aria-pressed={tool === item.id}
|
||||
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
|
||||
key={item.id}
|
||||
onClick={() => setTool(item.id)}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
))}
|
||||
<span className="botsu-drawing-toolbar__separator" aria-hidden="true" />
|
||||
<label className="botsu-drawing-toolbar__field" title="Couleur">
|
||||
<span className="sr-only">Couleur</span>
|
||||
<input
|
||||
aria-label="Couleur"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="botsu-drawing-toolbar__field">
|
||||
<span>Trait</span>
|
||||
<select
|
||||
aria-label="Épaisseur du trait"
|
||||
value={size}
|
||||
onChange={(event) => setSize(Number(event.target.value))}
|
||||
>
|
||||
{[1, 2, 4, 5, 8, 12, 18, 24, 32, 48, 64].map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{tool === 'text' && (
|
||||
<>
|
||||
<label className="botsu-drawing-toolbar__field">
|
||||
<span>Police</span>
|
||||
<select value={font} onChange={(event) => setFont(event.target.value as typeof font)}>
|
||||
<option value="site">Site</option>
|
||||
<option value="inter">Inter</option>
|
||||
<option value="velvelyne">Velvelyne</option>
|
||||
<option value="minecraft">Minecraft</option>
|
||||
<option value="monospace">Mono</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="botsu-drawing-toolbar__field">
|
||||
<span>Taille</span>
|
||||
<select value={fontSize} onChange={(event) => setFontSize(Number(event.target.value))}>
|
||||
{[16, 24, 32, 48, 64, 96, 128].map((value) => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<span className="botsu-drawing-toolbar__separator" aria-hidden="true" />
|
||||
<button
|
||||
aria-label="Exporter PNG"
|
||||
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
|
||||
onClick={exportPng}
|
||||
title="Exporter PNG"
|
||||
type="button"
|
||||
>
|
||||
<FilePngIcon aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Exporter GIF"
|
||||
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
|
||||
onClick={exportGif}
|
||||
title="Exporter GIF"
|
||||
type="button"
|
||||
>
|
||||
<GifIcon aria-hidden="true" size={18} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Chat"
|
||||
aria-pressed={chat}
|
||||
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
|
||||
onClick={() => setChat((value) => !value)}
|
||||
title="Chat"
|
||||
type="button"
|
||||
>
|
||||
<ChatCircleIcon aria-hidden="true" size={18} weight={chat ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import type { DrawingFont } from '@botsu/protocol';
|
||||
|
||||
export type DrawingTool =
|
||||
| 'select'
|
||||
| 'eyedropper'
|
||||
| 'pencil'
|
||||
| 'eraser'
|
||||
| 'bucket'
|
||||
| 'rectangle'
|
||||
| 'rectangle-filled'
|
||||
| 'circle'
|
||||
| 'circle-filled'
|
||||
| 'text'
|
||||
| 'line'
|
||||
| 'polygon';
|
||||
|
||||
type DrawingToolContextValue = {
|
||||
tool: DrawingTool;
|
||||
setTool: (tool: DrawingTool) => void;
|
||||
color: string;
|
||||
setColor: (color: string) => void;
|
||||
size: number;
|
||||
setSize: (size: number) => void;
|
||||
font: DrawingFont;
|
||||
setFont: (font: DrawingFont) => void;
|
||||
fontSize: number;
|
||||
setFontSize: (size: number) => void;
|
||||
exportPng: () => void;
|
||||
exportGif: () => void;
|
||||
registerExporters: (png: () => void, gif: () => void) => () => void;
|
||||
};
|
||||
|
||||
const DrawingToolContext = createContext<DrawingToolContextValue | undefined>(undefined);
|
||||
|
||||
export function DrawingToolProvider({ children }: React.PropsWithChildren) {
|
||||
const [tool, setTool] = useState<DrawingTool>('pencil');
|
||||
const [color, setColor] = useState('#111111');
|
||||
const [size, setSize] = useState(5);
|
||||
const [font, setFont] = useState<DrawingFont>('site');
|
||||
const [fontSize, setFontSize] = useState(32);
|
||||
const pngRef = useRef<() => void>();
|
||||
const gifRef = useRef<() => void>();
|
||||
const exportPng = useCallback(() => pngRef.current?.(), []);
|
||||
const exportGif = useCallback(() => gifRef.current?.(), []);
|
||||
const registerExporters = useCallback((png: () => void, gif: () => void) => {
|
||||
pngRef.current = png;
|
||||
gifRef.current = gif;
|
||||
return () => {
|
||||
if (pngRef.current === png) pngRef.current = undefined;
|
||||
if (gifRef.current === gif) gifRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
tool,
|
||||
setTool,
|
||||
color,
|
||||
setColor,
|
||||
size,
|
||||
setSize,
|
||||
font,
|
||||
setFont,
|
||||
fontSize,
|
||||
setFontSize,
|
||||
exportPng,
|
||||
exportGif,
|
||||
registerExporters,
|
||||
}),
|
||||
[color, exportGif, exportPng, font, fontSize, registerExporters, size, tool]
|
||||
);
|
||||
return <DrawingToolContext.Provider value={value}>{children}</DrawingToolContext.Provider>;
|
||||
}
|
||||
|
||||
export const useDrawingTool = (): DrawingToolContextValue => {
|
||||
const value = useContext(DrawingToolContext);
|
||||
if (!value) throw new Error('Drawing tool provider is missing');
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { DrawingClientMessage, DrawingElement } from '@botsu/protocol';
|
||||
import { createDrawingBridge } from './drawing-bridge.ts';
|
||||
|
||||
const roomId = '!zlpDmLJtzRUYjIctHZ:botsu.net';
|
||||
|
||||
const stroke: DrawingElement = {
|
||||
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
|
||||
kind: 'stroke',
|
||||
mode: 'paint',
|
||||
color: '#112233',
|
||||
size: 5,
|
||||
points: [{ x: 10, y: 20 }],
|
||||
};
|
||||
|
||||
const mutationOne = 'mut_123e4567-e89b-42d3-a456-426614174040';
|
||||
const mutationTwo = 'mut_123e4567-e89b-42d3-a456-426614174041';
|
||||
const remoteMutation = 'mut_123e4567-e89b-42d3-a456-426614174042';
|
||||
|
||||
test('subscribes, applies local drawing optimistically, and accepts server revisions', () => {
|
||||
const sent: unknown[] = [];
|
||||
const states: Array<{ status: string; revision: number; elements: DrawingElement[] }> = [];
|
||||
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
|
||||
const deactivate = bridge.activate((state) => states.push(state));
|
||||
const detach = bridge.attach((message) => sent.push(message), roomId);
|
||||
|
||||
assert.deepEqual(sent, [{ type: 'drawing.subscribe', protocolVersion: 1, roomId }]);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
assert.equal(bridge.upsert(stroke), true);
|
||||
assert.deepEqual(states.at(-1)?.elements, [stroke]);
|
||||
assert.deepEqual(sent.at(-1), {
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
mutationId: mutationOne,
|
||||
expectedRevision: 0,
|
||||
element: stroke,
|
||||
});
|
||||
|
||||
bridge.receive({
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
revision: 1,
|
||||
mutationId: mutationOne,
|
||||
element: stroke,
|
||||
});
|
||||
assert.equal(states.at(-1)?.revision, 1);
|
||||
assert.equal(states.at(-1)?.status, 'online');
|
||||
deactivate();
|
||||
assert.deepEqual(sent.at(-1), { type: 'drawing.unsubscribe', protocolVersion: 1 });
|
||||
detach();
|
||||
});
|
||||
|
||||
test('resubscribes on revision gaps and ignores stale transport cleanup', () => {
|
||||
const first: unknown[] = [];
|
||||
const second: unknown[] = [];
|
||||
const bridge = createDrawingBridge();
|
||||
const deactivate = bridge.activate(() => undefined);
|
||||
const detachFirst = bridge.attach((message) => first.push(message), roomId);
|
||||
bridge.attach((message) => second.push(message), roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 1,
|
||||
elements: [stroke],
|
||||
});
|
||||
detachFirst();
|
||||
bridge.receive({
|
||||
type: 'drawing.remove',
|
||||
protocolVersion: 1,
|
||||
revision: 3,
|
||||
mutationId: remoteMutation,
|
||||
id: stroke.id,
|
||||
});
|
||||
assert.deepEqual(second.slice(-2), [
|
||||
{ type: 'drawing.unsubscribe', protocolVersion: 1 },
|
||||
{ type: 'drawing.subscribe', protocolVersion: 1, roomId },
|
||||
]);
|
||||
assert.equal(bridge.remove(stroke.id), false);
|
||||
deactivate();
|
||||
});
|
||||
|
||||
test('keeps newer optimistic edits over old echoes and rejects stale snapshots', () => {
|
||||
const mutations = [mutationOne, mutationTwo];
|
||||
const bridge = createDrawingBridge({ createMutationId: () => mutations.shift()! });
|
||||
bridge.activate(() => undefined);
|
||||
bridge.attach(() => undefined, roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
const red = { ...stroke, color: '#ff0000' };
|
||||
const blue = { ...stroke, color: '#0000ff' };
|
||||
assert.equal(bridge.upsert(red), true);
|
||||
assert.equal(bridge.upsert(blue), true);
|
||||
bridge.receive({
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
revision: 1,
|
||||
mutationId: mutationOne,
|
||||
element: red,
|
||||
});
|
||||
assert.equal(bridge.getState().elements[0]?.color, '#0000ff');
|
||||
bridge.receive({
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
revision: 2,
|
||||
mutationId: mutationTwo,
|
||||
element: blue,
|
||||
});
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 1,
|
||||
elements: [red],
|
||||
});
|
||||
assert.equal(bridge.getState().revision, 2);
|
||||
assert.equal(bridge.getState().elements[0]?.color, '#0000ff');
|
||||
});
|
||||
|
||||
test('queues an offline mutation and resends it after a reconnect snapshot', () => {
|
||||
const first: unknown[] = [];
|
||||
const second: unknown[] = [];
|
||||
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
|
||||
bridge.activate(() => undefined);
|
||||
const detach = bridge.attach((message) => first.push(message), roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
detach();
|
||||
assert.equal(bridge.upsert(stroke), true);
|
||||
bridge.attach((message) => second.push(message), roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
assert.deepEqual(second.at(-1), {
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
mutationId: mutationOne,
|
||||
expectedRevision: 0,
|
||||
element: stroke,
|
||||
});
|
||||
});
|
||||
|
||||
test('re-sequences offline coalesced edits before reconnecting', () => {
|
||||
const sent: DrawingClientMessage[] = [];
|
||||
const mutationIds = [mutationOne, mutationTwo];
|
||||
const bridge = createDrawingBridge({ createMutationId: () => mutationIds.shift() ?? mutationTwo });
|
||||
bridge.activate(() => undefined);
|
||||
const detach = bridge.attach(() => undefined, roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
detach();
|
||||
const red = { ...stroke, color: '#ff0000' };
|
||||
const blue = { ...stroke, color: '#0000ff' };
|
||||
assert.equal(bridge.upsert(red), true);
|
||||
assert.equal(bridge.upsert(blue), true);
|
||||
bridge.attach((message) => sent.push(message), roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
assert.deepEqual(sent.filter((message) => message.type === 'drawing.upsert'), [
|
||||
{
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
mutationId: mutationTwo,
|
||||
expectedRevision: 0,
|
||||
element: blue,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('rolls back a rejected optimistic mutation and requests a fresh snapshot', () => {
|
||||
const sent: unknown[] = [];
|
||||
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
|
||||
bridge.activate(() => undefined);
|
||||
bridge.attach((message) => sent.push(message), roomId);
|
||||
bridge.receive({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 0,
|
||||
elements: [],
|
||||
});
|
||||
assert.equal(bridge.upsert(stroke), true);
|
||||
bridge.receive({
|
||||
type: 'drawing.error',
|
||||
protocolVersion: 1,
|
||||
mutationId: mutationOne,
|
||||
code: 'rejected',
|
||||
});
|
||||
assert.deepEqual(bridge.getState().elements, []);
|
||||
assert.equal(bridge.getState().status, 'syncing');
|
||||
assert.deepEqual(sent.slice(-2), [
|
||||
{ type: 'drawing.unsubscribe', protocolVersion: 1 },
|
||||
{ type: 'drawing.subscribe', protocolVersion: 1, roomId },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import type {
|
||||
DrawingClientMessage,
|
||||
DrawingElement,
|
||||
DrawingRemove,
|
||||
DrawingServerMessage,
|
||||
DrawingUpsert,
|
||||
} from '@botsu/protocol';
|
||||
|
||||
const applyDrawingElement = (
|
||||
elements: readonly DrawingElement[],
|
||||
element: DrawingElement
|
||||
): DrawingElement[] => {
|
||||
const index = elements.findIndex((candidate) => candidate.id === element.id);
|
||||
if (index < 0) return [...elements, element];
|
||||
return elements.map((candidate, candidateIndex) =>
|
||||
candidateIndex === index ? element : candidate
|
||||
);
|
||||
};
|
||||
|
||||
const removeDrawingElement = (
|
||||
elements: readonly DrawingElement[],
|
||||
id: string
|
||||
): DrawingElement[] => elements.filter((element) => element.id !== id);
|
||||
|
||||
export type DrawingClientState = {
|
||||
status: 'idle' | 'syncing' | 'online' | 'offline';
|
||||
revision: number;
|
||||
elements: DrawingElement[];
|
||||
};
|
||||
|
||||
type Sender = (message: DrawingClientMessage) => void;
|
||||
type Listener = (state: DrawingClientState) => void;
|
||||
type PendingMutation = { message: DrawingUpsert | DrawingRemove; sent: boolean };
|
||||
type DrawingBridgeOptions = { createMutationId?: () => string };
|
||||
|
||||
export const createDrawingBridge = (options: DrawingBridgeOptions = {}) => {
|
||||
const createMutationId = options.createMutationId ?? (() => `mut_${crypto.randomUUID()}`);
|
||||
let state: DrawingClientState = { status: 'idle', revision: 0, elements: [] };
|
||||
let authoritativeRevision = 0;
|
||||
let authoritativeElements: DrawingElement[] = [];
|
||||
let hasSnapshot = false;
|
||||
const pending: PendingMutation[] = [];
|
||||
let sender: Sender | undefined;
|
||||
let roomId: string | undefined;
|
||||
let senderGeneration: object | undefined;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const notify = (): void => listeners.forEach((listener) => listener(state));
|
||||
const composeElements = (): DrawingElement[] =>
|
||||
pending.reduce(
|
||||
(elements, entry) =>
|
||||
entry.message.type === 'drawing.upsert'
|
||||
? applyDrawingElement(elements, entry.message.element)
|
||||
: removeDrawingElement(elements, entry.message.id),
|
||||
authoritativeElements
|
||||
);
|
||||
const emit = (status: DrawingClientState['status']): void => {
|
||||
state = { status, revision: authoritativeRevision, elements: composeElements() };
|
||||
notify();
|
||||
};
|
||||
const resequencePending = (): void => {
|
||||
pending.forEach((entry, index) => {
|
||||
entry.message.expectedRevision = authoritativeRevision + index;
|
||||
});
|
||||
};
|
||||
const resendPending = (): void => {
|
||||
if (!sender) return;
|
||||
resequencePending();
|
||||
pending.forEach((entry) => {
|
||||
sender?.(entry.message);
|
||||
entry.sent = true;
|
||||
});
|
||||
};
|
||||
const requestSnapshot = (reset = false): void => {
|
||||
if (!sender || !roomId || listeners.size === 0) return;
|
||||
emit('syncing');
|
||||
if (reset) sender({ type: 'drawing.unsubscribe', protocolVersion: 1 });
|
||||
sender({ type: 'drawing.subscribe', protocolVersion: 1, roomId });
|
||||
};
|
||||
|
||||
return {
|
||||
reset(): void {
|
||||
sender = undefined;
|
||||
roomId = undefined;
|
||||
senderGeneration = undefined;
|
||||
pending.length = 0;
|
||||
authoritativeRevision = 0;
|
||||
authoritativeElements = [];
|
||||
hasSnapshot = false;
|
||||
emit(listeners.size > 0 ? 'offline' : 'idle');
|
||||
},
|
||||
activate(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(state);
|
||||
if (listeners.size === 1) requestSnapshot();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
sender?.({ type: 'drawing.unsubscribe', protocolVersion: 1 });
|
||||
emit('idle');
|
||||
}
|
||||
};
|
||||
},
|
||||
attach(nextSender: Sender, nextRoomId: string): () => void {
|
||||
const generation = {};
|
||||
sender = nextSender;
|
||||
roomId = nextRoomId;
|
||||
senderGeneration = generation;
|
||||
requestSnapshot();
|
||||
return () => {
|
||||
if (senderGeneration !== generation) return;
|
||||
sender = undefined;
|
||||
roomId = undefined;
|
||||
senderGeneration = undefined;
|
||||
if (listeners.size > 0) {
|
||||
emit('offline');
|
||||
}
|
||||
};
|
||||
},
|
||||
receive(message: DrawingServerMessage): void {
|
||||
if (message.type === 'drawing.error') {
|
||||
const pendingIndex = pending.findIndex(
|
||||
(entry) => entry.message.mutationId === message.mutationId
|
||||
);
|
||||
if (pendingIndex >= 0) {
|
||||
pending.splice(pendingIndex, 1);
|
||||
resequencePending();
|
||||
}
|
||||
emit(state.status);
|
||||
requestSnapshot(true);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'drawing.snapshot') {
|
||||
if (hasSnapshot && message.revision < authoritativeRevision) return;
|
||||
authoritativeRevision = message.revision;
|
||||
authoritativeElements = message.elements;
|
||||
hasSnapshot = true;
|
||||
emit('online');
|
||||
resendPending();
|
||||
return;
|
||||
}
|
||||
const pendingIndex = pending.findIndex(
|
||||
(entry) => entry.message.mutationId === message.mutationId
|
||||
);
|
||||
if (message.revision <= authoritativeRevision) {
|
||||
if (pendingIndex >= 0) {
|
||||
pending.splice(pendingIndex, 1);
|
||||
resequencePending();
|
||||
}
|
||||
emit(state.status);
|
||||
return;
|
||||
}
|
||||
if (state.status !== 'online' || message.revision !== authoritativeRevision + 1) {
|
||||
requestSnapshot(true);
|
||||
return;
|
||||
}
|
||||
authoritativeRevision = message.revision;
|
||||
authoritativeElements =
|
||||
message.type === 'drawing.upsert'
|
||||
? applyDrawingElement(authoritativeElements, message.element)
|
||||
: removeDrawingElement(authoritativeElements, message.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pending.splice(pendingIndex, 1);
|
||||
resequencePending();
|
||||
}
|
||||
emit('online');
|
||||
},
|
||||
upsert(element: DrawingElement): boolean {
|
||||
if (!hasSnapshot || pending.length >= 1_024) return false;
|
||||
if (!sender || state.status !== 'online') {
|
||||
const unsentIndex = pending.findIndex(
|
||||
(entry) =>
|
||||
!entry.sent &&
|
||||
entry.message.type === 'drawing.upsert' &&
|
||||
entry.message.element.id === element.id
|
||||
);
|
||||
if (unsentIndex >= 0) {
|
||||
pending.splice(unsentIndex, 1);
|
||||
resequencePending();
|
||||
}
|
||||
}
|
||||
const message: DrawingUpsert = {
|
||||
type: 'drawing.upsert',
|
||||
protocolVersion: 1,
|
||||
mutationId: createMutationId(),
|
||||
expectedRevision: authoritativeRevision + pending.length,
|
||||
element,
|
||||
};
|
||||
const entry: PendingMutation = { message, sent: false };
|
||||
pending.push(entry);
|
||||
emit(state.status);
|
||||
if (sender && state.status === 'online') {
|
||||
sender(message);
|
||||
entry.sent = true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
remove(id: string): boolean {
|
||||
if (
|
||||
!hasSnapshot ||
|
||||
state.status === 'idle' ||
|
||||
state.status === 'syncing' ||
|
||||
!state.elements.some((element) => element.id === id) ||
|
||||
pending.length >= 1_024
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message: DrawingRemove = {
|
||||
type: 'drawing.remove',
|
||||
protocolVersion: 1,
|
||||
mutationId: createMutationId(),
|
||||
expectedRevision: authoritativeRevision + pending.length,
|
||||
id,
|
||||
};
|
||||
const entry: PendingMutation = { message, sent: false };
|
||||
pending.push(entry);
|
||||
emit(state.status);
|
||||
if (sender && state.status === 'online') {
|
||||
sender(message);
|
||||
entry.sent = true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getState(): DrawingClientState {
|
||||
return state;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type DrawingBridge = ReturnType<typeof createDrawingBridge>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { encodeDrawingGif } from './drawing-export.ts';
|
||||
|
||||
test('encodes a valid compact GIF89a frame from RGBA pixels', () => {
|
||||
const rgba = new Uint8ClampedArray([
|
||||
255, 255, 255, 255,
|
||||
255, 0, 0, 255,
|
||||
0, 255, 0, 255,
|
||||
0, 0, 255, 255,
|
||||
]);
|
||||
const gif = encodeDrawingGif(2, 2, rgba);
|
||||
assert.equal(new TextDecoder().decode(gif.slice(0, 6)), 'GIF89a');
|
||||
assert.equal(gif[6], 2);
|
||||
assert.equal(gif[7], 0);
|
||||
assert.equal(gif[8], 2);
|
||||
assert.equal(gif[9], 0);
|
||||
assert.equal(gif.at(-1), 0x3b);
|
||||
assert.ok(gif.byteLength < 1_024);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
const pushWord = (output: number[], value: number): void => {
|
||||
output.push(value & 0xff, (value >> 8) & 0xff);
|
||||
};
|
||||
|
||||
const createPalette = (): number[] => {
|
||||
const palette: number[] = [];
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
const red = ((index >> 5) & 0x07) * (255 / 7);
|
||||
const green = ((index >> 2) & 0x07) * (255 / 7);
|
||||
const blue = (index & 0x03) * (255 / 3);
|
||||
palette.push(Math.round(red), Math.round(green), Math.round(blue));
|
||||
}
|
||||
return palette;
|
||||
};
|
||||
|
||||
const rgbaToPaletteIndex = (rgba: Uint8ClampedArray, offset: number): number => {
|
||||
if ((rgba[offset + 3] ?? 255) < 128) return 255;
|
||||
const red = rgba[offset] ?? 255;
|
||||
const green = rgba[offset + 1] ?? 255;
|
||||
const blue = rgba[offset + 2] ?? 255;
|
||||
return (red & 0xe0) | ((green & 0xe0) >> 3) | (blue >> 6);
|
||||
};
|
||||
|
||||
const packNineBitCodes = (indices: Uint8Array): Uint8Array => {
|
||||
const clearCode = 256;
|
||||
const endCode = 257;
|
||||
const bytes: number[] = [];
|
||||
let accumulator = 0;
|
||||
let bitCount = 0;
|
||||
const writeCode = (code: number): void => {
|
||||
accumulator |= code << bitCount;
|
||||
bitCount += 9;
|
||||
while (bitCount >= 8) {
|
||||
bytes.push(accumulator & 0xff);
|
||||
accumulator >>>= 8;
|
||||
bitCount -= 8;
|
||||
}
|
||||
};
|
||||
// A clear before every literal keeps the code width fixed at nine bits.
|
||||
// It trades maximum compression for a tiny, deterministic encoder with
|
||||
// bounded memory that works for the full 1024² artboard.
|
||||
indices.forEach((index) => {
|
||||
writeCode(clearCode);
|
||||
writeCode(index);
|
||||
});
|
||||
writeCode(endCode);
|
||||
if (bitCount > 0) bytes.push(accumulator & 0xff);
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
|
||||
export const encodeDrawingGif = (
|
||||
width: number,
|
||||
height: number,
|
||||
rgba: Uint8ClampedArray
|
||||
): Uint8Array => {
|
||||
if (
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width < 1 ||
|
||||
height < 1 ||
|
||||
width > 65_535 ||
|
||||
height > 65_535 ||
|
||||
rgba.length !== width * height * 4
|
||||
) {
|
||||
throw new TypeError('Invalid GIF dimensions or pixel data');
|
||||
}
|
||||
|
||||
const indices = new Uint8Array(width * height);
|
||||
for (let index = 0; index < indices.length; index += 1) {
|
||||
indices[index] = rgbaToPaletteIndex(rgba, index * 4);
|
||||
}
|
||||
const compressed = packNineBitCodes(indices);
|
||||
const output: number[] = [...new TextEncoder().encode('GIF89a')];
|
||||
pushWord(output, width);
|
||||
pushWord(output, height);
|
||||
output.push(0xf7, 0x00, 0x00, ...createPalette());
|
||||
output.push(0x21, 0xf9, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00);
|
||||
output.push(0x2c, 0x00, 0x00, 0x00, 0x00);
|
||||
pushWord(output, width);
|
||||
pushWord(output, height);
|
||||
output.push(0x00, 0x08);
|
||||
for (let offset = 0; offset < compressed.length; offset += 255) {
|
||||
const block = compressed.slice(offset, offset + 255);
|
||||
output.push(block.length, ...block);
|
||||
}
|
||||
output.push(0x00, 0x3b);
|
||||
return Uint8Array.from(output);
|
||||
};
|
||||
|
||||
export const downloadDrawingBlob = (blob: Blob, filename: string): void => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { DrawingElement } from '@botsu/protocol';
|
||||
import {
|
||||
applyDrawingElement,
|
||||
createDrawingFillRuns,
|
||||
hitTestDrawingElement,
|
||||
moveDrawingElement,
|
||||
sampleDrawingPoint,
|
||||
} from './drawing-model.ts';
|
||||
|
||||
const rectangle: DrawingElement = {
|
||||
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
|
||||
kind: 'shape',
|
||||
shape: 'rectangle-filled',
|
||||
color: '#ff0000',
|
||||
size: 4,
|
||||
points: [
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 110, y: 120 },
|
||||
],
|
||||
};
|
||||
|
||||
test('upserts one semantic element without a per-pixel patch', () => {
|
||||
const inserted = applyDrawingElement([], rectangle);
|
||||
const moved = moveDrawingElement(rectangle, 15, -5);
|
||||
const replaced = applyDrawingElement(inserted, moved);
|
||||
|
||||
assert.equal(replaced.length, 1);
|
||||
const result = replaced[0];
|
||||
assert.ok(result && result.kind === 'shape');
|
||||
assert.deepEqual(result.points, [
|
||||
{ x: 25, y: 15 },
|
||||
{ x: 125, y: 115 },
|
||||
]);
|
||||
assert.equal('pixels' in result, false);
|
||||
});
|
||||
|
||||
test('samples pointer movement by distance instead of calculating every pixel', () => {
|
||||
const points = [{ x: 10, y: 10 }];
|
||||
assert.equal(sampleDrawingPoint(points, { x: 11, y: 11 }, 3), points);
|
||||
assert.deepEqual(sampleDrawingPoint(points, { x: 14, y: 10 }, 3), [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 14, y: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hit-tests and moves Paint elements for the mouse selection tool', () => {
|
||||
assert.equal(hitTestDrawingElement(rectangle, { x: 30, y: 40 }), true);
|
||||
assert.equal(hitTestDrawingElement(rectangle, { x: 300, y: 400 }), false);
|
||||
|
||||
const text: DrawingElement = {
|
||||
id: 'draw_123e4567-e89b-42d3-a456-426614174001',
|
||||
kind: 'text',
|
||||
color: '#000000',
|
||||
font: 'site',
|
||||
size: 32,
|
||||
x: 50,
|
||||
y: 60,
|
||||
text: 'BOTSU',
|
||||
};
|
||||
assert.deepEqual(moveDrawingElement(text, 10, 20), { ...text, x: 60, y: 80 });
|
||||
});
|
||||
|
||||
test('clamps a moved shape as one object without deforming it at the artboard edge', () => {
|
||||
const moved = moveDrawingElement(rectangle, -50, 1_000);
|
||||
assert.equal(moved.kind, 'shape');
|
||||
if (moved.kind !== 'shape') return;
|
||||
assert.deepEqual(moved.points, [
|
||||
{ x: 0, y: 923 },
|
||||
{ x: 100, y: 1023 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('converts one bucket operation to bounded raster spans that replay without flood fill', () => {
|
||||
const pixels = new Uint8ClampedArray([
|
||||
255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
]);
|
||||
assert.deepEqual(createDrawingFillRuns(pixels, 3, 2, { x: 0, y: 0 }, '#ff0000'), [
|
||||
{ y: 0, startX: 0, endX: 0 },
|
||||
{ y: 1, startX: 0, endX: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hit-tests and moves a bucket fill by its spans', () => {
|
||||
const fill: DrawingElement = {
|
||||
id: 'draw_123e4567-e89b-42d3-a456-426614174002',
|
||||
kind: 'fill',
|
||||
color: '#ff0000',
|
||||
runs: [
|
||||
{ y: 10, startX: 20, endX: 30 },
|
||||
{ y: 11, startX: 22, endX: 28 },
|
||||
],
|
||||
};
|
||||
assert.equal(hitTestDrawingElement(fill, { x: 25, y: 10 }), true);
|
||||
assert.equal(hitTestDrawingElement(fill, { x: 20, y: 11 }), false);
|
||||
assert.deepEqual(moveDrawingElement(fill, 5, 6), {
|
||||
...fill,
|
||||
runs: [
|
||||
{ y: 16, startX: 25, endX: 35 },
|
||||
{ y: 17, startX: 27, endX: 33 },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
BOTSU_DRAWING_SIZE,
|
||||
MAXIMUM_DRAWING_FILL_RUNS,
|
||||
type DrawingElement,
|
||||
type DrawingFillRun,
|
||||
type DrawingPoint,
|
||||
} from '@botsu/protocol';
|
||||
|
||||
const clamp = (value: number): number => Math.max(0, Math.min(BOTSU_DRAWING_SIZE - 1, Math.round(value)));
|
||||
|
||||
const colorToRgba = (color: string): readonly [number, number, number, number] => [
|
||||
Number.parseInt(color.slice(1, 3), 16),
|
||||
Number.parseInt(color.slice(3, 5), 16),
|
||||
Number.parseInt(color.slice(5, 7), 16),
|
||||
255,
|
||||
];
|
||||
|
||||
export const createDrawingFillRuns = (
|
||||
pixels: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
point: DrawingPoint,
|
||||
color: string
|
||||
): DrawingFillRun[] | undefined => {
|
||||
const startX = Math.round(point.x);
|
||||
const startY = Math.round(point.y);
|
||||
if (
|
||||
width < 1 ||
|
||||
height < 1 ||
|
||||
pixels.length !== width * height * 4 ||
|
||||
startX < 0 ||
|
||||
startX >= width ||
|
||||
startY < 0 ||
|
||||
startY >= height
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const first = startY * width + startX;
|
||||
const firstOffset = first * 4;
|
||||
const target = [
|
||||
pixels[firstOffset] ?? 0,
|
||||
pixels[firstOffset + 1] ?? 0,
|
||||
pixels[firstOffset + 2] ?? 0,
|
||||
pixels[firstOffset + 3] ?? 0,
|
||||
];
|
||||
const replacement = colorToRgba(color);
|
||||
if (target.every((value, index) => value === replacement[index])) return [];
|
||||
|
||||
const queue = new Int32Array(width * height);
|
||||
const visited = new Uint8Array(width * height);
|
||||
const filled = new Uint8Array(width * height);
|
||||
let read = 0;
|
||||
let write = 1;
|
||||
queue[0] = first;
|
||||
visited[first] = 1;
|
||||
while (read < write) {
|
||||
const index = queue[read++]!;
|
||||
const offset = index * 4;
|
||||
if (
|
||||
pixels[offset] !== target[0] ||
|
||||
pixels[offset + 1] !== target[1] ||
|
||||
pixels[offset + 2] !== target[2] ||
|
||||
pixels[offset + 3] !== target[3]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
filled[index] = 1;
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
const neighbors = [
|
||||
x > 0 ? index - 1 : -1,
|
||||
x + 1 < width ? index + 1 : -1,
|
||||
y > 0 ? index - width : -1,
|
||||
y + 1 < height ? index + width : -1,
|
||||
];
|
||||
neighbors.forEach((neighbor) => {
|
||||
if (neighbor < 0 || visited[neighbor]) return;
|
||||
visited[neighbor] = 1;
|
||||
queue[write++] = neighbor;
|
||||
});
|
||||
}
|
||||
|
||||
const runs: DrawingFillRun[] = [];
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
let x = 0;
|
||||
while (x < width) {
|
||||
while (x < width && !filled[y * width + x]) x += 1;
|
||||
if (x >= width) break;
|
||||
const runStart = x;
|
||||
while (x + 1 < width && filled[y * width + x + 1]) x += 1;
|
||||
runs.push({ y, startX: runStart, endX: x });
|
||||
if (runs.length > MAXIMUM_DRAWING_FILL_RUNS) return undefined;
|
||||
x += 1;
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
};
|
||||
|
||||
export const applyDrawingElement = (
|
||||
elements: readonly DrawingElement[],
|
||||
element: DrawingElement
|
||||
): DrawingElement[] => {
|
||||
const index = elements.findIndex((candidate) => candidate.id === element.id);
|
||||
if (index < 0) return [...elements, element];
|
||||
return elements.map((candidate, candidateIndex) => (candidateIndex === index ? element : candidate));
|
||||
};
|
||||
|
||||
export const removeDrawingElement = (
|
||||
elements: readonly DrawingElement[],
|
||||
id: string
|
||||
): DrawingElement[] => elements.filter((element) => element.id !== id);
|
||||
|
||||
export const sampleDrawingPoint = (
|
||||
points: readonly DrawingPoint[],
|
||||
next: DrawingPoint,
|
||||
minimumDistance = 2
|
||||
): readonly DrawingPoint[] => {
|
||||
const previous = points.at(-1);
|
||||
if (!previous) return [{ x: clamp(next.x), y: clamp(next.y) }];
|
||||
const distance = Math.hypot(next.x - previous.x, next.y - previous.y);
|
||||
if (distance < minimumDistance) return points;
|
||||
return [...points, { x: clamp(next.x), y: clamp(next.y) }];
|
||||
};
|
||||
|
||||
export const moveDrawingElement = (
|
||||
element: DrawingElement,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): DrawingElement => {
|
||||
if (element.kind === 'text') {
|
||||
return { ...element, x: clamp(element.x + deltaX), y: clamp(element.y + deltaY) };
|
||||
}
|
||||
if (element.kind === 'fill') {
|
||||
const bounds = getDrawingElementBounds(element);
|
||||
const boundedDeltaX = Math.max(
|
||||
-bounds.left,
|
||||
Math.min(BOTSU_DRAWING_SIZE - 1 - bounds.right, Math.round(deltaX))
|
||||
);
|
||||
const boundedDeltaY = Math.max(
|
||||
-bounds.top,
|
||||
Math.min(BOTSU_DRAWING_SIZE - 1 - bounds.bottom, Math.round(deltaY))
|
||||
);
|
||||
return {
|
||||
...element,
|
||||
runs: element.runs.map((run) => ({
|
||||
y: run.y + boundedDeltaY,
|
||||
startX: run.startX + boundedDeltaX,
|
||||
endX: run.endX + boundedDeltaX,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const xs = element.points.map((point) => point.x);
|
||||
const ys = element.points.map((point) => point.y);
|
||||
const boundedDeltaX = Math.max(
|
||||
-Math.min(...xs),
|
||||
Math.min(BOTSU_DRAWING_SIZE - 1 - Math.max(...xs), Math.round(deltaX))
|
||||
);
|
||||
const boundedDeltaY = Math.max(
|
||||
-Math.min(...ys),
|
||||
Math.min(BOTSU_DRAWING_SIZE - 1 - Math.max(...ys), Math.round(deltaY))
|
||||
);
|
||||
return {
|
||||
...element,
|
||||
points: element.points.map((point) => ({
|
||||
x: point.x + boundedDeltaX,
|
||||
y: point.y + boundedDeltaY,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type Bounds = { left: number; top: number; right: number; bottom: number };
|
||||
|
||||
export const getDrawingElementBounds = (element: DrawingElement): Bounds => {
|
||||
if (element.kind === 'text') {
|
||||
return {
|
||||
left: element.x,
|
||||
top: Math.max(0, element.y - element.size),
|
||||
right: Math.min(BOTSU_DRAWING_SIZE - 1, element.x + element.text.length * element.size * 0.7),
|
||||
bottom: Math.min(BOTSU_DRAWING_SIZE - 1, element.y + element.size * 0.25),
|
||||
};
|
||||
}
|
||||
if (element.kind === 'fill') {
|
||||
return {
|
||||
left: Math.min(...element.runs.map((run) => run.startX)),
|
||||
top: Math.min(...element.runs.map((run) => run.y)),
|
||||
right: Math.max(...element.runs.map((run) => run.endX)),
|
||||
bottom: Math.max(...element.runs.map((run) => run.y)),
|
||||
};
|
||||
}
|
||||
const xs = element.points.map((point) => point.x);
|
||||
const ys = element.points.map((point) => point.y);
|
||||
const padding = element.size / 2 + 4;
|
||||
return {
|
||||
left: Math.min(...xs) - padding,
|
||||
top: Math.min(...ys) - padding,
|
||||
right: Math.max(...xs) + padding,
|
||||
bottom: Math.max(...ys) + padding,
|
||||
};
|
||||
};
|
||||
|
||||
export const hitTestDrawingElement = (element: DrawingElement, point: DrawingPoint): boolean => {
|
||||
if (element.kind === 'fill') {
|
||||
const x = Math.round(point.x);
|
||||
const y = Math.round(point.y);
|
||||
return element.runs.some((run) => run.y === y && x >= run.startX && x <= run.endX);
|
||||
}
|
||||
const bounds = getDrawingElementBounds(element);
|
||||
return point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom;
|
||||
};
|
||||
|
||||
export const findDrawingElementAt = (
|
||||
elements: readonly DrawingElement[],
|
||||
point: DrawingPoint
|
||||
): DrawingElement | undefined => [...elements].reverse().find((element) => hitTestDrawingElement(element, point));
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (relativePath: string) =>
|
||||
readFile(new URL(relativePath, import.meta.url), 'utf8');
|
||||
|
||||
test('Drawing Room exposes the complete Paint toolbar in the room navbar', async () => {
|
||||
const toolbar = await read('./DrawingRoomToolbar.tsx');
|
||||
assert.match(toolbar, /@phosphor-icons\/react/);
|
||||
[
|
||||
'CursorIcon',
|
||||
'EyedropperIcon',
|
||||
'PencilSimpleIcon',
|
||||
'EraserIcon',
|
||||
'PaintBucketIcon',
|
||||
'RectangleIcon',
|
||||
'CircleIcon',
|
||||
'TextTIcon',
|
||||
'LineSegmentIcon',
|
||||
'PolygonIcon',
|
||||
].forEach((icon) => assert.match(toolbar, new RegExp(icon)));
|
||||
[
|
||||
'Déplacer',
|
||||
'Pipette',
|
||||
'Crayon',
|
||||
'Gomme',
|
||||
'Remplir',
|
||||
'Rectangle',
|
||||
'Rectangle plein',
|
||||
'Cercle',
|
||||
'Cercle plein',
|
||||
'Texte',
|
||||
'Ligne',
|
||||
'Polygone',
|
||||
].forEach((label) => assert.match(toolbar, new RegExp(label)));
|
||||
assert.match(toolbar, /Exporter PNG/);
|
||||
assert.match(toolbar, /Exporter GIF/);
|
||||
assert.match(toolbar, /Police/);
|
||||
assert.match(toolbar, /\{item\.icon\}/);
|
||||
assert.doesNotMatch(toolbar, />\s*\{item\.label\}\s*<\/button>/);
|
||||
});
|
||||
|
||||
test('Drawing Room uses a white artboard on a themed page and a semantic realtime bridge', async () => {
|
||||
const [view, css, room, transport] = await Promise.all([
|
||||
read('./BotsuDrawingRoomView.tsx'),
|
||||
read('./drawing.css'),
|
||||
read('../../app/features/room/Room.tsx'),
|
||||
read('./BotsuDrawingTransport.tsx'),
|
||||
]);
|
||||
assert.match(view, /BotsuDrawingCanvas/);
|
||||
assert.doesNotMatch(view, /BotsuPixelCanvas/);
|
||||
assert.match(css, /background:\s*var\(--bg-surface/);
|
||||
assert.match(css, /background:\s*#fff/);
|
||||
assert.match(transport, /drawing\.snapshot/);
|
||||
assert.match(transport, /drawing\.upsert/);
|
||||
assert.match(transport, /createBotsuDrawingResourceId/);
|
||||
assert.match(transport, /sendStateEvent\(/);
|
||||
assert.match(transport, /readPowerLevel\.state/);
|
||||
assert.match(room, /drawingChatAtom/);
|
||||
assert.match(room, /<CallChatView onClose=/);
|
||||
});
|
||||
|
||||
test('bucket fills replay bounded spans and stroke segmentation does not discard a failed publish', async () => {
|
||||
const canvas = await read('./BotsuDrawingCanvas.tsx');
|
||||
assert.doesNotMatch(canvas, /const floodFill/);
|
||||
assert.match(canvas, /createDrawingFillRuns/);
|
||||
assert.match(canvas, /if \(!publishActive\(true\)\)/);
|
||||
assert.match(canvas, /if \(active\.points\.length >= 128\)/);
|
||||
});
|
||||
|
||||
test('mobile chat keeps the drawing provider mounted and resets when the room changes', async () => {
|
||||
const room = await read('../../app/features/room/Room.tsx');
|
||||
assert.doesNotMatch(room, /drawingView && \(screenSize === ScreenSize\.Desktop \|\| !drawingChat\)/);
|
||||
assert.match(room, /<DrawingProvider key=\{room\.roomId\}>/);
|
||||
assert.match(room, /setDrawingChat\(false\)/);
|
||||
assert.match(room, /\[room\.roomId, setDrawingChat\]/);
|
||||
});
|
||||
|
||||
test('drawing transport resets rotated capabilities and ignores stale socket callbacks', async () => {
|
||||
const [transport, bridge] = await Promise.all([
|
||||
read('./BotsuDrawingTransport.tsx'),
|
||||
read('./drawing-bridge.ts'),
|
||||
]);
|
||||
assert.match(transport, /if \(stopped \|\| currentSocket !== socket\) return/);
|
||||
assert.match(transport, /drawingBridge\.reset\(\)/);
|
||||
assert.match(bridge, /pending\.length = 0/);
|
||||
assert.match(bridge, /authoritativeRevision = 0/);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
.botsu-drawing-room {
|
||||
--botsu-color-text: var(--tc-surface-normal, #111111);
|
||||
--botsu-color-text-muted: var(--tc-surface-low, #6f6f6f);
|
||||
--botsu-color-accent: var(--tc-primary-normal, #5b6ee1);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 1rem;
|
||||
padding: clamp(0.5rem, 2vw, 1.5rem);
|
||||
overflow: auto;
|
||||
background: var(--bg-surface, var(--bg-surface-low, #ececec));
|
||||
color: var(--botsu-color-text);
|
||||
}
|
||||
|
||||
.botsu-drawing-room__canvas {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.botsu-drawing-canvas-frame {
|
||||
width: min(1024px, 100%, calc(100vh - 7rem));
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.botsu-drawing-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
aspect-ratio: 1;
|
||||
background: #fff;
|
||||
border: 1px solid var(--bg-surface-border, rgba(127, 127, 127, 0.35));
|
||||
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.12);
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.botsu-drawing-canvas[data-tool='select'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.botsu-drawing-canvas[data-tool='select']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.botsu-drawing-canvas[data-tool='text'] {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.botsu-drawing-status {
|
||||
min-height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
color: var(--botsu-color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar {
|
||||
max-width: min(72vw, 1200px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__button,
|
||||
.botsu-drawing-toolbar__field,
|
||||
.botsu-drawing-toolbar select {
|
||||
flex: 0 0 auto;
|
||||
min-height: 1.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--botsu-radius, 4px);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__button {
|
||||
padding: 0.2rem 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__button--icon {
|
||||
width: 1.75rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__button--icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__button:hover,
|
||||
.botsu-drawing-toolbar__button:focus-visible,
|
||||
.botsu-drawing-toolbar__button[aria-pressed='true'] {
|
||||
border-color: var(--bg-surface-border, currentColor);
|
||||
background: var(--bg-surface-hover, rgba(127, 127, 127, 0.14));
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.2rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar input[type='color'] {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar select {
|
||||
max-width: 7rem;
|
||||
padding: 0 0.25rem;
|
||||
border-color: var(--bg-surface-border, rgba(127, 127, 127, 0.35));
|
||||
background: var(--bg-surface, transparent);
|
||||
}
|
||||
|
||||
.botsu-drawing-toolbar__separator {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 1px;
|
||||
background: var(--bg-surface-border, rgba(127, 127, 127, 0.35));
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.botsu-drawing-toolbar {
|
||||
max-width: 65vw;
|
||||
}
|
||||
|
||||
.botsu-drawing-room {
|
||||
padding: 0.35rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
DragEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
color,
|
||||
config,
|
||||
Input,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
TextArea,
|
||||
vars,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import {
|
||||
CheckIcon,
|
||||
PencilSimpleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { readPowerLevel, usePowerLevelsContext } from '../../app/hooks/usePowerLevels';
|
||||
import { getStateEvent, getStateEvents } from '../../app/utils/room';
|
||||
import { stopPropagation } from '../../app/utils/keyboard';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import {
|
||||
KANBAN_MAX_CARDS,
|
||||
KANBAN_MAX_COLUMNS,
|
||||
KanbanCard,
|
||||
KanbanColumn,
|
||||
moveKanbanCard,
|
||||
nextKanbanCardPosition,
|
||||
parseKanbanCard,
|
||||
parseKanbanColumn,
|
||||
} from './model';
|
||||
import './kanban.css';
|
||||
|
||||
type BoardState = {
|
||||
columns: KanbanColumn[];
|
||||
cards: KanbanCard[];
|
||||
columnEventIds: Map<string, string | undefined>;
|
||||
cardEventIds: Map<string, string | undefined>;
|
||||
invalidCount: number;
|
||||
};
|
||||
|
||||
type KanbanThemeStyle = CSSProperties & Record<`--kanban-${string}`, string>;
|
||||
|
||||
const kanbanThemeStyle = {
|
||||
'--kanban-canvas': color.Background.Container,
|
||||
'--kanban-surface': color.Surface.Container,
|
||||
'--kanban-surface-raised': color.SurfaceVariant.Container,
|
||||
'--kanban-text': color.Background.OnContainer,
|
||||
'--kanban-muted': color.SurfaceVariant.OnContainer,
|
||||
'--kanban-border': color.Surface.ContainerLine,
|
||||
'--kanban-accent': color.Primary.Main,
|
||||
'--kanban-on-accent': color.Primary.OnMain,
|
||||
'--kanban-critical': color.Critical.Main,
|
||||
'--kanban-focus': vars.outline.FocusRing,
|
||||
'--kanban-radius': config.radii.R300,
|
||||
} satisfies KanbanThemeStyle;
|
||||
|
||||
type CardDraft = Omit<KanbanCard, 'version' | 'id' | 'position' | 'deleted'> & {
|
||||
id?: string;
|
||||
expectedEventId?: string;
|
||||
labelsInput: string;
|
||||
};
|
||||
|
||||
const EMPTY_CARD: CardDraft = {
|
||||
columnId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
assignee: '',
|
||||
dueDate: '',
|
||||
labels: [],
|
||||
labelsInput: '',
|
||||
};
|
||||
|
||||
const byPosition = <T extends { position: number; id: string }>(left: T, right: T) =>
|
||||
left.position - right.position || left.id.localeCompare(right.id);
|
||||
|
||||
const readBoardState = (room: Room): BoardState => {
|
||||
let invalidCount = 0;
|
||||
const columnEventIds = new Map<string, string | undefined>();
|
||||
const cardEventIds = new Map<string, string | undefined>();
|
||||
const columns = getStateEvents(room, StateEvent.BotsuKanbanColumn).flatMap((event) => {
|
||||
try {
|
||||
const column = parseKanbanColumn(event.getContent());
|
||||
if (event.getStateKey() !== column.id) throw new TypeError('column state key mismatch');
|
||||
columnEventIds.set(column.id, event.getId());
|
||||
return column.deleted ? [] : [column];
|
||||
} catch {
|
||||
invalidCount += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const validColumnIds = new Set(columns.map((column) => column.id));
|
||||
const cards = getStateEvents(room, StateEvent.BotsuKanbanCard).flatMap((event) => {
|
||||
try {
|
||||
const card = parseKanbanCard(event.getContent());
|
||||
if (event.getStateKey() !== card.id) throw new TypeError('card state key mismatch');
|
||||
cardEventIds.set(card.id, event.getId());
|
||||
return card.deleted || !validColumnIds.has(card.columnId) ? [] : [card];
|
||||
} catch {
|
||||
invalidCount += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return {
|
||||
columns: columns.sort(byPosition).slice(0, KANBAN_MAX_COLUMNS),
|
||||
cards: cards.sort(byPosition).slice(0, KANBAN_MAX_CARDS),
|
||||
columnEventIds,
|
||||
cardEventIds,
|
||||
invalidCount,
|
||||
};
|
||||
};
|
||||
|
||||
const labelsFromInput = (value: string): string[] => {
|
||||
const parsed = value
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean);
|
||||
if (parsed.length > 6) throw new Error('Six étiquettes maximum.');
|
||||
if (parsed.some((label) => label.length > 32)) {
|
||||
throw new Error('Une étiquette ne peut pas dépasser 32 caractères.');
|
||||
}
|
||||
if (new Set(parsed.map((label) => label.toLocaleLowerCase())).size !== parsed.length) {
|
||||
throw new Error('Chaque étiquette doit être unique.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export function BotsuKanbanRoomView({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const userPower = readPowerLevel.user(powerLevels, mx.getUserId() ?? undefined);
|
||||
const canEditColumns =
|
||||
userPower >= readPowerLevel.state(powerLevels, StateEvent.BotsuKanbanColumn);
|
||||
const canEditCards =
|
||||
userPower >= readPowerLevel.state(powerLevels, StateEvent.BotsuKanbanCard);
|
||||
const [board, setBoard] = useState<BoardState>(() => readBoardState(room));
|
||||
const [draft, setDraft] = useState<CardDraft>();
|
||||
const [columnTitle, setColumnTitle] = useState('');
|
||||
const [addingColumn, setAddingColumn] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveLock = useRef(false);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
if (!saving) setDraft(undefined);
|
||||
}, [saving]);
|
||||
|
||||
const refresh = useCallback(() => setBoard(readBoardState(room)), [room]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(readBoardState(room));
|
||||
const handleStateEvent = (event: MatrixEvent) => {
|
||||
if (
|
||||
event.getRoomId() !== room.roomId ||
|
||||
(event.getType() !== StateEvent.BotsuKanbanColumn &&
|
||||
event.getType() !== StateEvent.BotsuKanbanCard)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
room.on(RoomStateEvent.Events, handleStateEvent);
|
||||
return () => {
|
||||
room.removeListener(RoomStateEvent.Events, handleStateEvent);
|
||||
};
|
||||
}, [refresh, room]);
|
||||
|
||||
const members = useMemo(
|
||||
() =>
|
||||
room
|
||||
.getJoinedMembers()
|
||||
.map((member) => ({ id: member.userId, name: member.name || member.userId }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
[room, board]
|
||||
);
|
||||
|
||||
const assertCurrentEvent = (
|
||||
eventType: StateEvent,
|
||||
stateKey: string,
|
||||
expectedEventId: string | undefined,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const current = getStateEvent(room, eventType, stateKey);
|
||||
if (
|
||||
(expectedEventId !== undefined && current?.getId() !== expectedEventId) ||
|
||||
(requireAbsent && current !== undefined)
|
||||
) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
};
|
||||
|
||||
const sendColumn = async (
|
||||
column: KanbanColumn,
|
||||
expectedEventId?: string,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const parsed = parseKanbanColumn(column);
|
||||
assertCurrentEvent(StateEvent.BotsuKanbanColumn, parsed.id, expectedEventId, requireAbsent);
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.BotsuKanbanColumn as any, parsed, parsed.id);
|
||||
};
|
||||
|
||||
const sendCard = async (
|
||||
card: KanbanCard,
|
||||
expectedEventId?: string,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const parsed = parseKanbanCard(card);
|
||||
const columnEvent = getStateEvent(room, StateEvent.BotsuKanbanColumn, parsed.columnId);
|
||||
try {
|
||||
const column = parseKanbanColumn(columnEvent?.getContent());
|
||||
if (column.deleted || column.id !== parsed.columnId) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
assertCurrentEvent(StateEvent.BotsuKanbanCard, parsed.id, expectedEventId, requireAbsent);
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.BotsuKanbanCard as any, parsed, parsed.id);
|
||||
};
|
||||
|
||||
const runSave = async (operation: () => Promise<void>) => {
|
||||
if (saveLock.current) return;
|
||||
saveLock.current = true;
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await operation();
|
||||
} catch (cause) {
|
||||
console.error('[BotsuKanban] Matrix state update failed', cause);
|
||||
setError(
|
||||
cause instanceof Error && cause.message === 'Kanban state changed remotely'
|
||||
? 'Une modification distante est arrivée. Rouvrez la carte puis réessayez.'
|
||||
: "La modification n'a pas pu être synchronisée."
|
||||
);
|
||||
refresh();
|
||||
} finally {
|
||||
saveLock.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNewCard = (columnId: string) => setDraft({ ...EMPTY_CARD, columnId });
|
||||
|
||||
const openCard = (card: KanbanCard) =>
|
||||
setDraft({
|
||||
id: card.id,
|
||||
expectedEventId: board.cardEventIds.get(card.id),
|
||||
columnId: card.columnId,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
assignee: card.assignee,
|
||||
dueDate: card.dueDate,
|
||||
labels: [...card.labels],
|
||||
labelsInput: card.labels.join(', '),
|
||||
});
|
||||
|
||||
const saveCard = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canEditCards || !draft || !draft.title.trim()) return;
|
||||
let labels: string[];
|
||||
try {
|
||||
labels = labelsFromInput(draft.labelsInput);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Étiquettes invalides.');
|
||||
return;
|
||||
}
|
||||
const existing = draft.id ? board.cards.find((card) => card.id === draft.id) : undefined;
|
||||
const id = existing?.id ?? `card_${crypto.randomUUID()}`;
|
||||
const position =
|
||||
existing?.position ?? nextKanbanCardPosition(board.cards, draft.columnId);
|
||||
void runSave(async () => {
|
||||
await sendCard(
|
||||
{
|
||||
version: 1,
|
||||
id,
|
||||
columnId: draft.columnId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
assignee: draft.assignee,
|
||||
dueDate: draft.dueDate,
|
||||
labels,
|
||||
position,
|
||||
deleted: false,
|
||||
},
|
||||
draft.expectedEventId,
|
||||
!existing
|
||||
);
|
||||
setDraft(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteCard = () => {
|
||||
if (!canEditCards || !draft?.id) return;
|
||||
const card = board.cards.find((item) => item.id === draft.id);
|
||||
if (!card || !window.confirm(`Supprimer « ${card.title} » ?`)) return;
|
||||
void runSave(async () => {
|
||||
await sendCard({ ...card, deleted: true }, draft.expectedEventId);
|
||||
setDraft(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const addColumn = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const title = columnTitle.trim();
|
||||
if (!canEditColumns || !title || board.columns.length >= KANBAN_MAX_COLUMNS) return;
|
||||
void runSave(async () => {
|
||||
await sendColumn(
|
||||
{
|
||||
version: 1,
|
||||
id: `col_${crypto.randomUUID()}`,
|
||||
title,
|
||||
position: board.columns.length,
|
||||
deleted: false,
|
||||
},
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
setColumnTitle('');
|
||||
setAddingColumn(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renameColumn = (column: KanbanColumn) => {
|
||||
if (!canEditColumns) return;
|
||||
const title = window.prompt('Nom de la colonne', column.title)?.trim();
|
||||
if (!title || title === column.title) return;
|
||||
void runSave(() =>
|
||||
sendColumn({ ...column, title }, board.columnEventIds.get(column.id))
|
||||
);
|
||||
};
|
||||
|
||||
const deleteColumn = (column: KanbanColumn) => {
|
||||
if (!canEditColumns) return;
|
||||
const columnCards = board.cards.filter((card) => card.columnId === column.id);
|
||||
if (columnCards.length > 0) {
|
||||
setError('Déplacez ou supprimez les cartes avant de retirer cette colonne.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Supprimer la colonne « ${column.title} » ?`)) return;
|
||||
void runSave(async () => {
|
||||
if (readBoardState(room).cards.some((card) => card.columnId === column.id)) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
await sendColumn(
|
||||
{ ...column, deleted: true },
|
||||
board.columnEventIds.get(column.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const moveCard = (cardId: string, columnId: string, index: number) => {
|
||||
if (!canEditCards || saveLock.current) return;
|
||||
let updates: KanbanCard[];
|
||||
try {
|
||||
updates = moveKanbanCard(board.cards, cardId, columnId, index);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (updates.length === 0) return;
|
||||
setBoard((current) => {
|
||||
const next = new Map(current.cards.map((card) => [card.id, card]));
|
||||
updates.forEach((card) => next.set(card.id, card));
|
||||
return { ...current, cards: Array.from(next.values()).sort(byPosition) };
|
||||
});
|
||||
void runSave(() =>
|
||||
Promise.all(
|
||||
updates.map((card) => sendCard(card, board.cardEventIds.get(card.id)))
|
||||
).then(() => undefined)
|
||||
);
|
||||
};
|
||||
|
||||
const dropCard = (event: DragEvent, columnId: string, index: number) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const cardId = event.dataTransfer.getData('text/plain');
|
||||
if (cardId) moveCard(cardId, columnId, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="botsu-kanban"
|
||||
aria-label="Tableau Kanban partagé"
|
||||
style={kanbanThemeStyle as CSSProperties}
|
||||
>
|
||||
{(error || board.invalidCount > 0) && (
|
||||
<div className="botsu-kanban__alert" role="alert">
|
||||
{error ?? `${board.invalidCount} élément(s) Matrix invalide(s) ignoré(s).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="botsu-kanban__board">
|
||||
{board.columns.map((column) => {
|
||||
const cards = board.cards
|
||||
.filter((card) => card.columnId === column.id)
|
||||
.sort(byPosition);
|
||||
return (
|
||||
<section
|
||||
className="botsu-kanban__column"
|
||||
key={column.id}
|
||||
aria-labelledby={`kanban-column-${column.id}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => dropCard(event, column.id, cards.length)}
|
||||
>
|
||||
<header className="botsu-kanban__column-header">
|
||||
<h2 id={`kanban-column-${column.id}`}>{column.title}</h2>
|
||||
<span aria-label={`${cards.length} cartes`}>{cards.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => renameColumn(column)}
|
||||
aria-label={`Renommer ${column.title}`}
|
||||
title="Renommer"
|
||||
disabled={saving || !canEditColumns}
|
||||
>
|
||||
<PencilSimpleIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteColumn(column)}
|
||||
aria-label={`Supprimer ${column.title}`}
|
||||
title="Supprimer"
|
||||
disabled={saving || !canEditColumns}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="botsu-kanban__cards">
|
||||
{cards.map((card, index) => (
|
||||
<article
|
||||
className="botsu-kanban__card"
|
||||
key={card.id}
|
||||
draggable={!saving && canEditCards}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', card.id);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => dropCard(event, column.id, index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCard(card)}
|
||||
aria-label={`Ouvrir ${card.title}`}
|
||||
disabled={saving}
|
||||
>
|
||||
<strong>{card.title}</strong>
|
||||
{card.labels.length > 0 && (
|
||||
<span className="botsu-kanban__labels">
|
||||
{card.labels.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{(card.assignee || card.dueDate) && (
|
||||
<span className="botsu-kanban__meta">
|
||||
{card.assignee && <span>{card.assignee}</span>}
|
||||
{card.dueDate && <time dateTime={card.dueDate}>{card.dueDate}</time>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="botsu-kanban__add-card"
|
||||
type="button"
|
||||
onClick={() => openNewCard(column.id)}
|
||||
disabled={!canEditCards || saving || board.cards.length >= KANBAN_MAX_CARDS}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" size={16} />
|
||||
<span>Ajouter une carte</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section className="botsu-kanban__new-column">
|
||||
{addingColumn ? (
|
||||
<form onSubmit={addColumn}>
|
||||
<label htmlFor="kanban-new-column">Nouvelle colonne</label>
|
||||
<input
|
||||
id="kanban-new-column"
|
||||
value={columnTitle}
|
||||
onChange={(event) => setColumnTitle(event.target.value)}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
required
|
||||
disabled={!canEditColumns || saving}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Ajouter la colonne"
|
||||
disabled={!canEditColumns || saving}
|
||||
>
|
||||
<CheckIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Annuler l'ajout de colonne"
|
||||
onClick={() => setAddingColumn(false)}
|
||||
>
|
||||
<XIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingColumn(true)}
|
||||
disabled={!canEditColumns || saving || board.columns.length >= KANBAN_MAX_COLUMNS}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" size={16} />
|
||||
<span>Ajouter une colonne</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: !saving,
|
||||
onDeactivate: closeEditor,
|
||||
escapeDeactivates: saving ? false : stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
flexHeight
|
||||
className="botsu-kanban__editor"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="kanban-card-title"
|
||||
style={kanbanThemeStyle as CSSProperties}
|
||||
>
|
||||
<form onSubmit={saveCard}>
|
||||
<header>
|
||||
<h2 id="kanban-card-title">{draft.id ? 'Carte' : 'Nouvelle carte'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
aria-label="Fermer"
|
||||
disabled={saving}
|
||||
>
|
||||
<XIcon aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="botsu-kanban__editor-fields">
|
||||
<label>
|
||||
<span>
|
||||
Titre
|
||||
<small>Obligatoire</small>
|
||||
</span>
|
||||
<Input
|
||||
id="kanban-card-title-input"
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.currentTarget.value })}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
maxLength={160}
|
||||
placeholder="Que faut-il faire ?"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<TextArea
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.description}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, description: event.currentTarget.value })
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
resize="Vertical"
|
||||
maxLength={4000}
|
||||
rows={6}
|
||||
placeholder="Ajoutez du contexte, des liens ou les critères de réussite…"
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
<div className="botsu-kanban__field-row">
|
||||
<label>
|
||||
<span>Responsable</span>
|
||||
<div className="botsu-kanban__select">
|
||||
<select
|
||||
value={draft.assignee}
|
||||
onChange={(event) => setDraft({ ...draft, assignee: event.target.value })}
|
||||
disabled={!canEditCards || saving}
|
||||
>
|
||||
<option value="">Personne</option>
|
||||
{members.map((member) => (
|
||||
<option key={member.id} value={member.id}>{member.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Échéance</span>
|
||||
<Input
|
||||
className="botsu-kanban__field-control"
|
||||
type="date"
|
||||
value={draft.dueDate}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, dueDate: event.currentTarget.value })
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>
|
||||
Étiquettes
|
||||
<small>Séparées par des virgules</small>
|
||||
</span>
|
||||
<Input
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.labelsInput}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
labelsInput: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
placeholder="design, urgent"
|
||||
autoComplete="off"
|
||||
maxLength={210}
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
{canEditCards && draft.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="botsu-kanban__danger"
|
||||
onClick={deleteCard}
|
||||
disabled={saving}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" size={16} />
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
<button type="button" onClick={closeEditor} disabled={saving}>
|
||||
Annuler
|
||||
</button>
|
||||
{canEditCards && (
|
||||
<button type="submit" disabled={saving}>
|
||||
{!saving && <CheckIcon aria-hidden="true" size={16} />}
|
||||
{saving ? 'Synchronisation…' : 'Enregistrer'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (url: URL) => readFile(url, 'utf8');
|
||||
|
||||
test('registers Kanban as a first-class Matrix room type in every creation path', async () => {
|
||||
const [roomTypes, createTypes, selector, createRoom, modal, spaceItem, roomUtils] =
|
||||
await Promise.all([
|
||||
read(new URL('../../types/matrix/room.ts', import.meta.url)),
|
||||
read(new URL('../../app/components/create-room/types.ts', import.meta.url)),
|
||||
read(new URL('../../app/components/create-room/CreateRoomTypeSelector.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/create-room/CreateRoom.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/create-room/CreateRoomModal.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/lobby/SpaceItem.tsx', import.meta.url)),
|
||||
read(new URL('../../app/utils/room.ts', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(roomTypes, /Kanban = 'net\.botsu\.kanban'/);
|
||||
assert.match(roomTypes, /BotsuKanbanColumn = 'net\.botsu\.kanban\.column'/);
|
||||
assert.match(roomTypes, /BotsuKanbanCard = 'net\.botsu\.kanban\.card'/);
|
||||
assert.match(createTypes, /KanbanRoom = 'kanban'/);
|
||||
assert.match(selector, /CreateRoomType\.KanbanRoom/);
|
||||
assert.match(selector, />\s*Kanban Room\s*</);
|
||||
assert.match(createRoom, /RoomType\.Kanban/);
|
||||
assert.match(modal, /New Kanban room/);
|
||||
assert.match(spaceItem, /handleCreateRoom\(CreateRoomType\.KanbanRoom\)/);
|
||||
assert.match(roomUtils, /isKanbanRoom/);
|
||||
assert.match(roomUtils, /type !== RoomType\.Kanban/);
|
||||
});
|
||||
|
||||
test('direct create route preserves the Kanban type query parameter', async () => {
|
||||
const directCreate = await read(
|
||||
new URL('../../app/pages/client/home/CreateRoom.tsx', import.meta.url)
|
||||
);
|
||||
assert.match(directCreate, /CreateRoomType\.KanbanRoom/);
|
||||
});
|
||||
|
||||
test('creates editable default state and renders the Kanban room instead of a timeline', async () => {
|
||||
const [utils, room, header, view] = await Promise.all([
|
||||
read(new URL('../../app/components/create-room/utils.ts', import.meta.url)),
|
||||
read(new URL('../../app/features/room/Room.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/room/RoomViewHeader.tsx', import.meta.url)),
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(utils, /createKanbanRoomPowerLevelsOverride/);
|
||||
assert.match(utils, /StateEvent\.BotsuKanbanColumn[^]*?: 0/);
|
||||
assert.match(utils, /StateEvent\.BotsuKanbanCard[^]*?: 0/);
|
||||
assert.match(utils, /KANBAN_DEFAULT_COLUMNS/);
|
||||
assert.match(room, /isKanbanRoom/);
|
||||
assert.match(room, /BotsuKanbanRoomView key=\{room\.roomId\}/);
|
||||
assert.match(header, /isKanbanRoom/);
|
||||
assert.match(view, /RoomStateEvent\.Events/);
|
||||
assert.match(view, /getStateEvent/);
|
||||
assert.match(view, /expectedEventId/);
|
||||
assert.match(view, /usePowerLevelsContext/);
|
||||
assert.match(view, /canEdit/);
|
||||
assert.match(view, /if \(!saving\) setDraft\(undefined\)/);
|
||||
assert.match(view, /aria-label=\{`Ouvrir \$\{card\.title\}`\}[^]*?disabled=\{saving\}/);
|
||||
assert.match(view, /aria-label="Fermer"[^]*?disabled=\{saving\}/);
|
||||
assert.match(view, /sendStateEvent/);
|
||||
assert.doesNotMatch(view, /dangerouslySetInnerHTML/);
|
||||
});
|
||||
|
||||
test('Kanban room exposes cards, metadata, drag and drop, columns, and accessible controls', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /draggable/);
|
||||
assert.match(view, /onDrop/);
|
||||
assert.match(view, /Ajouter une carte/);
|
||||
assert.match(view, /Ajouter une colonne/);
|
||||
assert.match(view, /Description/);
|
||||
assert.match(view, /Responsable/);
|
||||
assert.match(view, /Échéance/);
|
||||
assert.match(view, /Étiquettes/);
|
||||
assert.match(view, /aria-label/);
|
||||
assert.match(css, /--kanban-radius/);
|
||||
assert.match(css, /overflow-x:\s*auto/);
|
||||
});
|
||||
|
||||
test('Kanban follows Cinny and BOTSU theme tokens and fills Safari flex layouts', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /color\.Background\.Container/);
|
||||
assert.match(view, /color\.Surface\.Container/);
|
||||
assert.match(view, /color\.Primary\.Main/);
|
||||
assert.match(view, /vars\.outline\.FocusRing/);
|
||||
assert.match(view, /style=\{kanbanThemeStyle/);
|
||||
assert.match(css, /flex:\s*1 1 0%/);
|
||||
assert.match(css, /width:\s*100%/);
|
||||
assert.match(css, /min-width:\s*0/);
|
||||
assert.match(css, /inline-size:\s*100%/);
|
||||
assert.match(css, /min-inline-size:\s*0/);
|
||||
assert.match(css, /align-self:\s*stretch/);
|
||||
assert.match(css, /var\(--kanban-surface-raised\)/);
|
||||
assert.match(css, /var\(--kanban-focus\)/);
|
||||
});
|
||||
|
||||
test('card editing uses the same centered floating modal pattern as Cinny settings', async () => {
|
||||
const view = await read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url));
|
||||
|
||||
assert.match(view, /<Overlay open backdrop=\{<OverlayBackdrop \/>\}>/);
|
||||
assert.match(view, /<OverlayCenter>/);
|
||||
assert.match(view, /<FocusTrap/);
|
||||
assert.match(view, /<Modal[^]*?flexHeight[^]*?className="botsu-kanban__editor"/);
|
||||
assert.doesNotMatch(view, /botsu-kanban__editor-backdrop/);
|
||||
});
|
||||
|
||||
test('card editor fields use polished Cinny form controls instead of raw inputs', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /\bInput\b/);
|
||||
assert.match(view, /<Input[^]*?variant="SurfaceVariant"/);
|
||||
assert.match(view, /<TextArea[^]*?variant="SurfaceVariant"/);
|
||||
assert.match(view, /className="botsu-kanban__select"/);
|
||||
assert.match(css, /\.botsu-kanban__field-row/);
|
||||
assert.match(css, /\.botsu-kanban__select:focus-within/);
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
.botsu-kanban {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
flex: 1 1 0%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
max-inline-size: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
color: var(--kanban-text);
|
||||
background: var(--kanban-canvas);
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.botsu-kanban *,
|
||||
.botsu-kanban *::before,
|
||||
.botsu-kanban *::after,
|
||||
.botsu-kanban__editor,
|
||||
.botsu-kanban__editor *,
|
||||
.botsu-kanban__editor *::before,
|
||||
.botsu-kanban__editor *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botsu-kanban button,
|
||||
.botsu-kanban input,
|
||||
.botsu-kanban textarea,
|
||||
.botsu-kanban select,
|
||||
.botsu-kanban__editor button,
|
||||
.botsu-kanban__editor input,
|
||||
.botsu-kanban__editor textarea,
|
||||
.botsu-kanban__editor select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.botsu-kanban button,
|
||||
.botsu-kanban__editor button {
|
||||
min-height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--kanban-radius);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-kanban button:disabled,
|
||||
.botsu-kanban__editor button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.botsu-kanban button:focus-visible,
|
||||
.botsu-kanban input:focus-visible,
|
||||
.botsu-kanban textarea:focus-visible,
|
||||
.botsu-kanban select:focus-visible,
|
||||
.botsu-kanban__editor button:focus-visible {
|
||||
outline: 2px solid var(--kanban-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.botsu-kanban__alert {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 1rem;
|
||||
top: 0.75rem;
|
||||
max-width: 34rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: var(--kanban-text);
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-left: 3px solid var(--kanban-critical);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.botsu-kanban__board {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.botsu-kanban__column,
|
||||
.botsu-kanban__new-column {
|
||||
flex: 0 0 clamp(17rem, 30%, 22rem);
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.botsu-kanban__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 8rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
overflow: hidden;
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface);
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--kanban-text) 5%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--kanban-border);
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header > span {
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header button {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
padding: 0;
|
||||
color: var(--kanban-muted);
|
||||
border-radius: var(--kanban-radius);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header button:hover,
|
||||
.botsu-kanban__add-card:hover,
|
||||
.botsu-kanban__new-column > button:hover {
|
||||
color: var(--kanban-text);
|
||||
background: color-mix(in srgb, var(--kanban-accent) 12%, var(--kanban-surface-raised));
|
||||
}
|
||||
|
||||
.botsu-kanban__cards {
|
||||
min-height: 1rem;
|
||||
flex: 1 1 auto;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
}
|
||||
|
||||
.botsu-kanban__card {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__card[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.botsu-kanban__card[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.botsu-kanban__card > button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.botsu-kanban__card > button:hover {
|
||||
background: color-mix(in srgb, var(--kanban-accent) 8%, var(--kanban-surface-raised));
|
||||
}
|
||||
|
||||
.botsu-kanban__card:has(> button:hover) {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 55%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__card strong {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.botsu-kanban__labels,
|
||||
.botsu-kanban__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__labels > span {
|
||||
padding: 0.15rem 0.38rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
color: var(--kanban-text);
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 42%, var(--kanban-border));
|
||||
background: color-mix(in srgb, var(--kanban-accent) 12%, transparent);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__meta {
|
||||
justify-content: space-between;
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__add-card,
|
||||
.botsu-kanban__new-column > button {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--kanban-muted);
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.botsu-kanban__add-card {
|
||||
border-top: 1px solid var(--kanban-border) !important;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column {
|
||||
border: 1px dashed var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: color-mix(in srgb, var(--kanban-surface) 72%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column label {
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.botsu-kanban input,
|
||||
.botsu-kanban textarea,
|
||||
.botsu-kanban select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--kanban-border);
|
||||
color: var(--kanban-text);
|
||||
border-radius: var(--kanban-radius);
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban input::placeholder,
|
||||
.botsu-kanban textarea::placeholder {
|
||||
color: var(--kanban-muted);
|
||||
}
|
||||
|
||||
.botsu-kanban textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor {
|
||||
width: calc(100vw - 2rem);
|
||||
max-width: min(42rem, calc(100vw - 2rem));
|
||||
max-height: calc(100dvh - 2rem);
|
||||
color: var(--kanban-text);
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface);
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 28%);
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor form {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header,
|
||||
.botsu-kanban__editor footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem 0.9rem;
|
||||
border-bottom: 1px solid var(--kanban-border);
|
||||
background: color-mix(in srgb, var(--kanban-surface-raised) 54%, var(--kanban-surface));
|
||||
}
|
||||
|
||||
.botsu-kanban__editor h2 {
|
||||
margin: 0;
|
||||
color: var(--kanban-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor-fields {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem 1.25rem 1.2rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label > span {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label small {
|
||||
color: color-mix(in srgb, var(--kanban-muted) 78%, transparent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control {
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control:hover {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 38%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control:focus-within {
|
||||
border-color: var(--kanban-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kanban-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
color: var(--kanban-text);
|
||||
background: var(--kanban-surface-raised);
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__select::after {
|
||||
position: absolute;
|
||||
right: 0.9rem;
|
||||
top: 50%;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
color: var(--kanban-muted);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
}
|
||||
|
||||
.botsu-kanban__select:hover {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 38%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__select:focus-within {
|
||||
border-color: var(--kanban-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kanban-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__select select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.7rem 2.5rem 0.7rem 0.85rem;
|
||||
color: inherit;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-kanban__select select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
padding: 0.9rem 1.25rem 1rem;
|
||||
border-top: 1px solid var(--kanban-border);
|
||||
background: color-mix(in srgb, var(--kanban-surface-raised) 54%, var(--kanban-surface));
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer button {
|
||||
min-height: 2.2rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer button[type='submit'] {
|
||||
color: var(--kanban-on-accent);
|
||||
border-color: var(--kanban-accent);
|
||||
background: var(--kanban-accent);
|
||||
}
|
||||
|
||||
.botsu-kanban__danger {
|
||||
color: var(--kanban-critical) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.botsu-kanban__board {
|
||||
padding: 0.75rem;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.botsu-kanban__column,
|
||||
.botsu-kanban__new-column {
|
||||
flex-basis: calc(100% - 1rem);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor {
|
||||
width: calc(100vw - 1.5rem);
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: calc(100dvh - 1.5rem);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor form {
|
||||
max-height: calc(100dvh - 1.5rem);
|
||||
}
|
||||
|
||||
.botsu-kanban__field-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
KANBAN_DEFAULT_COLUMNS,
|
||||
type KanbanCard,
|
||||
moveKanbanCard,
|
||||
nextKanbanCardPosition,
|
||||
parseKanbanCard,
|
||||
parseKanbanColumn,
|
||||
} from './model.ts';
|
||||
|
||||
const column = {
|
||||
version: 1,
|
||||
id: 'todo',
|
||||
title: 'À faire',
|
||||
position: 0,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
const card: KanbanCard = {
|
||||
version: 1,
|
||||
id: 'card_1',
|
||||
columnId: 'todo',
|
||||
title: 'Première carte',
|
||||
description: '',
|
||||
assignee: '',
|
||||
dueDate: '',
|
||||
labels: ['design'],
|
||||
position: 0,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
test('parses bounded Kanban columns and cards into fresh values', () => {
|
||||
const parsedColumn = parseKanbanColumn(column);
|
||||
const parsedCard = parseKanbanCard(card);
|
||||
|
||||
assert.deepEqual(parsedColumn, column);
|
||||
assert.deepEqual(parsedCard, card);
|
||||
assert.notEqual(parsedColumn, column);
|
||||
assert.notEqual(parsedCard, card);
|
||||
assert.notEqual(parsedCard.labels, card.labels);
|
||||
});
|
||||
|
||||
test('rejects malformed, oversized, accessor-backed, and extra Kanban data', () => {
|
||||
assert.throws(() => parseKanbanColumn({ ...column, title: 'x'.repeat(81) }));
|
||||
assert.throws(() => parseKanbanColumn({ ...column, extra: true }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: Array(7).fill('x') }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: Array(1) }));
|
||||
const accessorLabels: string[] = [];
|
||||
Object.defineProperty(accessorLabels, '0', {
|
||||
enumerable: true,
|
||||
get: () => 'piège',
|
||||
});
|
||||
Object.defineProperty(accessorLabels, 'length', { value: 1 });
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: accessorLabels }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, dueDate: '08/08/2026' }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, id: '../secret' }));
|
||||
|
||||
const accessorCard = { ...card } as Record<string, unknown>;
|
||||
Object.defineProperty(accessorCard, 'title', { enumerable: true, get: () => 'piège' });
|
||||
assert.throws(() => parseKanbanCard(accessorCard));
|
||||
});
|
||||
|
||||
test('provides the three minimal default columns', () => {
|
||||
assert.deepEqual(
|
||||
KANBAN_DEFAULT_COLUMNS.map((item) => item.title),
|
||||
['À faire', 'En cours', 'Terminé']
|
||||
);
|
||||
});
|
||||
|
||||
test('moves a card across columns with one state update and stable ordering', () => {
|
||||
const cards = [
|
||||
{ ...card, id: 'a', position: 0 },
|
||||
{ ...card, id: 'b', position: 1 },
|
||||
{ ...card, id: 'c', columnId: 'doing', position: 0 },
|
||||
];
|
||||
|
||||
const updates = moveKanbanCard(cards, 'b', 'doing', 0);
|
||||
assert.equal(updates.length, 1);
|
||||
const merged = new Map(cards.map((item) => [item.id, item]));
|
||||
updates.forEach((item) => merged.set(item.id, item));
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(merged.values())
|
||||
.filter((item) => item.columnId === 'todo')
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => [item.id, item.position]),
|
||||
[['a', 0]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(merged.values())
|
||||
.filter((item) => item.columnId === 'doing')
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => item.id),
|
||||
['b', 'c']
|
||||
);
|
||||
assert.equal(merged.get('c')?.position, 0);
|
||||
});
|
||||
|
||||
test('places a new card after the last ranked card without renumbering', () => {
|
||||
assert.equal(nextKanbanCardPosition([{ ...card, position: 2048 }], 'todo'), 3072);
|
||||
assert.equal(nextKanbanCardPosition([], 'todo'), 0);
|
||||
});
|
||||
|
||||
test('adjusts a same-column downward drop before the original target', () => {
|
||||
const cards = [
|
||||
{ ...card, id: 'a', position: 0 },
|
||||
{ ...card, id: 'b', position: 1024 },
|
||||
{ ...card, id: 'c', position: 2048 },
|
||||
];
|
||||
const [moved] = moveKanbanCard(cards, 'a', 'todo', 2);
|
||||
assert.ok(moved);
|
||||
const merged = cards.map((item) => (item.id === moved.id ? moved : item));
|
||||
assert.deepEqual(merged.sort((a, b) => a.position - b.position).map((item) => item.id), [
|
||||
'b',
|
||||
'a',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
test('refuses moves to unknown columns or for deleted cards', () => {
|
||||
const cards = [{ ...card, deleted: true }];
|
||||
assert.throws(() => moveKanbanCard(cards, card.id, 'todo', 0));
|
||||
assert.throws(() => moveKanbanCard([{ ...card }], card.id, '', 0));
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
export const KANBAN_MAX_COLUMNS = 12;
|
||||
export const KANBAN_MAX_CARDS = 500;
|
||||
export const KANBAN_MAX_LABELS = 6;
|
||||
|
||||
export type KanbanColumn = {
|
||||
version: 1;
|
||||
id: string;
|
||||
title: string;
|
||||
position: number;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type KanbanCard = {
|
||||
version: 1;
|
||||
id: string;
|
||||
columnId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee: string;
|
||||
dueDate: string;
|
||||
labels: string[];
|
||||
position: number;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/i;
|
||||
const MATRIX_USER_ID_PATTERN = /^@[^\s:]{1,128}:[^\s:]{1,255}$/;
|
||||
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
const ownDataRecord = (value: unknown, keys: readonly string[], name: string) => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} must be an object`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${name} must be a plain object`);
|
||||
}
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (ownKeys.some((key) => typeof key !== 'string') || ownKeys.length !== keys.length) {
|
||||
throw new TypeError(`${name} has unsupported fields`);
|
||||
}
|
||||
const expected = new Set(keys);
|
||||
if (ownKeys.some((key) => typeof key !== 'string' || !expected.has(key))) {
|
||||
throw new TypeError(`${name} has unsupported fields`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
for (const key of keys) {
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError(`${name}.${key} must be an enumerable data field`);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => [key, descriptors[key]?.value])) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
|
||||
const boundedString = (
|
||||
value: unknown,
|
||||
name: string,
|
||||
max: number,
|
||||
{ empty = false }: { empty?: boolean } = {}
|
||||
) => {
|
||||
if (typeof value !== 'string' || value.length > max || (!empty && value.trim().length === 0)) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const entityId = (value: unknown, name: string) => {
|
||||
const id = boundedString(value, name, 64);
|
||||
if (!ID_PATTERN.test(id)) throw new TypeError(`${name} is invalid`);
|
||||
return id;
|
||||
};
|
||||
|
||||
const columnPosition = (value: unknown, name: string) => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > 9999) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const cardPosition = (value: unknown, name: string) => {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
value < -1_000_000_000_000 ||
|
||||
value > 1_000_000_000_000
|
||||
) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const bool = (value: unknown, name: string) => {
|
||||
if (typeof value !== 'boolean') throw new TypeError(`${name} is invalid`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const dueDate = (value: unknown) => {
|
||||
const date = boundedString(value, 'KanbanCard.dueDate', 10, { empty: true });
|
||||
if (date === '') return date;
|
||||
const match = DATE_PATTERN.exec(date);
|
||||
if (!match) throw new TypeError('KanbanCard.dueDate is invalid');
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year ||
|
||||
parsed.getUTCMonth() !== month - 1 ||
|
||||
parsed.getUTCDate() !== day
|
||||
) {
|
||||
throw new TypeError('KanbanCard.dueDate is invalid');
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
const labels = (value: unknown) => {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Array.prototype ||
|
||||
value.length > KANBAN_MAX_LABELS
|
||||
) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
const expectedKeys = new Set([
|
||||
'length',
|
||||
...Array.from({ length: value.length }, (_unused, index) => String(index)),
|
||||
]);
|
||||
if (
|
||||
ownKeys.length !== value.length + 1 ||
|
||||
ownKeys.some((key) => typeof key !== 'string' || !expectedKeys.has(key))
|
||||
) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
const parsed = Array.from({ length: value.length }, (_unused, index) => {
|
||||
const descriptor = descriptors[String(index)];
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
return boundedString(descriptor.value, 'KanbanCard.label', 32);
|
||||
});
|
||||
if (new Set(parsed.map((label) => label.toLocaleLowerCase())).size !== parsed.length) {
|
||||
throw new TypeError('KanbanCard.labels must be unique');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseKanbanColumn = (value: unknown): KanbanColumn => {
|
||||
const record = ownDataRecord(
|
||||
value,
|
||||
['version', 'id', 'title', 'position', 'deleted'],
|
||||
'KanbanColumn'
|
||||
);
|
||||
if (record.version !== 1) throw new TypeError('KanbanColumn.version is unsupported');
|
||||
return {
|
||||
version: 1,
|
||||
id: entityId(record.id, 'KanbanColumn.id'),
|
||||
title: boundedString(record.title, 'KanbanColumn.title', 80),
|
||||
position: columnPosition(record.position, 'KanbanColumn.position'),
|
||||
deleted: bool(record.deleted, 'KanbanColumn.deleted'),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseKanbanCard = (value: unknown): KanbanCard => {
|
||||
const record = ownDataRecord(
|
||||
value,
|
||||
[
|
||||
'version',
|
||||
'id',
|
||||
'columnId',
|
||||
'title',
|
||||
'description',
|
||||
'assignee',
|
||||
'dueDate',
|
||||
'labels',
|
||||
'position',
|
||||
'deleted',
|
||||
],
|
||||
'KanbanCard'
|
||||
);
|
||||
if (record.version !== 1) throw new TypeError('KanbanCard.version is unsupported');
|
||||
const parsedAssignee = boundedString(record.assignee, 'KanbanCard.assignee', 384, {
|
||||
empty: true,
|
||||
});
|
||||
if (parsedAssignee && !MATRIX_USER_ID_PATTERN.test(parsedAssignee)) {
|
||||
throw new TypeError('KanbanCard.assignee is invalid');
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
id: entityId(record.id, 'KanbanCard.id'),
|
||||
columnId: entityId(record.columnId, 'KanbanCard.columnId'),
|
||||
title: boundedString(record.title, 'KanbanCard.title', 160),
|
||||
description: boundedString(record.description, 'KanbanCard.description', 4000, {
|
||||
empty: true,
|
||||
}),
|
||||
assignee: parsedAssignee,
|
||||
dueDate: dueDate(record.dueDate),
|
||||
labels: labels(record.labels),
|
||||
position: cardPosition(record.position, 'KanbanCard.position'),
|
||||
deleted: bool(record.deleted, 'KanbanCard.deleted'),
|
||||
};
|
||||
};
|
||||
|
||||
export const KANBAN_DEFAULT_COLUMNS: readonly KanbanColumn[] = [
|
||||
{ version: 1, id: 'todo', title: 'À faire', position: 0, deleted: false },
|
||||
{ version: 1, id: 'doing', title: 'En cours', position: 1, deleted: false },
|
||||
{ version: 1, id: 'done', title: 'Terminé', position: 2, deleted: false },
|
||||
];
|
||||
|
||||
export const nextKanbanCardPosition = (
|
||||
cards: readonly KanbanCard[],
|
||||
columnId: string
|
||||
): number => {
|
||||
entityId(columnId, 'columnId');
|
||||
const positions = cards
|
||||
.filter((card) => !card.deleted && card.columnId === columnId)
|
||||
.map((card) => card.position);
|
||||
return positions.length === 0 ? 0 : Math.max(...positions) + 1024;
|
||||
};
|
||||
|
||||
export const moveKanbanCard = (
|
||||
cards: readonly KanbanCard[],
|
||||
cardId: string,
|
||||
targetColumnId: string,
|
||||
targetIndex: number
|
||||
): KanbanCard[] => {
|
||||
entityId(targetColumnId, 'targetColumnId');
|
||||
if (!Number.isSafeInteger(targetIndex) || targetIndex < 0) {
|
||||
throw new TypeError('targetIndex is invalid');
|
||||
}
|
||||
const moving = cards.find((card) => card.id === cardId);
|
||||
if (!moving || moving.deleted) throw new TypeError('card is unavailable');
|
||||
|
||||
const targetWithMoving = cards
|
||||
.filter((card) => !card.deleted && card.columnId === targetColumnId)
|
||||
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
|
||||
const sourceIndex = targetWithMoving.findIndex((card) => card.id === moving.id);
|
||||
const targetCards = targetWithMoving.filter((card) => card.id !== moving.id);
|
||||
const adjustedTargetIndex =
|
||||
moving.columnId === targetColumnId && sourceIndex >= 0 && sourceIndex < targetIndex
|
||||
? targetIndex - 1
|
||||
: targetIndex;
|
||||
const insertionIndex = Math.min(adjustedTargetIndex, targetCards.length);
|
||||
const previous = targetCards[insertionIndex - 1];
|
||||
const next = targetCards[insertionIndex];
|
||||
let nextPosition = 0;
|
||||
if (previous && next) nextPosition = (previous.position + next.position) / 2;
|
||||
else if (previous) nextPosition = previous.position + 1024;
|
||||
else if (next) nextPosition = next.position - 1024;
|
||||
|
||||
if (moving.columnId === targetColumnId && moving.position === nextPosition) return [];
|
||||
return [{ ...moving, columnId: targetColumnId, position: nextPosition }];
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileIcon,
|
||||
LinkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
PushPinIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { createMoodboardController } from './controller';
|
||||
import type {
|
||||
MoodboardCard,
|
||||
MoodboardCardDraft,
|
||||
MoodboardCardKind,
|
||||
MoodboardSearchResult,
|
||||
} from './model';
|
||||
import './moodboard.css';
|
||||
|
||||
type ComposerState = {
|
||||
kind: MoodboardCardKind;
|
||||
title: string;
|
||||
value: string;
|
||||
color: string;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
const EMPTY_COMPOSER: ComposerState = { kind: 'image', title: '', value: '', color: '#7C5CFF' };
|
||||
const DEFAULT_PINTEREST_SOURCE = 'https://www.pinterest.com/pinterest/';
|
||||
const PINTEREST_SOURCE_KEY = 'botsu.moodboard.pinterest-source';
|
||||
const TYPE_LABELS: Record<MoodboardCardKind, string> = {
|
||||
image: 'Image',
|
||||
color: 'Couleur',
|
||||
text: 'Texte',
|
||||
file: 'Fichier',
|
||||
link: 'Lien',
|
||||
};
|
||||
|
||||
const readFileAsDataUrl = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
reject(new Error('Le fichier dépasse 2 Mio.'));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Lecture du fichier impossible.'));
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const toDraft = async (state: ComposerState): Promise<MoodboardCardDraft> => {
|
||||
if (state.kind === 'color') return { kind: 'color', title: state.title, color: state.color };
|
||||
if (state.kind === 'text') return { kind: 'text', title: state.title, text: state.value };
|
||||
if (state.kind === 'link') return { kind: 'link', title: state.title, url: state.value };
|
||||
if (state.kind === 'image') return { kind: 'image', title: state.title, url: state.value };
|
||||
if (!state.file) throw new Error('Choisis un fichier.');
|
||||
return {
|
||||
kind: 'file',
|
||||
title: state.title || state.file.name,
|
||||
fileName: state.file.name,
|
||||
mimeType: state.file.type || 'application/octet-stream',
|
||||
dataUrl: await readFileAsDataUrl(state.file),
|
||||
};
|
||||
};
|
||||
|
||||
function SharedCard({ card }: { card: MoodboardCard }) {
|
||||
return (
|
||||
<article className={`botsu-moodboard-card is-${card.kind}`}>
|
||||
{card.kind === 'image' && (
|
||||
<a href={card.sourceUrl ?? card.url} target="_blank" rel="noopener noreferrer">
|
||||
<img alt={card.title} loading="lazy" src={card.url} />
|
||||
</a>
|
||||
)}
|
||||
{card.kind === 'color' && (
|
||||
<div className="botsu-moodboard-color" style={{ background: card.color }}>
|
||||
<span>{card.color}</span>
|
||||
</div>
|
||||
)}
|
||||
{card.kind === 'text' && <p className="botsu-moodboard-note">{card.text}</p>}
|
||||
{card.kind === 'link' && (
|
||||
<a className="botsu-moodboard-link" href={card.url} target="_blank" rel="noopener noreferrer">
|
||||
<LinkIcon aria-hidden="true" size={22} />
|
||||
<span>{new URL(card.url).hostname}</span>
|
||||
</a>
|
||||
)}
|
||||
{card.kind === 'file' && (
|
||||
<a className="botsu-moodboard-file" download={card.fileName} href={card.dataUrl}>
|
||||
<FileIcon aria-hidden="true" size={32} />
|
||||
<span>{card.fileName}</span>
|
||||
</a>
|
||||
)}
|
||||
<div className="botsu-moodboard-card-meta">
|
||||
<strong>{card.title}</strong>
|
||||
<small>{card.creatorName}</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchCard({ result, onPin, pending }: { result: MoodboardSearchResult; onPin: () => void; pending: boolean }) {
|
||||
return (
|
||||
<article className="botsu-moodboard-card is-search">
|
||||
<a href={result.sourceUrl} target="_blank" rel="noopener noreferrer">
|
||||
<img alt={result.title} loading="lazy" src={result.thumbnailUrl ?? result.imageUrl} />
|
||||
</a>
|
||||
<div className="botsu-moodboard-card-meta">
|
||||
<strong>{result.title}</strong>
|
||||
<small>{new URL(result.sourceUrl).hostname}</small>
|
||||
<button disabled={pending} type="button" onClick={onPin}>
|
||||
<PushPinIcon aria-hidden="true" size={16} />
|
||||
{pending ? 'Ajout…' : 'Épingler'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotsuMoodboard() {
|
||||
const mx = useMatrixClient();
|
||||
const controller = useMemo(
|
||||
() => createMoodboardController({ getOpenIdToken: () => mx.getOpenIdToken() }),
|
||||
[mx]
|
||||
);
|
||||
const [cards, setCards] = useState<MoodboardCard[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<MoodboardSearchResult[]>([]);
|
||||
const [pinterestResults, setPinterestResults] = useState<MoodboardSearchResult[]>([]);
|
||||
const [pinterestUrl, setPinterestUrl] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(PINTEREST_SOURCE_KEY) ?? DEFAULT_PINTEREST_SOURCE;
|
||||
} catch {
|
||||
return DEFAULT_PINTEREST_SOURCE;
|
||||
}
|
||||
});
|
||||
const [pinterestLoading, setPinterestLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeQuery, setActiveQuery] = useState('');
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [composerOpen, setComposerOpen] = useState(false);
|
||||
const [composer, setComposer] = useState<ComposerState>(EMPTY_COMPOSER);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pinningUrl, setPinningUrl] = useState('');
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadFirst = useCallback(async (nextQuery = '') => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [page, webResults] = await Promise.all([
|
||||
controller.list(nextQuery ? { query: nextQuery } : {}),
|
||||
nextQuery ? controller.search(nextQuery) : Promise.resolve([]),
|
||||
]);
|
||||
setCards(page.cards);
|
||||
setNextCursor(page.nextCursor);
|
||||
setSearchResults(webResults);
|
||||
} catch {
|
||||
setError('Le Moodboard partagé est momentanément indisponible.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [controller]);
|
||||
|
||||
const loadPinterest = useCallback(async (sourceUrl: string) => {
|
||||
setPinterestLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const pins = await controller.getPinterest(sourceUrl);
|
||||
setPinterestResults(pins);
|
||||
try {
|
||||
window.localStorage.setItem(PINTEREST_SOURCE_KEY, sourceUrl.trim());
|
||||
} catch {
|
||||
// The direct feed remains usable without local persistence.
|
||||
}
|
||||
} catch {
|
||||
setPinterestResults([]);
|
||||
setError('Ce profil ou tableau Pinterest public est indisponible.');
|
||||
} finally {
|
||||
setPinterestLoading(false);
|
||||
}
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFirst();
|
||||
void loadPinterest(pinterestUrl);
|
||||
const poll = window.setInterval(() => {
|
||||
if (!activeQuery && document.visibilityState === 'visible') void loadFirst();
|
||||
}, 8_000);
|
||||
return () => window.clearInterval(poll);
|
||||
}, [activeQuery, loadFirst, loadPinterest]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await controller.list({ cursor: nextCursor, ...(activeQuery ? { query: activeQuery } : {}) });
|
||||
setCards((current) => [...current, ...page.cards.filter((card) => !current.some(({ id }) => id === card.id))]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch {
|
||||
setError('Impossible de charger la suite du Moodboard.');
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [activeQuery, controller, loadingMore, nextCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return undefined;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
}, { rootMargin: '500px' });
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const submitSearch = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
setActiveQuery(normalized);
|
||||
void loadFirst(normalized);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
setActiveQuery('');
|
||||
void loadFirst();
|
||||
};
|
||||
|
||||
const submitPinterest = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void loadPinterest(pinterestUrl);
|
||||
};
|
||||
|
||||
const submitCard = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const card = await controller.create(await toDraft(composer));
|
||||
setCards((current) => [card, ...current]);
|
||||
setComposer(EMPTY_COMPOSER);
|
||||
setComposerOpen(false);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Ajout impossible.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pinResult = async (result: MoodboardSearchResult) => {
|
||||
setPinningUrl(result.imageUrl);
|
||||
try {
|
||||
const card = await controller.create({
|
||||
kind: 'image',
|
||||
title: result.title,
|
||||
url: result.imageUrl,
|
||||
sourceUrl: result.sourceUrl,
|
||||
});
|
||||
setCards((current) => [card, ...current]);
|
||||
setSearchResults((current) => current.filter(({ imageUrl }) => imageUrl !== result.imageUrl));
|
||||
setPinterestResults((current) => current.filter(({ imageUrl }) => imageUrl !== result.imageUrl));
|
||||
} catch {
|
||||
setError('Cette image ne peut pas être épinglée.');
|
||||
} finally {
|
||||
setPinningUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="botsu-moodboard" aria-label="Moodboard partagé">
|
||||
<header className="botsu-moodboard-toolbar">
|
||||
<form className="botsu-moodboard-search" role="search" onSubmit={submitSearch}>
|
||||
<MagnifyingGlassIcon aria-hidden="true" size={19} />
|
||||
<input
|
||||
aria-label="Rechercher dans le Moodboard et sur BOTSU Search"
|
||||
maxLength={200}
|
||||
placeholder="Rechercher"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
{activeQuery && (
|
||||
<button aria-label="Effacer la recherche" type="button" onClick={clearSearch}>
|
||||
<XIcon aria-hidden="true" size={17} />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<button className="botsu-moodboard-add" type="button" onClick={() => setComposerOpen((open) => !open)}>
|
||||
<PlusIcon aria-hidden="true" size={18} />
|
||||
Ajouter
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="botsu-moodboard-pinterest-source" onSubmit={submitPinterest}>
|
||||
<input
|
||||
aria-label="Profil ou tableau Pinterest"
|
||||
maxLength={240}
|
||||
placeholder="https://www.pinterest.com/profil/tableau/"
|
||||
required
|
||||
type="url"
|
||||
value={pinterestUrl}
|
||||
onChange={(event) => setPinterestUrl(event.target.value)}
|
||||
/>
|
||||
<button disabled={pinterestLoading} type="submit">
|
||||
{pinterestLoading ? 'Pinterest…' : 'Charger Pinterest'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{composerOpen && (
|
||||
<form className="botsu-moodboard-composer" onSubmit={submitCard}>
|
||||
<div className="botsu-moodboard-kinds" role="group" aria-label="Type de contenu">
|
||||
{(Object.keys(TYPE_LABELS) as MoodboardCardKind[]).map((kind) => (
|
||||
<button
|
||||
aria-pressed={composer.kind === kind}
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setComposer((current) => ({ ...current, kind }))}
|
||||
>
|
||||
{TYPE_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
aria-label="Titre"
|
||||
maxLength={160}
|
||||
placeholder="Titre"
|
||||
required={composer.kind !== 'file'}
|
||||
value={composer.title}
|
||||
onChange={(event) => setComposer((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
{composer.kind === 'color' ? (
|
||||
<input aria-label="Couleur" type="color" value={composer.color} onChange={(event) => setComposer((current) => ({ ...current, color: event.target.value }))} />
|
||||
) : composer.kind === 'file' ? (
|
||||
<input aria-label="Fichier" required type="file" onChange={(event) => setComposer((current) => ({ ...current, file: event.target.files?.[0] }))} />
|
||||
) : composer.kind === 'text' ? (
|
||||
<textarea aria-label="Texte" maxLength={5000} placeholder="Texte" required value={composer.value} onChange={(event) => setComposer((current) => ({ ...current, value: event.target.value }))} />
|
||||
) : (
|
||||
<input aria-label={composer.kind === 'image' ? "Lien de l'image" : 'Lien'} maxLength={2048} placeholder="https://" required type="url" value={composer.value} onChange={(event) => setComposer((current) => ({ ...current, value: event.target.value }))} />
|
||||
)}
|
||||
<button disabled={saving} type="submit">{saving ? 'Ajout…' : 'Ajouter au Moodboard'}</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="botsu-moodboard-error" role="alert">{error}</p>}
|
||||
{activeQuery && <p className="botsu-moodboard-context">BOTSU Search · {activeQuery}</p>}
|
||||
{!loading && cards.length === 0 && searchResults.length === 0 && pinterestResults.length === 0 && (
|
||||
<p className="botsu-moodboard-empty">Rien ici pour l’instant. Ajoute la première inspiration.</p>
|
||||
)}
|
||||
<div className="botsu-moodboard-feed" aria-busy={loading || loadingMore || pinterestLoading}>
|
||||
{pinterestResults.map((result) => (
|
||||
<SearchCard key={`pinterest-${result.imageUrl}`} pending={pinningUrl === result.imageUrl} result={result} onPin={() => void pinResult(result)} />
|
||||
))}
|
||||
{searchResults.map((result) => (
|
||||
<SearchCard key={`search-${result.imageUrl}`} pending={pinningUrl === result.imageUrl} result={result} onPin={() => void pinResult(result)} />
|
||||
))}
|
||||
{cards.map((card) => <SharedCard card={card} key={card.id} />)}
|
||||
</div>
|
||||
{(loading || loadingMore) && <p className="botsu-moodboard-loading" aria-live="polite">Chargement…</p>}
|
||||
<div ref={sentinelRef} className="botsu-moodboard-sentinel" aria-hidden="true" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMoodboardController } from './controller.ts';
|
||||
|
||||
const token = { access_token: 'openid-token-long-enough' };
|
||||
|
||||
test('loads paginated cards with Matrix OpenID authentication', async () => {
|
||||
let requested = '';
|
||||
const controller = createMoodboardController({
|
||||
getOpenIdToken: async () => token,
|
||||
fetchImpl: async (input, init) => {
|
||||
requested = String(input);
|
||||
assert.equal((init?.headers as Record<string, string>).authorization, `Bearer ${token.access_token}`);
|
||||
return new Response(JSON.stringify({ cards: [], nextCursor: '24' }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
const page = await controller.list({ cursor: '12', query: 'béton' });
|
||||
const url = new URL(requested, 'https://test.botsu.net');
|
||||
assert.equal(url.pathname, '/presence/moodboard/cards');
|
||||
assert.equal(url.searchParams.get('cursor'), '12');
|
||||
assert.equal(url.searchParams.get('q'), 'béton');
|
||||
assert.equal(page.nextCursor, '24');
|
||||
});
|
||||
|
||||
test('loads a public Pinterest profile directly through the authenticated BOTSU proxy', async () => {
|
||||
let requested = '';
|
||||
const controller = createMoodboardController({
|
||||
getOpenIdToken: async () => token,
|
||||
fetchImpl: async (input) => {
|
||||
requested = String(input);
|
||||
return new Response(JSON.stringify({
|
||||
source: 'https://www.pinterest.com/pinterest/',
|
||||
pins: [{
|
||||
id: '424605071136961057',
|
||||
title: 'Architecture',
|
||||
sourceUrl: 'https://www.pinterest.com/pin/424605071136961057/',
|
||||
imageUrl: 'https://i.pinimg.com/564x/a.jpg',
|
||||
thumbnailUrl: 'https://i.pinimg.com/236x/a.jpg',
|
||||
width: 564,
|
||||
height: 846,
|
||||
}],
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
const pins = await controller.getPinterest('https://www.pinterest.com/pinterest/');
|
||||
const url = new URL(requested, 'https://test.botsu.net');
|
||||
assert.equal(url.pathname, '/presence/moodboard/pinterest');
|
||||
assert.equal(url.searchParams.get('url'), 'https://www.pinterest.com/pinterest/');
|
||||
assert.equal(pins[0]?.title, 'Architecture');
|
||||
});
|
||||
|
||||
test('pins a normalized search result as a shared image card', async () => {
|
||||
let body: unknown;
|
||||
const controller = createMoodboardController({
|
||||
getOpenIdToken: async () => token,
|
||||
fetchImpl: async (_input, init) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({
|
||||
card: {
|
||||
version: 1,
|
||||
id: 'mood_12345678',
|
||||
creatorId: '@alice:botsu.net',
|
||||
creatorName: 'Alice',
|
||||
createdAt: 123,
|
||||
...body as object,
|
||||
},
|
||||
}), { status: 201 });
|
||||
},
|
||||
});
|
||||
|
||||
const card = await controller.create({
|
||||
kind: 'image',
|
||||
title: 'Béton',
|
||||
url: 'https://img.example.org/beton.jpg',
|
||||
sourceUrl: 'https://example.org/article',
|
||||
});
|
||||
assert.equal((body as { kind: string }).kind, 'image');
|
||||
assert.equal(card.title, 'Béton');
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
buildMoodboardCardDraft,
|
||||
parseMoodboardCard,
|
||||
parseMoodboardPage,
|
||||
parseSearxngImageResults,
|
||||
type MoodboardCard,
|
||||
type MoodboardCardDraft,
|
||||
type MoodboardPage,
|
||||
type MoodboardSearchResult,
|
||||
} from './model.ts';
|
||||
|
||||
type OpenIdToken = { access_token: string };
|
||||
|
||||
type MoodboardControllerOptions = {
|
||||
getOpenIdToken: () => Promise<OpenIdToken>;
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
|
||||
export type MoodboardController = {
|
||||
list: (options?: { cursor?: string; query?: string }) => Promise<MoodboardPage>;
|
||||
getPinterest: (sourceUrl: string) => Promise<MoodboardSearchResult[]>;
|
||||
search: (query: string) => Promise<MoodboardSearchResult[]>;
|
||||
create: (draft: MoodboardCardDraft) => Promise<MoodboardCard>;
|
||||
};
|
||||
|
||||
const BASE = '/presence/moodboard';
|
||||
|
||||
const parsePinterestPins = (input: unknown): MoodboardSearchResult[] => {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return [];
|
||||
const pins = (input as { pins?: unknown }).pins;
|
||||
if (!Array.isArray(pins)) return [];
|
||||
return pins.flatMap((pin): MoodboardSearchResult[] => {
|
||||
if (typeof pin !== 'object' || pin === null || Array.isArray(pin)) return [];
|
||||
const value = pin as Record<string, unknown>;
|
||||
if (
|
||||
typeof value.title !== 'string' ||
|
||||
typeof value.sourceUrl !== 'string' ||
|
||||
typeof value.imageUrl !== 'string' ||
|
||||
typeof value.thumbnailUrl !== 'string'
|
||||
) return [];
|
||||
try {
|
||||
const source = new URL(value.sourceUrl);
|
||||
const image = new URL(value.imageUrl);
|
||||
const thumbnail = new URL(value.thumbnailUrl);
|
||||
if (
|
||||
source.protocol !== 'https:' ||
|
||||
source.hostname !== 'www.pinterest.com' ||
|
||||
image.protocol !== 'https:' ||
|
||||
image.hostname !== 'i.pinimg.com' ||
|
||||
thumbnail.protocol !== 'https:' ||
|
||||
thumbnail.hostname !== 'i.pinimg.com'
|
||||
) return [];
|
||||
return [{
|
||||
title: value.title.trim().slice(0, 240) || 'Pin Pinterest',
|
||||
sourceUrl: source.toString(),
|
||||
imageUrl: image.toString(),
|
||||
thumbnailUrl: thumbnail.toString(),
|
||||
}];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createMoodboardController = (options: MoodboardControllerOptions): MoodboardController => {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
const request = async (path: string, init: RequestInit = {}): Promise<unknown> => {
|
||||
const token = await options.getOpenIdToken();
|
||||
const response = await fetchImpl(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token.access_token}`,
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(payload.error ?? `Moodboard API ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<unknown>;
|
||||
};
|
||||
|
||||
return {
|
||||
async list(listOptions = {}) {
|
||||
const params = new URLSearchParams({ limit: '24' });
|
||||
if (listOptions.cursor) params.set('cursor', listOptions.cursor);
|
||||
if (listOptions.query?.trim()) params.set('q', listOptions.query.trim());
|
||||
return parseMoodboardPage(await request(`/cards?${params}`));
|
||||
},
|
||||
async getPinterest(sourceUrl) {
|
||||
const params = new URLSearchParams({ url: sourceUrl.trim() });
|
||||
return parsePinterestPins(await request(`/pinterest?${params}`));
|
||||
},
|
||||
async search(query) {
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
return parseSearxngImageResults(await request(`/search?${params}`));
|
||||
},
|
||||
async create(draft) {
|
||||
const normalized = buildMoodboardCardDraft(draft as unknown as Record<string, unknown>);
|
||||
const payload = await request('/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(normalized),
|
||||
});
|
||||
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
||||
throw new TypeError('Réponse Moodboard invalide');
|
||||
}
|
||||
return parseMoodboardCard((payload as { card?: unknown }).card);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BotsuMoodboard } from './BotsuMoodboard';
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildMoodboardCardDraft,
|
||||
parseMoodboardPage,
|
||||
parseSearxngImageResults,
|
||||
} from './model.ts';
|
||||
|
||||
test('normalizes relevant SearXNG image results and rejects unsafe URLs', () => {
|
||||
const results = parseSearxngImageResults({
|
||||
results: [
|
||||
{
|
||||
title: 'Architecture brutaliste',
|
||||
url: 'https://example.org/article',
|
||||
img_src: 'https://images.example.org/brutalisme.jpg',
|
||||
thumbnail_src: 'https://images.example.org/thumb.jpg',
|
||||
content: 'Béton, lignes et lumière.',
|
||||
},
|
||||
{ title: 'Javascript', url: 'javascript:alert(1)', img_src: 'https://example.org/x.jpg' },
|
||||
{ title: 'Sans image', url: 'https://example.org/no-image' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{
|
||||
title: 'Architecture brutaliste',
|
||||
sourceUrl: 'https://example.org/article',
|
||||
imageUrl: 'https://images.example.org/brutalisme.jpg',
|
||||
thumbnailUrl: 'https://images.example.org/thumb.jpg',
|
||||
description: 'Béton, lignes et lumière.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds bounded drafts for every Moodboard content type', () => {
|
||||
assert.deepEqual(buildMoodboardCardDraft({ kind: 'color', title: 'Rouge', color: '#ff3300' }), {
|
||||
kind: 'color',
|
||||
title: 'Rouge',
|
||||
color: '#FF3300',
|
||||
});
|
||||
assert.deepEqual(
|
||||
buildMoodboardCardDraft({ kind: 'image', title: 'Mer', url: 'https://example.org/mer.jpg' }),
|
||||
{ kind: 'image', title: 'Mer', url: 'https://example.org/mer.jpg' }
|
||||
);
|
||||
assert.throws(() => buildMoodboardCardDraft({ kind: 'color', title: 'Non', color: 'red' }), /couleur/i);
|
||||
assert.throws(
|
||||
() => buildMoodboardCardDraft({ kind: 'link', title: 'Non', url: 'javascript:alert(1)' }),
|
||||
/lien/i
|
||||
);
|
||||
});
|
||||
|
||||
test('parses a paginated shared feed without trusting unknown fields', () => {
|
||||
const page = parseMoodboardPage({
|
||||
cards: [
|
||||
{
|
||||
version: 1,
|
||||
id: 'mood_12345678',
|
||||
kind: 'text',
|
||||
title: 'Ambiance',
|
||||
text: 'Calme et minéral',
|
||||
creatorId: '@alice:botsu.net',
|
||||
creatorName: 'Alice',
|
||||
createdAt: 123,
|
||||
},
|
||||
],
|
||||
nextCursor: '1',
|
||||
});
|
||||
|
||||
assert.equal(page.cards[0]?.title, 'Ambiance');
|
||||
assert.equal(page.nextCursor, '1');
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
export type MoodboardCardKind = 'image' | 'color' | 'text' | 'file' | 'link';
|
||||
|
||||
export type MoodboardCardDraft =
|
||||
| { kind: 'image'; title: string; url: string; sourceUrl?: string }
|
||||
| { kind: 'color'; title: string; color: string }
|
||||
| { kind: 'text'; title: string; text: string }
|
||||
| { kind: 'file'; title: string; fileName: string; mimeType: string; dataUrl: string }
|
||||
| { kind: 'link'; title: string; url: string };
|
||||
|
||||
export type MoodboardCard = MoodboardCardDraft & {
|
||||
version: 1;
|
||||
id: string;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type MoodboardPage = { cards: MoodboardCard[]; nextCursor?: string };
|
||||
|
||||
export type MoodboardSearchResult = {
|
||||
title: string;
|
||||
sourceUrl: string;
|
||||
imageUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const normalizeText = (value: unknown, label: string, maxLength: number): string => {
|
||||
if (typeof value !== 'string') throw new TypeError(`${label} invalide`);
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized || normalized.length > maxLength) throw new TypeError(`${label} invalide`);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeHttpUrl = (value: unknown, label: string): string => {
|
||||
const input = normalizeText(value, label, 2048);
|
||||
const url = new URL(input);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new TypeError(`${label} invalide`);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const optionalHttpUrl = (value: unknown, label: string): string | undefined => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
return normalizeHttpUrl(value, label);
|
||||
};
|
||||
|
||||
const parseDataUrl = (value: unknown): string => {
|
||||
if (typeof value !== 'string' || value.length > 2_800_000) throw new TypeError('Fichier invalide');
|
||||
if (!/^data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/]+={0,2}$/i.test(value)) {
|
||||
throw new TypeError('Fichier invalide');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const buildMoodboardCardDraft = (input: Record<string, unknown>): MoodboardCardDraft => {
|
||||
const title = normalizeText(input.title, 'Titre', 160);
|
||||
if (input.kind === 'image') {
|
||||
const sourceUrl = optionalHttpUrl(input.sourceUrl, 'Lien source');
|
||||
const draft: MoodboardCardDraft = { kind: 'image', title, url: normalizeHttpUrl(input.url, 'Lien image') };
|
||||
return sourceUrl ? { ...draft, sourceUrl } : draft;
|
||||
}
|
||||
if (input.kind === 'color') {
|
||||
if (typeof input.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(input.color)) {
|
||||
throw new TypeError('Couleur invalide');
|
||||
}
|
||||
return { kind: 'color', title, color: input.color.toUpperCase() };
|
||||
}
|
||||
if (input.kind === 'text') return { kind: 'text', title, text: normalizeText(input.text, 'Texte', 5000) };
|
||||
if (input.kind === 'file') {
|
||||
return {
|
||||
kind: 'file',
|
||||
title,
|
||||
fileName: normalizeText(input.fileName, 'Nom du fichier', 180),
|
||||
mimeType: normalizeText(input.mimeType, 'Type du fichier', 120),
|
||||
dataUrl: parseDataUrl(input.dataUrl),
|
||||
};
|
||||
}
|
||||
if (input.kind === 'link') return { kind: 'link', title, url: normalizeHttpUrl(input.url, 'Lien') };
|
||||
throw new TypeError('Type de carte invalide');
|
||||
};
|
||||
|
||||
export const parseMoodboardCard = (input: unknown): MoodboardCard => {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) throw new TypeError('Carte invalide');
|
||||
const record = input as Record<string, unknown>;
|
||||
if (record.version !== 1) throw new TypeError('Version de carte invalide');
|
||||
const draft = buildMoodboardCardDraft(record);
|
||||
const id = normalizeText(record.id, 'Identifiant', 80);
|
||||
if (!/^mood_[a-z0-9_-]{8,64}$/i.test(id)) throw new TypeError('Identifiant invalide');
|
||||
const creatorId = normalizeText(record.creatorId, 'Auteur', 255);
|
||||
const creatorName = normalizeText(record.creatorName, 'Nom auteur', 160);
|
||||
if (typeof record.createdAt !== 'number' || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
|
||||
throw new TypeError('Date invalide');
|
||||
}
|
||||
return { version: 1, id, creatorId, creatorName, createdAt: record.createdAt, ...draft };
|
||||
};
|
||||
|
||||
export const parseMoodboardPage = (input: unknown): MoodboardPage => {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) throw new TypeError('Page invalide');
|
||||
const record = input as Record<string, unknown>;
|
||||
if (!Array.isArray(record.cards)) throw new TypeError('Cartes invalides');
|
||||
const cards = record.cards.map(parseMoodboardCard);
|
||||
const nextCursor = record.nextCursor === undefined ? undefined : normalizeText(record.nextCursor, 'Curseur', 32);
|
||||
return nextCursor ? { cards, nextCursor } : { cards };
|
||||
};
|
||||
|
||||
export const parseSearxngImageResults = (input: unknown): MoodboardSearchResult[] => {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return [];
|
||||
const rawResults = (input as { results?: unknown }).results;
|
||||
if (!Array.isArray(rawResults)) return [];
|
||||
const results: MoodboardSearchResult[] = [];
|
||||
rawResults.slice(0, 60).forEach((item) => {
|
||||
if (typeof item !== 'object' || item === null || Array.isArray(item)) return;
|
||||
const record = item as Record<string, unknown>;
|
||||
try {
|
||||
const title = normalizeText(record.title, 'Titre', 240);
|
||||
const sourceUrl = normalizeHttpUrl(record.url, 'Lien source');
|
||||
const imageUrl = normalizeHttpUrl(record.img_src ?? record.thumbnail_src, 'Lien image');
|
||||
const thumbnailUrl = optionalHttpUrl(record.thumbnail_src, 'Miniature');
|
||||
const description = typeof record.content === 'string' && record.content.trim()
|
||||
? normalizeText(record.content, 'Description', 600)
|
||||
: undefined;
|
||||
results.push({
|
||||
title,
|
||||
sourceUrl,
|
||||
imageUrl,
|
||||
...(thumbnailUrl ? { thumbnailUrl } : {}),
|
||||
...(description ? { description } : {}),
|
||||
});
|
||||
} catch {
|
||||
// A malformed third-party result must not break the whole feed.
|
||||
}
|
||||
});
|
||||
return results;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const pageUrl = new URL('./BotsuMoodboard.tsx', import.meta.url);
|
||||
const cssUrl = new URL('./moodboard.css', import.meta.url);
|
||||
const routerUrl = new URL('../../app/pages/Router.tsx', import.meta.url);
|
||||
const pathsUrl = new URL('../../app/pages/paths.ts', import.meta.url);
|
||||
const navUrl = new URL('../shell/BotsuNav.tsx', import.meta.url);
|
||||
|
||||
test('Moodboard is a native BOTSU application with search and shared creation controls', async () => {
|
||||
const [page, css, router, paths, nav] = await Promise.all([
|
||||
readFile(pageUrl, 'utf8'),
|
||||
readFile(cssUrl, 'utf8'),
|
||||
readFile(routerUrl, 'utf8'),
|
||||
readFile(pathsUrl, 'utf8'),
|
||||
readFile(navUrl, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(paths, /_BOTSU_MOODBOARD_PATH = 'moodboard\/'/);
|
||||
assert.match(router, /<BotsuMoodboard \/>/);
|
||||
assert.match(nav, /'moodboard'/);
|
||||
assert.match(page, /aria-label="Rechercher dans le Moodboard et sur BOTSU Search"/);
|
||||
assert.match(page, /aria-label="Profil ou tableau Pinterest"/);
|
||||
assert.match(page, /Charger Pinterest/);
|
||||
assert.match(page, /controller\.getPinterest/);
|
||||
assert.match(page, /Ajouter/);
|
||||
assert.match(page, /Image|Couleur|Texte|Fichier|Lien/);
|
||||
assert.match(page, /IntersectionObserver/);
|
||||
assert.match(css, /column-count:/);
|
||||
assert.match(css, /break-inside:\s*avoid/);
|
||||
assert.match(css, /var\(--botsu-color-/);
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
.botsu-moodboard {
|
||||
min-height: 100%;
|
||||
padding: clamp(16px, 3vw, 36px);
|
||||
color: var(--botsu-color-text);
|
||||
background: var(--botsu-color-canvas);
|
||||
}
|
||||
|
||||
.botsu-moodboard-toolbar {
|
||||
position: sticky;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
max-width: 1180px;
|
||||
padding: 10px 0 18px;
|
||||
margin: 0 auto;
|
||||
background: color-mix(in srgb, var(--botsu-color-canvas) 92%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.botsu-moodboard-search {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 13px;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
background: var(--botsu-color-surface);
|
||||
}
|
||||
|
||||
.botsu-moodboard-search:focus-within {
|
||||
border-color: var(--botsu-color-accent);
|
||||
box-shadow: 0 0 0 2px var(--botsu-color-focus);
|
||||
}
|
||||
|
||||
.botsu-moodboard-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 12px 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.botsu-moodboard button,
|
||||
.botsu-moodboard input,
|
||||
.botsu-moodboard textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.botsu-moodboard button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.botsu-moodboard-search button,
|
||||
.botsu-moodboard-add,
|
||||
.botsu-moodboard-pinterest-source button,
|
||||
.botsu-moodboard-composer button,
|
||||
.botsu-moodboard-card-meta button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
background: var(--botsu-color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-moodboard-search button {
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.botsu-moodboard-add {
|
||||
flex: 0 0 auto;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-add:hover,
|
||||
.botsu-moodboard-composer button:hover,
|
||||
.botsu-moodboard-card-meta button:hover {
|
||||
border-color: var(--botsu-color-accent);
|
||||
}
|
||||
|
||||
.botsu-moodboard-pinterest-source {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 1180px;
|
||||
margin: -8px auto 18px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-pinterest-source input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
outline: 0;
|
||||
color: inherit;
|
||||
background: var(--botsu-color-surface);
|
||||
}
|
||||
|
||||
.botsu-moodboard-pinterest-source input:focus {
|
||||
border-color: var(--botsu-color-accent);
|
||||
box-shadow: 0 0 0 2px var(--botsu-color-focus);
|
||||
}
|
||||
|
||||
.botsu-moodboard-pinterest-source button {
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.7fr) minmax(220px, 1fr) auto;
|
||||
gap: 10px;
|
||||
max-width: 1180px;
|
||||
padding: 14px;
|
||||
margin: 0 auto 20px;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
background: var(--botsu-color-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-moodboard-kinds {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.botsu-moodboard-kinds button,
|
||||
.botsu-moodboard-composer > button {
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-kinds button[aria-pressed='true'],
|
||||
.botsu-moodboard-composer > button {
|
||||
border-color: var(--botsu-color-accent);
|
||||
color: var(--botsu-color-on-accent);
|
||||
background: var(--botsu-color-accent);
|
||||
}
|
||||
|
||||
.botsu-moodboard-composer > input,
|
||||
.botsu-moodboard-composer > textarea {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
outline: 0;
|
||||
color: inherit;
|
||||
background: var(--botsu-color-surface);
|
||||
}
|
||||
|
||||
.botsu-moodboard-composer textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.botsu-moodboard-context,
|
||||
.botsu-moodboard-error,
|
||||
.botsu-moodboard-empty,
|
||||
.botsu-moodboard-loading {
|
||||
max-width: 1180px;
|
||||
margin: 8px auto 16px;
|
||||
color: var(--botsu-color-text-muted);
|
||||
}
|
||||
|
||||
.botsu-moodboard-error {
|
||||
color: var(--botsu-color-text);
|
||||
}
|
||||
|
||||
.botsu-moodboard-feed {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
column-count: 4;
|
||||
column-gap: 14px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-card {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
overflow: hidden;
|
||||
break-inside: avoid;
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
background: var(--botsu-color-surface);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.botsu-moodboard-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 120px;
|
||||
max-height: 520px;
|
||||
object-fit: cover;
|
||||
background: var(--botsu-color-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-moodboard-card a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.botsu-moodboard-color {
|
||||
display: grid;
|
||||
min-height: 190px;
|
||||
padding: 14px;
|
||||
place-items: end start;
|
||||
}
|
||||
|
||||
.botsu-moodboard-color span {
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--botsu-radius);
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 58%);
|
||||
font: 600 0.78rem/1 var(--botsu-font-family);
|
||||
}
|
||||
|
||||
.botsu-moodboard-note {
|
||||
padding: 24px 18px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: clamp(1rem, 1.7vw, 1.3rem);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.botsu-moodboard-link,
|
||||
.botsu-moodboard-file {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
padding: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--botsu-color-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-moodboard-card-meta {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 11px 12px 12px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-card-meta strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.botsu-moodboard-card-meta small {
|
||||
overflow: hidden;
|
||||
color: var(--botsu-color-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-moodboard-card-meta button {
|
||||
justify-self: start;
|
||||
padding: 6px 9px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.botsu-moodboard-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.botsu-moodboard-feed { column-count: 3; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.botsu-moodboard { padding: 10px; }
|
||||
.botsu-moodboard-feed { column-count: 2; column-gap: 10px; }
|
||||
.botsu-moodboard-composer { grid-template-columns: 1fr; }
|
||||
.botsu-moodboard-composer > * { grid-column: 1; }
|
||||
.botsu-moodboard-add { padding-inline: 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.botsu-moodboard-feed { column-count: 1; }
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||
import { useMapViewportBridge } from './MapViewportContext';
|
||||
import { geocodeAddress, type GeocoderResult } from './geocoder';
|
||||
import './openstreetmap.css';
|
||||
|
||||
const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
|
||||
const DEFAULT_VIEWPORT = { lat: 48.8566, lng: 2.3522, zoom: 5 };
|
||||
|
||||
type LeafletMap = {
|
||||
setView: (latlng: [number, number], zoom: number) => void;
|
||||
getCenter: () => { lat: number; lng: number };
|
||||
getZoom: () => number;
|
||||
on: (event: string, fn: () => void) => void;
|
||||
once: (event: string, fn: () => void) => void;
|
||||
off: (event: string, fn: () => void) => void;
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
type LeafletTileLayer = {
|
||||
addTo: (map: LeafletMap) => LeafletTileLayer;
|
||||
};
|
||||
|
||||
type LeafletModule = {
|
||||
map: (el: HTMLElement, options?: { center?: [number, number]; zoom?: number; attributionControl?: boolean }) => LeafletMap;
|
||||
tileLayer: (
|
||||
url: string,
|
||||
options?: { attribution?: string; crossOrigin?: boolean; maxZoom?: number }
|
||||
) => LeafletTileLayer;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
L?: LeafletModule;
|
||||
}
|
||||
}
|
||||
|
||||
const loadLeaflet = (): Promise<LeafletModule> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (window.L) {
|
||||
resolve(window.L);
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.onload = () => {
|
||||
if (window.L) resolve(window.L);
|
||||
else reject(new Error('Leaflet failed to load'));
|
||||
};
|
||||
script.onerror = () => reject(new Error('Leaflet script failed'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export function BotsuOpenStreetMap() {
|
||||
const location = useLocation();
|
||||
const mapViewportBridge = useMapViewportBridge();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const syncingRef = useRef(false);
|
||||
const pathRef = useRef(location.pathname);
|
||||
pathRef.current = location.pathname;
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<GeocoderResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
setSearchError('');
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const found = await geocodeAddress(trimmed);
|
||||
setResults(found);
|
||||
setShowResults(true);
|
||||
} catch {
|
||||
setSearchError('Recherche impossible. Vérifie ta connexion.');
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const goToResult = useCallback((result: GeocoderResult) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
map.setView([result.lat, result.lng], 15);
|
||||
setQuery(result.label);
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
setSearchError('');
|
||||
}, []);
|
||||
|
||||
const handleResultKeyDown = (event: React.KeyboardEvent, result: GeocoderResult) => {
|
||||
if (event.key === 'Enter') goToResult(result);
|
||||
};
|
||||
|
||||
// Load Leaflet
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadLeaflet()
|
||||
.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Erreur de chargement');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create map
|
||||
useEffect(() => {
|
||||
if (!ready || !containerRef.current || !window.L) return;
|
||||
|
||||
const map = window.L.map(containerRef.current, {
|
||||
center: [DEFAULT_VIEWPORT.lat, DEFAULT_VIEWPORT.lng],
|
||||
zoom: DEFAULT_VIEWPORT.zoom,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
window.L.tileLayer(OSM_TILE_URL, {
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
crossOrigin: true,
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, [ready]);
|
||||
|
||||
// Publish viewport changes
|
||||
const publishViewport = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || syncingRef.current) return;
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
mapViewportBridge.publish(
|
||||
{ lat: center.lat, lng: center.lng, zoom },
|
||||
pathRef.current
|
||||
);
|
||||
}, [mapViewportBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
map.on('moveend', publishViewport);
|
||||
map.on('zoomend', publishViewport);
|
||||
return () => {
|
||||
map.off('moveend', publishViewport);
|
||||
map.off('zoomend', publishViewport);
|
||||
};
|
||||
}, [ready, publishViewport]);
|
||||
|
||||
// Receive remote viewport updates
|
||||
useEffect(() => {
|
||||
return mapViewportBridge.activate((viewport) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
syncingRef.current = true;
|
||||
map.setView([viewport.lat, viewport.lng], viewport.zoom);
|
||||
// Reset syncing flag only after the animation actually finishes
|
||||
map.once('moveend', () => {
|
||||
syncingRef.current = false;
|
||||
});
|
||||
});
|
||||
}, [mapViewportBridge]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="botsu-openstreetmap" aria-label="OpenStreetMap">
|
||||
<div className="botsu-openstreetmap-error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="botsu-openstreetmap" aria-label="OpenStreetMap">
|
||||
<div className="botsu-openstreetmap-search">
|
||||
<MagnifyingGlassIcon
|
||||
className="botsu-openstreetmap-search-icon"
|
||||
size={18}
|
||||
weight="bold"
|
||||
/>
|
||||
<input
|
||||
aria-label="Rechercher une adresse ou un lieu"
|
||||
className="botsu-openstreetmap-search-input"
|
||||
maxLength={120}
|
||||
placeholder="Rechercher une adresse ou un lieu…"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => handleQueryChange(event.target.value)}
|
||||
/>
|
||||
{searching && (
|
||||
<span className="botsu-openstreetmap-searching" aria-live="polite">
|
||||
Recherche…
|
||||
</span>
|
||||
)}
|
||||
{showResults && results.length > 0 && (
|
||||
<ul className="botsu-openstreetmap-results" aria-label="Résultats de recherche">
|
||||
{results.map((result) => (
|
||||
<li key={result.placeId}>
|
||||
<button
|
||||
type="button"
|
||||
className="botsu-openstreetmap-result"
|
||||
onClick={() => goToResult(result)}
|
||||
onKeyDown={(event) => handleResultKeyDown(event, result)}
|
||||
>
|
||||
{result.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{searchError && (
|
||||
<p className="botsu-openstreetmap-search-error" role="alert">
|
||||
{searchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div ref={containerRef} className="botsu-openstreetmap-container" />
|
||||
{!ready && (
|
||||
<div className="botsu-openstreetmap-loading">
|
||||
<p>Chargement de la carte…</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { createContext, useContext, useRef } from 'react';
|
||||
import { createMapViewportBridge, type MapViewportBridge } from './map-viewport-bridge';
|
||||
|
||||
const MapViewportContext = createContext<MapViewportBridge | undefined>(undefined);
|
||||
|
||||
const noopBridge: MapViewportBridge = {
|
||||
activate: () => () => undefined,
|
||||
attach: () => () => undefined,
|
||||
receive: () => undefined,
|
||||
publish: () => false,
|
||||
};
|
||||
|
||||
export function MapViewportProvider({ children }: React.PropsWithChildren) {
|
||||
const bridgeRef = useRef<MapViewportBridge>();
|
||||
if (!bridgeRef.current) bridgeRef.current = createMapViewportBridge();
|
||||
return (
|
||||
<MapViewportContext.Provider value={bridgeRef.current}>{children}</MapViewportContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useMapViewportBridge = (): MapViewportBridge => {
|
||||
const bridge = useContext(MapViewportContext);
|
||||
return bridge ?? noopBridge;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { geocodeAddress, parseGeocoderResults } from './geocoder.ts';
|
||||
|
||||
test('parses Nominatim results into typed coordinates', () => {
|
||||
const response = [
|
||||
{
|
||||
place_id: 123,
|
||||
lat: '48.8566',
|
||||
lon: '2.3522',
|
||||
display_name: 'Paris, France',
|
||||
},
|
||||
{
|
||||
place_id: 456,
|
||||
lat: '45.7640',
|
||||
lon: '4.8357',
|
||||
display_name: 'Lyon, France',
|
||||
},
|
||||
];
|
||||
|
||||
const results = parseGeocoderResults(response);
|
||||
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].placeId, 123);
|
||||
assert.equal(results[0].lat, 48.8566);
|
||||
assert.equal(results[0].lng, 2.3522);
|
||||
assert.equal(results[0].label, 'Paris, France');
|
||||
});
|
||||
|
||||
test('parseGeocoderResults rejects non-array responses', () => {
|
||||
assert.throws(() => parseGeocoderResults(null), /Nominatim/);
|
||||
assert.throws(() => parseGeocoderResults({}), /Nominatim/);
|
||||
});
|
||||
|
||||
test('parseGeocoderResults skips malformed entries and clamps out-of-range', () => {
|
||||
const results = parseGeocoderResults([
|
||||
{ place_id: 1, display_name: 'Pas de coords' },
|
||||
{ place_id: 2, lat: 'x', lon: '2', display_name: 'Lat invalide' },
|
||||
{ place_id: 3, lat: '90', lon: '181', display_name: 'Hors bornes (clampé)' },
|
||||
{ place_id: 4, lat: '10', lon: '20', display_name: 'Valide' },
|
||||
]);
|
||||
|
||||
// Malformed entries are skipped; out-of-range coords are clamped (kept)
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].placeId, 3);
|
||||
assert.equal(results[0].lat, 90);
|
||||
assert.equal(results[0].lng, 180);
|
||||
assert.equal(results[1].placeId, 4);
|
||||
assert.equal(
|
||||
parseGeocoderResults([{ place_id: 5, lat: '95', lon: '-190', display_name: 'x' }])[0].lat,
|
||||
90
|
||||
);
|
||||
});
|
||||
|
||||
test('geocodeAddress builds a Nominatim request and parses results', async () => {
|
||||
const calls: { url: string; headers: Record<string, string> }[] = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, headers: (init?.headers as Record<string, string>) ?? {} });
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ place_id: 1, lat: '48.85', lon: '2.35', display_name: 'Paris, France' },
|
||||
]),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
const results = await geocodeAddress('Paris', fakeFetch);
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].label, 'Paris, France');
|
||||
assert.ok(calls[0].url.includes('/search?'));
|
||||
assert.ok(calls[0].url.includes('q=Paris'));
|
||||
assert.ok(calls[0].url.includes('format=jsonv2'));
|
||||
});
|
||||
|
||||
test('geocodeAddress returns empty for blank or overlong queries', async () => {
|
||||
const fakeFetch: typeof fetch = async () => {
|
||||
throw new Error('should not be called');
|
||||
};
|
||||
assert.deepEqual(await geocodeAddress(' ', fakeFetch), []);
|
||||
assert.deepEqual(await geocodeAddress('x'.repeat(121), fakeFetch), []);
|
||||
});
|
||||
|
||||
test('geocodeAddress throws on non-ok responses', async () => {
|
||||
const fakeFetch: typeof fetch = async () => new Response('', { status: 500 });
|
||||
await assert.rejects(geocodeAddress('Paris', fakeFetch), /Geocoding failed/);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export type GeocoderResult = {
|
||||
placeId: number;
|
||||
label: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
const MAXIMUM_SEARCH_LENGTH = 120;
|
||||
const MAXIMUM_RESULTS = 8;
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const parseCoordinate = (value: unknown): number | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseGeocoderResults = (value: unknown): GeocoderResult[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError('Invalid Nominatim response');
|
||||
}
|
||||
const results: GeocoderResult[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
const lat = parseCoordinate(record.lat);
|
||||
const lng = parseCoordinate(record.lon);
|
||||
if (lat === undefined || lng === undefined) continue;
|
||||
if (typeof record.display_name !== 'string' || record.display_name.length < 1) continue;
|
||||
const placeId = typeof record.place_id === 'number' ? record.place_id : -1;
|
||||
results.push({
|
||||
placeId,
|
||||
label: record.display_name,
|
||||
lat: clamp(lat, -90, 90),
|
||||
lng: clamp(lng, -180, 180),
|
||||
});
|
||||
if (results.length >= MAXIMUM_RESULTS) break;
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
export const geocodeAddress = async (
|
||||
query: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<GeocoderResult[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || trimmed.length > MAXIMUM_SEARCH_LENGTH) return [];
|
||||
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', trimmed);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('limit', String(MAXIMUM_RESULTS));
|
||||
url.searchParams.set('addressdetails', '0');
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'botsu-app/1.0 (https://botsu.net)',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Geocoding failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
return parseGeocoderResults(data);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PresenceMapViewport, PresenceRemoteMapViewport } from '@botsu/protocol';
|
||||
|
||||
export type MapViewportState = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
type MapViewportSender = (message: PresenceMapViewport) => void;
|
||||
|
||||
export function createMapViewportBridge() {
|
||||
let sender: MapViewportSender | undefined;
|
||||
let senderGeneration: object | undefined;
|
||||
const listeners = new Set<(viewport: MapViewportState) => void>();
|
||||
|
||||
return {
|
||||
activate(listener: (viewport: MapViewportState) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
|
||||
attach(nextSender: MapViewportSender): () => void {
|
||||
const generation = {};
|
||||
sender = nextSender;
|
||||
senderGeneration = generation;
|
||||
return () => {
|
||||
if (senderGeneration !== generation) return;
|
||||
sender = undefined;
|
||||
senderGeneration = undefined;
|
||||
};
|
||||
},
|
||||
|
||||
receive(message: PresenceRemoteMapViewport): void {
|
||||
listeners.forEach((listener) =>
|
||||
listener({ lat: message.lat, lng: message.lng, zoom: message.zoom })
|
||||
);
|
||||
},
|
||||
|
||||
publish(viewport: MapViewportState, path: string): boolean {
|
||||
if (!sender) return false;
|
||||
sender({
|
||||
type: 'presence.map.viewport',
|
||||
protocolVersion: 1,
|
||||
path,
|
||||
lat: viewport.lat,
|
||||
lng: viewport.lng,
|
||||
zoom: viewport.zoom,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MapViewportBridge = ReturnType<typeof createMapViewportBridge>;
|
||||
@@ -0,0 +1,126 @@
|
||||
.botsu-openstreetmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-container .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--botsu-color-surface);
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.botsu-openstreetmap-search {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
background: var(--botsu-color-surface);
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-search-icon {
|
||||
color: var(--botsu-color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--botsu-color-text);
|
||||
font-family: var(--botsu-font-family);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-search-input::placeholder {
|
||||
color: var(--botsu-color-text-muted);
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-searching {
|
||||
font-size: 0.8rem;
|
||||
color: var(--botsu-color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 1001;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: var(--botsu-color-surface);
|
||||
border: var(--botsu-border-width) solid var(--botsu-color-border);
|
||||
border-radius: var(--botsu-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-results li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-result {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--botsu-color-text);
|
||||
font-family: var(--botsu-font-family);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
border-radius: var(--botsu-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-result:hover {
|
||||
background: var(--botsu-color-surface-raised);
|
||||
color: var(--botsu-color-accent);
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-search-error {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 1001;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--botsu-radius);
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-loading,
|
||||
.botsu-openstreetmap-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--botsu-color-text-muted);
|
||||
background: var(--botsu-color-canvas);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.botsu-openstreetmap-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { setRemoteCursorPointers } from './remote-cursor-pointers';
|
||||
import { getPresenceJoinDestination } from './join-destination';
|
||||
import { usePixelCanvasBridge } from '../start/PixelCanvasContext';
|
||||
import { useDocumentSyncBridge } from '../documents/DocumentSyncContext';
|
||||
import { useMapViewportBridge } from '../openstreetmap/MapViewportContext';
|
||||
|
||||
const getInitials = (displayName: string): string =>
|
||||
displayName
|
||||
@@ -55,6 +56,7 @@ export function BotsuPresence() {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const pixelCanvasBridge = usePixelCanvasBridge();
|
||||
const documentSyncBridge = useDocumentSyncBridge();
|
||||
const mapViewportBridge = useMapViewportBridge();
|
||||
const presenceRef = useRef<HTMLElement>(null);
|
||||
const [state, setState] = useState<BotsuPresenceState>(initialPresenceState);
|
||||
const [remoteCursors, setRemoteCursors] = useState<RemoteCursorState[]>([]);
|
||||
@@ -84,6 +86,7 @@ export function BotsuPresence() {
|
||||
let reconnectTimer: number | undefined;
|
||||
let detachPixelCanvasSender: (() => void) | undefined;
|
||||
let detachDocumentSender: (() => void) | undefined;
|
||||
let detachMapViewportSender: (() => void) | undefined;
|
||||
const connectionGeneration = createPresenceConnectionGeneration();
|
||||
const publishLocation = createPresenceLocationPublisher({
|
||||
initialPath: currentPathRef.current,
|
||||
@@ -166,6 +169,8 @@ export function BotsuPresence() {
|
||||
documentSyncBridge.receive(message);
|
||||
} else if (message.type === 'presence.cursor') {
|
||||
setRemoteCursors((current) => applyRemoteCursor(current, message));
|
||||
} else if (message.type === 'presence.map.viewport') {
|
||||
mapViewportBridge.receive(message);
|
||||
} else {
|
||||
setState((current) => applyPresenceServerMessage(current, message));
|
||||
}
|
||||
@@ -191,6 +196,12 @@ export function BotsuPresence() {
|
||||
currentSocket.send(JSON.stringify(documentMessage));
|
||||
}
|
||||
});
|
||||
detachMapViewportSender?.();
|
||||
detachMapViewportSender = mapViewportBridge.attach((viewportMessage) => {
|
||||
if (joined && currentSocket.readyState === WebSocket.OPEN) {
|
||||
currentSocket.send(JSON.stringify(viewportMessage));
|
||||
}
|
||||
});
|
||||
clearHeartbeat();
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
if (isCurrent() && currentSocket.readyState === WebSocket.OPEN) {
|
||||
@@ -213,6 +224,8 @@ export function BotsuPresence() {
|
||||
detachPixelCanvasSender = undefined;
|
||||
detachDocumentSender?.();
|
||||
detachDocumentSender = undefined;
|
||||
detachMapViewportSender?.();
|
||||
detachMapViewportSender = undefined;
|
||||
clearHeartbeat();
|
||||
if (stopped) return;
|
||||
setState({ status: 'offline', participants: [] });
|
||||
@@ -231,6 +244,7 @@ export function BotsuPresence() {
|
||||
publishLocation.setJoined(false);
|
||||
detachPixelCanvasSender?.();
|
||||
detachDocumentSender?.();
|
||||
detachMapViewportSender?.();
|
||||
publishLocationRef.current = () => undefined;
|
||||
connectionGeneration.invalidate();
|
||||
clearHeartbeat();
|
||||
@@ -238,7 +252,7 @@ export function BotsuPresence() {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
socket?.close();
|
||||
};
|
||||
}, [documentSyncBridge, mx, pixelCanvasBridge]);
|
||||
}, [documentSyncBridge, mapViewportBridge, mx, pixelCanvasBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
||||
@@ -39,7 +39,7 @@ test('rejects an oversized server frame before parsing JSON', async () => {
|
||||
const message = `${JSON.stringify({
|
||||
type: 'presence.heartbeat.ack',
|
||||
protocolVersion: 1,
|
||||
})}${' '.repeat(256 * 1024)}`;
|
||||
})}${' '.repeat(2 * 1024 * 1024)}`;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -88,3 +88,28 @@ test('accepts document Yjs updates on the presence websocket', async () => {
|
||||
|
||||
assert.equal(message.type, 'document.yjs.update');
|
||||
});
|
||||
|
||||
test('accepts semantic drawing snapshots on the presence websocket', async () => {
|
||||
const { parsePresenceSocketMessage } = await import('./socket-message.ts');
|
||||
const message = parsePresenceSocketMessage(
|
||||
JSON.stringify({
|
||||
type: 'drawing.snapshot',
|
||||
protocolVersion: 1,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revision: 1,
|
||||
elements: [
|
||||
{
|
||||
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
|
||||
kind: 'stroke',
|
||||
mode: 'paint',
|
||||
color: '#123456',
|
||||
size: 5,
|
||||
points: [{ x: 10, y: 20 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
() => undefined
|
||||
);
|
||||
assert.equal(message.type, 'drawing.snapshot');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES,
|
||||
parseDrawingServerMessage,
|
||||
parseDocumentServerMessage,
|
||||
parsePresenceServerMessage,
|
||||
type DrawingServerMessage,
|
||||
type DocumentServerMessage,
|
||||
type PresenceServerMessage,
|
||||
} from '@botsu/protocol';
|
||||
@@ -25,7 +27,7 @@ export const createPresenceWebSocketUrl = (origin: string): string => {
|
||||
export const parsePresenceSocketMessage = (
|
||||
data: unknown,
|
||||
closeSocket: CloseSocket
|
||||
): PresenceServerMessage | DocumentServerMessage => {
|
||||
): PresenceServerMessage | DocumentServerMessage | DrawingServerMessage => {
|
||||
const text = String(data);
|
||||
if (
|
||||
text.length > MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES ||
|
||||
@@ -42,7 +44,11 @@ export const parsePresenceSocketMessage = (
|
||||
try {
|
||||
return parseDocumentServerMessage(parsed);
|
||||
} catch {
|
||||
throw presenceError;
|
||||
try {
|
||||
return parseDrawingServerMessage(parsed);
|
||||
} catch {
|
||||
throw presenceError;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { color, config, vars } from 'folds';
|
||||
import { getApp } from '../apps/catalog';
|
||||
import { buildLaunchPlan } from '../apps/launcher';
|
||||
|
||||
const botsuCinnyThemeVars = {
|
||||
'--botsu-color-canvas': color.Background.Container,
|
||||
'--botsu-color-surface': color.Surface.Container,
|
||||
'--botsu-color-text': color.Background.OnContainer,
|
||||
'--botsu-color-text-muted': color.SurfaceVariant.OnContainer,
|
||||
'--botsu-color-border': color.Surface.ContainerLine,
|
||||
'--botsu-color-accent': color.Primary.Main,
|
||||
'--botsu-color-on-accent': color.Primary.OnMain,
|
||||
'--botsu-radius': config.radii.R300,
|
||||
'--botsu-font-family': 'var(--font-secondary)',
|
||||
};
|
||||
|
||||
export function BotsuEmbed() {
|
||||
const { appId } = useParams<{ appId: string }>();
|
||||
const app = appId ? getApp(appId) : undefined;
|
||||
const plan = app ? buildLaunchPlan(app) : undefined;
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Send theme to Godot iframe
|
||||
useEffect(() => {
|
||||
if (app?.id !== 'godot' || !iframeRef.current) return;
|
||||
const iframe = iframeRef.current;
|
||||
const sendTheme = () => {
|
||||
const computed = getComputedStyle(document.documentElement);
|
||||
const theme: Record<string, string> = {};
|
||||
for (const [key] of Object.entries(botsuCinnyThemeVars)) {
|
||||
theme[key] = computed.getPropertyValue(key).trim();
|
||||
}
|
||||
iframe.contentWindow?.postMessage({ kind: 'botsu-theme', theme }, 'https://test.botsu.net');
|
||||
};
|
||||
iframe.addEventListener('load', sendTheme);
|
||||
return () => iframe.removeEventListener('load', sendTheme);
|
||||
}, [app?.id]);
|
||||
|
||||
if (!app || !plan || plan.kind !== 'embed') {
|
||||
return (
|
||||
@@ -29,6 +59,7 @@ export function BotsuEmbed() {
|
||||
</a>
|
||||
</header>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={plan.src}
|
||||
title={app.label}
|
||||
sandbox={plan.sandbox.join(' ')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { color, config, vars } from 'folds';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { BotsuPresence } from '../presence/BotsuPresence';
|
||||
import { PixelCanvasProvider } from '../start/PixelCanvasContext';
|
||||
import { MapViewportProvider } from '../openstreetmap/MapViewportContext';
|
||||
import '@botsu/ui/styles.css';
|
||||
import './shell.css';
|
||||
|
||||
@@ -12,6 +13,7 @@ const BOTSU_ROUTE_LABELS: Record<string, string> = {
|
||||
botsu: 'botsu',
|
||||
apps: 'apps',
|
||||
documents: 'documents',
|
||||
moodboard: 'pinterest',
|
||||
services: 'services',
|
||||
home: 'home',
|
||||
};
|
||||
@@ -48,20 +50,22 @@ export function BotsuFrame() {
|
||||
return (
|
||||
<div ref={rootRef} className="botsu-theme botsu-shell" style={botsuCinnyThemeVars as CSSProperties}>
|
||||
<PixelCanvasProvider>
|
||||
<div className="botsu-suite-strip">
|
||||
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.to}>
|
||||
{index > 0 && <span aria-hidden="true">/</span>}
|
||||
<Link to={crumb.to}>{crumb.label}</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
<BotsuPresence />
|
||||
</div>
|
||||
<main className="botsu-shell-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<MapViewportProvider>
|
||||
<div className="botsu-suite-strip">
|
||||
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.to}>
|
||||
{index > 0 && <span aria-hidden="true">/</span>}
|
||||
<Link to={crumb.to}>{crumb.label}</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
<BotsuPresence />
|
||||
</div>
|
||||
<main className="botsu-shell-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</MapViewportProvider>
|
||||
</PixelCanvasProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { color, config } from 'folds';
|
||||
|
||||
const botsuCinnyThemeVars = {
|
||||
'--botsu-color-canvas': color.Background.Container,
|
||||
'--botsu-color-surface': color.Surface.Container,
|
||||
'--botsu-color-text': color.Background.OnContainer,
|
||||
'--botsu-color-text-muted': color.SurfaceVariant.OnContainer,
|
||||
'--botsu-color-border': color.Surface.ContainerLine,
|
||||
'--botsu-color-accent': color.Primary.Main,
|
||||
'--botsu-color-on-accent': color.Primary.OnMain,
|
||||
} as const;
|
||||
|
||||
const resolveCssColor = (root: HTMLElement, value: string): string => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = value;
|
||||
probe.style.display = 'none';
|
||||
root.appendChild(probe);
|
||||
const resolved = getComputedStyle(probe).color.trim();
|
||||
probe.remove();
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const readCinnyTheme = (iframe: HTMLIFrameElement): Record<string, string> => {
|
||||
const themeRoot = iframe.closest<HTMLElement>('.botsu-theme') ?? document.body;
|
||||
const theme = Object.fromEntries(
|
||||
Object.entries(botsuCinnyThemeVars).map(([key, value]) => [
|
||||
key,
|
||||
resolveCssColor(themeRoot, value),
|
||||
])
|
||||
);
|
||||
const computed = getComputedStyle(themeRoot);
|
||||
|
||||
return {
|
||||
...theme,
|
||||
'--botsu-radius': computed.getPropertyValue('--botsu-radius').trim() || config.radii.R300,
|
||||
'--botsu-font-family': computed.fontFamily,
|
||||
};
|
||||
};
|
||||
|
||||
export function BotsuGodot() {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const godotOrigin = window.location.origin;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return undefined;
|
||||
|
||||
let frameRequest = 0;
|
||||
const sendTheme = () => {
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ kind: 'botsu-theme', theme: readCinnyTheme(iframe) },
|
||||
godotOrigin
|
||||
);
|
||||
};
|
||||
const scheduleTheme = () => {
|
||||
cancelAnimationFrame(frameRequest);
|
||||
frameRequest = requestAnimationFrame(sendTheme);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
event.origin !== godotOrigin ||
|
||||
event.source !== iframe.contentWindow ||
|
||||
event.data?.kind !== 'botsu-theme-ready'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scheduleTheme();
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(scheduleTheme);
|
||||
const observerOptions: MutationObserverInit = {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
};
|
||||
observer.observe(document.documentElement, observerOptions);
|
||||
observer.observe(document.body, observerOptions);
|
||||
const themeRoot = iframe.closest<HTMLElement>('.botsu-theme');
|
||||
if (themeRoot && themeRoot !== document.body && themeRoot !== document.documentElement) {
|
||||
observer.observe(themeRoot, observerOptions);
|
||||
}
|
||||
|
||||
iframe.addEventListener('load', scheduleTheme);
|
||||
window.addEventListener('message', handleMessage);
|
||||
scheduleTheme();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameRequest);
|
||||
observer.disconnect();
|
||||
iframe.removeEventListener('load', scheduleTheme);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="botsu-godot">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/godot/godot.editor.html"
|
||||
title="Godot"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { botsuApps, getVisibleApps, type BotsuApp, type BotsuRole } from '../apps/catalog';
|
||||
import { botsuApps, getBotsuApplicationApps, type BotsuApp, type BotsuRole } from '../apps/catalog';
|
||||
import { buildLaunchPlan } from '../apps/launcher';
|
||||
import { getBotsuEmbedPath } from '../../app/pages/pathUtils';
|
||||
|
||||
@@ -32,7 +32,7 @@ function AppAction({ app }: { app: BotsuApp }) {
|
||||
}
|
||||
|
||||
export function BotsuLauncher() {
|
||||
const apps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps);
|
||||
const apps = getBotsuApplicationApps(new Set<BotsuRole>(['member']), botsuApps);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="botsu-apps-title">
|
||||
|
||||
@@ -56,7 +56,8 @@ async function saveLinks(
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=32`;
|
||||
const faviconTarget = encodeURIComponent(parsed.origin);
|
||||
return `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${faviconTarget}&size=32`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -267,7 +268,7 @@ export function BotsuLinks() {
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={`Supprimer ${link.label}`}
|
||||
onClick={(evt) => {
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
handleDelete(index);
|
||||
|
||||
@@ -17,11 +17,21 @@ import { botsuServices } from './BotsuServices';
|
||||
import { BotsuLinks } from './BotsuLinks';
|
||||
import { Line } from 'folds';
|
||||
|
||||
const APP_NAV_IDS = new Set(['emoji-tcg', 'radio', 'shop', 'voxel', 'dessin', 'cinema', 'casino', 'bourse', 'pets']);
|
||||
const LIVE_NAV_IDS = new Set(['peertube', 'wikipedia', 'openstreetmap']);
|
||||
const APP_NAV_IDS: readonly BotsuAppId[] = [
|
||||
'godot',
|
||||
'moodboard',
|
||||
'wikipedia',
|
||||
'openstreetmap',
|
||||
];
|
||||
const GENERATIONS_NAV_IDS = new Set(['generations-audiovisuel', 'generations-vision', 'generations-textuel']);
|
||||
const COOKIES_NAV_IDS: readonly BotsuAppId[] = ['shop', 'emoji-tcg', 'casino', 'bourse', 'pets'];
|
||||
const HIDDEN_SERVICE_PANEL_LABELS = new Set(['accueil', 'recherche']);
|
||||
|
||||
const orderApps = (ids: readonly BotsuAppId[], apps: readonly BotsuApp[]): BotsuApp[] =>
|
||||
ids
|
||||
.map((id) => apps.find((app) => app.id === id))
|
||||
.filter((app): app is BotsuApp => app !== undefined);
|
||||
|
||||
function getAppNavTarget(app: BotsuApp): { to: string } | { href: string } | undefined {
|
||||
const plan = buildLaunchPlan(app);
|
||||
if (plan.kind === 'navigate') return { to: plan.to };
|
||||
@@ -31,6 +41,8 @@ function getAppNavTarget(app: BotsuApp): { to: string } | { href: string } | und
|
||||
}
|
||||
|
||||
const getAppNavIcon = (appId: BotsuAppId | 'test') => {
|
||||
if (appId === 'godot') return Icons.Category;
|
||||
if (appId === 'moodboard') return Icons.Pin;
|
||||
if (appId === 'discussions') return Icons.Pencil;
|
||||
if (appId === 'documents') return Icons.File;
|
||||
if (appId === 'tables') return Icons.Category;
|
||||
@@ -62,6 +74,7 @@ const getServiceNavIcon = (label: string) => {
|
||||
if (serviceLabel === 'transferts') return Icons.Download;
|
||||
if (serviceLabel === 'ripper') return Icons.Play;
|
||||
if (serviceLabel === 'traduction') return Icons.Globe;
|
||||
if (serviceLabel === 'convertisseur') return Icons.ArrowGoRight;
|
||||
return Icons.External;
|
||||
};
|
||||
|
||||
@@ -120,17 +133,14 @@ export function BotsuNav() {
|
||||
const [applicationsClosed, setApplicationsClosed] = useState(false);
|
||||
const [servicesClosed, setServicesClosed] = useState(false);
|
||||
const [generationsClosed, setGenerationsClosed] = useState(false);
|
||||
const [liveClosed, setLiveClosed] = useState(false);
|
||||
const [cookiesClosed, setCookiesClosed] = useState(false);
|
||||
const [linksClosed, setLinksClosed] = useState(false);
|
||||
const apps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
|
||||
APP_NAV_IDS.has(app.id)
|
||||
);
|
||||
const liveApps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
|
||||
LIVE_NAV_IDS.has(app.id)
|
||||
);
|
||||
const generationsApps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps).filter((app) =>
|
||||
const memberApps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps);
|
||||
const apps = orderApps(APP_NAV_IDS, memberApps);
|
||||
const generationsApps = memberApps.filter((app) =>
|
||||
GENERATIONS_NAV_IDS.has(app.id)
|
||||
);
|
||||
const cookiesApps = orderApps(COOKIES_NAV_IDS, memberApps);
|
||||
const panelServices = botsuServices.filter(
|
||||
(service) => !HIDDEN_SERVICE_PANEL_LABELS.has(service.label.toLocaleLowerCase('fr-FR'))
|
||||
);
|
||||
@@ -166,38 +176,6 @@ export function BotsuNav() {
|
||||
|
||||
<Line className="botsu-links-separator" />
|
||||
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={liveClosed}
|
||||
onClick={() => setLiveClosed((closed) => !closed)}
|
||||
>
|
||||
live
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{!liveClosed && (
|
||||
<Box direction="Column" gap="100">
|
||||
{liveApps.map((app) => {
|
||||
const target = getAppNavTarget(app);
|
||||
const to = target && 'to' in target ? target.to : undefined;
|
||||
const href = target && 'href' in target ? target.href : undefined;
|
||||
return (
|
||||
<BotsuNavLink
|
||||
active={to ? location.pathname.startsWith(to) : false}
|
||||
href={href}
|
||||
key={app.id}
|
||||
to={to}
|
||||
>
|
||||
<BotsuNavLabel icon={getAppNavIcon(app.id)}>
|
||||
{app.label.toLocaleLowerCase('fr-FR')}
|
||||
</BotsuNavLabel>
|
||||
</BotsuNavLink>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</NavCategory>
|
||||
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -283,6 +261,38 @@ export function BotsuNav() {
|
||||
</Box>
|
||||
)}
|
||||
</NavCategory>
|
||||
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={cookiesClosed}
|
||||
onClick={() => setCookiesClosed((closed) => !closed)}
|
||||
>
|
||||
cookies
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{!cookiesClosed && (
|
||||
<Box direction="Column" gap="100">
|
||||
{cookiesApps.map((app) => {
|
||||
const target = getAppNavTarget(app);
|
||||
const to = target && 'to' in target ? target.to : undefined;
|
||||
const href = target && 'href' in target ? target.href : undefined;
|
||||
return (
|
||||
<BotsuNavLink
|
||||
active={to ? location.pathname.startsWith(to) : false}
|
||||
href={href}
|
||||
key={app.id}
|
||||
to={to}
|
||||
>
|
||||
<BotsuNavLabel icon={getAppNavIcon(app.id)}>
|
||||
{app.label.toLocaleLowerCase('fr-FR')}
|
||||
</BotsuNavLabel>
|
||||
</BotsuNavLink>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
|
||||