25 Commits
Author SHA1 Message Date
koka d139034044 feat(watch): add Twitch account mode 2026-08-10 07:31:49 +02:00
koka 5b049c9cad feat(watch): add YouTube Premium account mode 2026-08-10 07:15:32 +02:00
botsu 8ad9c5b320 feat(branding): replace Cinny logos with BOTSU mark 2026-08-09 17:32:36 +02:00
koka 0951b48fba feat(watch): add immersive OLED fullscreen 2026-08-09 14:05:52 +02:00
koka ba0ade6983 feat(rooms): refine story layout and room icons 2026-08-09 13:22:48 +02:00
koka 4f5cf0292c feat(client): polish shared Kanban room 2026-08-09 13:07:34 +02:00
botsu a298b1bbeb feat(client): add shared Kanban rooms 2026-08-09 12:05:34 +02:00
botsu ac990d9adc [verified] fix: serialize TCG discovery claims 2026-08-08 21:25:01 +02:00
botsu 5a48ba98c3 [verified] chore: prepare macOS Codex development 2026-08-08 21:13:30 +02:00
botsu ed3f934b61 feat: expand BOTSU creative rooms and apps 2026-08-08 20:31:07 +02:00
koka eae9333307 feat: add watch and voxel rooms 2026-08-08 16:00:42 +02:00
botsu 4c136b0f09 feat: add drawing rooms and Godot workspace support 2026-08-08 15:08:11 +02:00
botsu b532c6cd7f Add 5 shader backgrounds (fractal, isolayers, vortex, waves, cells) to BOTSU start page 2026-08-07 14:16:52 +02:00
botsu 27bfdd3deb feat: unify botsu shader options and add dust background 2026-08-07 01:20:49 +02:00
botsu 77eee8f4cc feat(sidebar): rename home tab tooltip to Reseau with hash icon 2026-08-07 00:29:16 +02:00
botsu 4245de0231 feat(nav): restructure BOTSU panel — applications, generations, services, cookies 2026-08-07 00:12:41 +02:00
botsu c14ed521ab fix(protocol): parse presence.map.viewport server message so viewport sync works end-to-end 2026-08-06 23:45:46 +02:00
botsu ad4374384a feat(openstreetmap): search addresses and places via Nominatim geocoding 2026-08-06 23:32:58 +02:00
botsu 17331a5792 fix(openstreetmap): use map.once('moveend') for reliable viewport sync 2026-08-06 23:23:15 +02:00
botsu 7f07a563b6 fix(openstreetmap): tolerate missing MapViewportProvider (noop bridge) 2026-08-06 23:15:56 +02:00
botsu 8494c18fa9 feat(openstreetmap): shared map with realtime viewport sync via presence.map.viewport 2026-08-06 23:09:07 +02:00
botsu b6c4fc6165 fix(wikipedia): remove shell padding for Wikipedia pages (edge-to-edge inset) 2026-08-06 21:47:44 +02:00
botsu 93f6dcd29d fix(wikipedia): edge-to-edge layout, no padding/max-width 2026-08-06 21:43:49 +02:00
botsu 5c92dab16c fix(wikipedia): wrap link rewrite in try/catch to handle invalid slugs 2026-08-06 21:39:32 +02:00
botsu c912996b4b feat(wikipedia): rewrite internal links to BOTSU paths so users stay on the same page 2026-08-06 21:33:06 +02:00
200 changed files with 26800 additions and 368 deletions
+22
View File
@@ -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
+2
View File
@@ -8,3 +8,5 @@ dist/
.vscode/
.idea/
coverage/
.data/
*.timestamp-*.mjs
+1
View File
@@ -0,0 +1 @@
24.13.1
+1
View File
@@ -0,0 +1 @@
24.13.1
+56
View File
@@ -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.
+41 -9
View File
@@ -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`.
+17 -11
View File
@@ -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">
+7
View File
@@ -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",
+3 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

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
+163 -12
View File
@@ -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>
+9 -1
View File
@@ -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>
+4
View File
@@ -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}`;
+2
View File
@@ -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);
+358 -3
View File
@@ -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,
+67 -1
View File
@@ -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 ||
+67 -5
View File
@@ -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);
});
+35 -10
View File
@@ -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);
+2 -2
View File
@@ -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/);
});
+148
View File
@@ -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/);
});
+515
View File
@@ -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;
}
}
+126
View File
@@ -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));
});
+258
View File
@@ -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 linstant. 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);
},
};
};
+1
View File
@@ -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');
});
+134
View File
@@ -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 = '&copy; <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) {
+32 -1
View File
@@ -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(' ')}
+18 -14
View File
@@ -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>
);
+105
View File
@@ -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">
+3 -2
View File
@@ -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);
+52 -42
View File
@@ -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>

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