Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d139034044 | ||
|
|
5b049c9cad | ||
|
|
8ad9c5b320 | ||
|
|
0951b48fba | ||
|
|
ba0ade6983 | ||
|
|
4f5cf0292c | ||
|
|
a298b1bbeb | ||
|
|
ac990d9adc |
@@ -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">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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/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",
|
||||
"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",
|
||||
|
||||
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 516 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -120,6 +120,31 @@ export function CreateRoomTypeSelector({
|
||||
</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'}
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum CreateRoomType {
|
||||
VoxelRoom = 'voxel',
|
||||
WatchRoom = 'watch',
|
||||
StoryboardRoom = 'storyboard',
|
||||
KanbanRoom = 'kanban',
|
||||
}
|
||||
|
||||
export enum CreateRoomAccess {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -111,6 +112,20 @@ export const createStoryboardRoomPowerLevelsOverride = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
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: '',
|
||||
@@ -158,6 +173,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
initialState.push(createWatchRoomState());
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Kanban) {
|
||||
initialState.push(...createKanbanRoomState());
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
@@ -177,7 +196,9 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
? createWatchRoomPowerLevelsOverride()
|
||||
: data.type === RoomType.Storyboard
|
||||
? createStoryboardRoomPowerLevelsOverride()
|
||||
: undefined,
|
||||
: data.type === RoomType.Kanban
|
||||
? createKanbanRoomPowerLevelsOverride()
|
||||
: undefined,
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ 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';
|
||||
|
||||
@@ -51,6 +53,7 @@ const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomTy
|
||||
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;
|
||||
@@ -63,6 +66,7 @@ const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomTy
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -70,9 +74,10 @@ 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 Icons.Hash;
|
||||
if (type === CreateRoomType.VoxelRoom) return VoxelRoomIcon;
|
||||
if (type === CreateRoomType.WatchRoom) return Icons.Play;
|
||||
if (type === CreateRoomType.StoryboardRoom) return Icons.Play;
|
||||
if (type === CreateRoomType.StoryboardRoom) return StoryRoomIcon;
|
||||
if (type === CreateRoomType.KanbanRoom) return Icons.Category;
|
||||
return Icons.Hash;
|
||||
};
|
||||
|
||||
@@ -161,6 +166,7 @@ export function CreateRoomForm({
|
||||
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,
|
||||
|
||||
@@ -71,6 +71,8 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||
? 'New Watch room'
|
||||
: type === CreateRoomType.StoryboardRoom
|
||||
? 'New Story Room'
|
||||
: type === CreateRoomType.KanbanRoom
|
||||
? 'New Kanban room'
|
||||
: 'New Chat room'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -315,6 +315,15 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||
>
|
||||
<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>
|
||||
@@ -400,6 +409,15 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
||||
>
|
||||
<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,7 +7,13 @@ import { callChatAtom } from '../../state/callEmbed';
|
||||
import { RoomView } from './RoomView';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
|
||||
export function CallChatView({ onClose }: { onClose?: () => void }) {
|
||||
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();
|
||||
@@ -24,10 +30,15 @@ export function CallChatView({ onClose }: { onClose?: () => void }) {
|
||||
>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="H5" truncate>
|
||||
Chat
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useCallback, useEffect } 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 { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
@@ -30,6 +30,7 @@ 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,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
import {
|
||||
isDocumentRoom,
|
||||
isDrawingRoom,
|
||||
isKanbanRoom,
|
||||
isStoryboardRoom,
|
||||
isVoxelRoom,
|
||||
isWatchRoom,
|
||||
@@ -94,13 +96,17 @@ export function Room() {
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
const [drawingChat, setDrawingChat] = useAtom(drawingChatAtom);
|
||||
const [watchChat, setWatchChat] = useAtom(watchChatAtom);
|
||||
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]);
|
||||
|
||||
@@ -122,6 +128,7 @@ export function Room() {
|
||||
const voxelView = isVoxelRoom(room);
|
||||
const watchView = isWatchRoom(room);
|
||||
const storyboardView = isStoryboardRoom(room);
|
||||
const kanbanView = isKanbanRoom(room);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
@@ -139,14 +146,15 @@ export function Room() {
|
||||
!drawingView &&
|
||||
!voxelView &&
|
||||
!watchView &&
|
||||
!storyboardView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<RoomView eventId={eventId} />
|
||||
!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">
|
||||
@@ -159,27 +167,24 @@ export function Room() {
|
||||
</DocumentEditorProvider>
|
||||
)}
|
||||
{!callView && watchView && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={
|
||||
screenSize === ScreenSize.Desktop || !watchChat ? undefined : { display: 'none' }
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<BotsuWatchRoomView room={room} />
|
||||
<BotsuWatchRoomView
|
||||
chatOpen={watchChat}
|
||||
chatContent={
|
||||
<CallChatView
|
||||
onClose={() => setWatchChat(false)}
|
||||
subtitle="Watch Room"
|
||||
title="Discussion"
|
||||
/>
|
||||
}
|
||||
onChatOpenChange={setWatchChat}
|
||||
room={room}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!callView && watchView && watchChat && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<CallChatView onClose={() => setWatchChat(false)} />
|
||||
</>
|
||||
)}
|
||||
{!callView && storyboardView && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
@@ -188,6 +193,14 @@ export function 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} />
|
||||
|
||||
@@ -39,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, isDrawingRoom, isVoxelRoom, isWatchRoom } 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';
|
||||
@@ -642,6 +648,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
!isDocumentRoom(room) &&
|
||||
!isDrawingRoom(room) &&
|
||||
!isVoxelRoom(room) &&
|
||||
!isKanbanRoom(room) &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
hasCallPermission && <CallButton />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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();
|
||||
|
||||
@@ -44,11 +45,24 @@ export function HomeCreateRoom() {
|
||||
<PageHeroSection>
|
||||
<Box direction="Column" gap="700">
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={watchRoom ? Icons.Play : Icons.Hash} />}
|
||||
title={watchRoom ? 'Create Watch Room' : 'Create Room'}
|
||||
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.'
|
||||
}
|
||||
/>
|
||||
@@ -59,6 +73,8 @@ export function HomeCreateRoom() {
|
||||
? CreateRoomType.WatchRoom
|
||||
: searchParams.get('type') === CreateRoomType.VoxelRoom
|
||||
? CreateRoomType.VoxelRoom
|
||||
: searchParams.get('type') === CreateRoomType.KanbanRoom
|
||||
? CreateRoomType.KanbanRoom
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
@@ -98,7 +100,8 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
|
||||
type !== RoomType.Drawing &&
|
||||
type !== RoomType.Voxel &&
|
||||
type !== RoomType.Watch &&
|
||||
type !== RoomType.Storyboard
|
||||
type !== RoomType.Storyboard &&
|
||||
type !== RoomType.Kanban
|
||||
);
|
||||
};
|
||||
|
||||
@@ -137,6 +140,13 @@ export const isStoryboardRoom = (room: Room | null): boolean => {
|
||||
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 &&
|
||||
@@ -355,11 +365,13 @@ export const getRoomIconSrc = (
|
||||
return icons.Pencil;
|
||||
}
|
||||
|
||||
if (roomType === RoomType.Voxel) return icons.Hash;
|
||||
if (roomType === RoomType.Voxel) return VoxelRoomIcon;
|
||||
|
||||
if (roomType === RoomType.Watch) return icons.Play;
|
||||
|
||||
if (roomType === RoomType.Storyboard) return icons.Play;
|
||||
if (roomType === RoomType.Storyboard) return StoryRoomIcon;
|
||||
|
||||
if (roomType === RoomType.Kanban) return icons.Category;
|
||||
|
||||
if (joinRule === JoinRule.Public) return icons.HashGlobe;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
DragEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
color,
|
||||
config,
|
||||
Input,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
TextArea,
|
||||
vars,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import {
|
||||
CheckIcon,
|
||||
PencilSimpleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
|
||||
import { readPowerLevel, usePowerLevelsContext } from '../../app/hooks/usePowerLevels';
|
||||
import { getStateEvent, getStateEvents } from '../../app/utils/room';
|
||||
import { stopPropagation } from '../../app/utils/keyboard';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import {
|
||||
KANBAN_MAX_CARDS,
|
||||
KANBAN_MAX_COLUMNS,
|
||||
KanbanCard,
|
||||
KanbanColumn,
|
||||
moveKanbanCard,
|
||||
nextKanbanCardPosition,
|
||||
parseKanbanCard,
|
||||
parseKanbanColumn,
|
||||
} from './model';
|
||||
import './kanban.css';
|
||||
|
||||
type BoardState = {
|
||||
columns: KanbanColumn[];
|
||||
cards: KanbanCard[];
|
||||
columnEventIds: Map<string, string | undefined>;
|
||||
cardEventIds: Map<string, string | undefined>;
|
||||
invalidCount: number;
|
||||
};
|
||||
|
||||
type KanbanThemeStyle = CSSProperties & Record<`--kanban-${string}`, string>;
|
||||
|
||||
const kanbanThemeStyle = {
|
||||
'--kanban-canvas': color.Background.Container,
|
||||
'--kanban-surface': color.Surface.Container,
|
||||
'--kanban-surface-raised': color.SurfaceVariant.Container,
|
||||
'--kanban-text': color.Background.OnContainer,
|
||||
'--kanban-muted': color.SurfaceVariant.OnContainer,
|
||||
'--kanban-border': color.Surface.ContainerLine,
|
||||
'--kanban-accent': color.Primary.Main,
|
||||
'--kanban-on-accent': color.Primary.OnMain,
|
||||
'--kanban-critical': color.Critical.Main,
|
||||
'--kanban-focus': vars.outline.FocusRing,
|
||||
'--kanban-radius': config.radii.R300,
|
||||
} satisfies KanbanThemeStyle;
|
||||
|
||||
type CardDraft = Omit<KanbanCard, 'version' | 'id' | 'position' | 'deleted'> & {
|
||||
id?: string;
|
||||
expectedEventId?: string;
|
||||
labelsInput: string;
|
||||
};
|
||||
|
||||
const EMPTY_CARD: CardDraft = {
|
||||
columnId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
assignee: '',
|
||||
dueDate: '',
|
||||
labels: [],
|
||||
labelsInput: '',
|
||||
};
|
||||
|
||||
const byPosition = <T extends { position: number; id: string }>(left: T, right: T) =>
|
||||
left.position - right.position || left.id.localeCompare(right.id);
|
||||
|
||||
const readBoardState = (room: Room): BoardState => {
|
||||
let invalidCount = 0;
|
||||
const columnEventIds = new Map<string, string | undefined>();
|
||||
const cardEventIds = new Map<string, string | undefined>();
|
||||
const columns = getStateEvents(room, StateEvent.BotsuKanbanColumn).flatMap((event) => {
|
||||
try {
|
||||
const column = parseKanbanColumn(event.getContent());
|
||||
if (event.getStateKey() !== column.id) throw new TypeError('column state key mismatch');
|
||||
columnEventIds.set(column.id, event.getId());
|
||||
return column.deleted ? [] : [column];
|
||||
} catch {
|
||||
invalidCount += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const validColumnIds = new Set(columns.map((column) => column.id));
|
||||
const cards = getStateEvents(room, StateEvent.BotsuKanbanCard).flatMap((event) => {
|
||||
try {
|
||||
const card = parseKanbanCard(event.getContent());
|
||||
if (event.getStateKey() !== card.id) throw new TypeError('card state key mismatch');
|
||||
cardEventIds.set(card.id, event.getId());
|
||||
return card.deleted || !validColumnIds.has(card.columnId) ? [] : [card];
|
||||
} catch {
|
||||
invalidCount += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return {
|
||||
columns: columns.sort(byPosition).slice(0, KANBAN_MAX_COLUMNS),
|
||||
cards: cards.sort(byPosition).slice(0, KANBAN_MAX_CARDS),
|
||||
columnEventIds,
|
||||
cardEventIds,
|
||||
invalidCount,
|
||||
};
|
||||
};
|
||||
|
||||
const labelsFromInput = (value: string): string[] => {
|
||||
const parsed = value
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean);
|
||||
if (parsed.length > 6) throw new Error('Six étiquettes maximum.');
|
||||
if (parsed.some((label) => label.length > 32)) {
|
||||
throw new Error('Une étiquette ne peut pas dépasser 32 caractères.');
|
||||
}
|
||||
if (new Set(parsed.map((label) => label.toLocaleLowerCase())).size !== parsed.length) {
|
||||
throw new Error('Chaque étiquette doit être unique.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export function BotsuKanbanRoomView({ room }: { room: Room }) {
|
||||
const mx = useMatrixClient();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const userPower = readPowerLevel.user(powerLevels, mx.getUserId() ?? undefined);
|
||||
const canEditColumns =
|
||||
userPower >= readPowerLevel.state(powerLevels, StateEvent.BotsuKanbanColumn);
|
||||
const canEditCards =
|
||||
userPower >= readPowerLevel.state(powerLevels, StateEvent.BotsuKanbanCard);
|
||||
const [board, setBoard] = useState<BoardState>(() => readBoardState(room));
|
||||
const [draft, setDraft] = useState<CardDraft>();
|
||||
const [columnTitle, setColumnTitle] = useState('');
|
||||
const [addingColumn, setAddingColumn] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveLock = useRef(false);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
if (!saving) setDraft(undefined);
|
||||
}, [saving]);
|
||||
|
||||
const refresh = useCallback(() => setBoard(readBoardState(room)), [room]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(readBoardState(room));
|
||||
const handleStateEvent = (event: MatrixEvent) => {
|
||||
if (
|
||||
event.getRoomId() !== room.roomId ||
|
||||
(event.getType() !== StateEvent.BotsuKanbanColumn &&
|
||||
event.getType() !== StateEvent.BotsuKanbanCard)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
room.on(RoomStateEvent.Events, handleStateEvent);
|
||||
return () => {
|
||||
room.removeListener(RoomStateEvent.Events, handleStateEvent);
|
||||
};
|
||||
}, [refresh, room]);
|
||||
|
||||
const members = useMemo(
|
||||
() =>
|
||||
room
|
||||
.getJoinedMembers()
|
||||
.map((member) => ({ id: member.userId, name: member.name || member.userId }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
[room, board]
|
||||
);
|
||||
|
||||
const assertCurrentEvent = (
|
||||
eventType: StateEvent,
|
||||
stateKey: string,
|
||||
expectedEventId: string | undefined,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const current = getStateEvent(room, eventType, stateKey);
|
||||
if (
|
||||
(expectedEventId !== undefined && current?.getId() !== expectedEventId) ||
|
||||
(requireAbsent && current !== undefined)
|
||||
) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
};
|
||||
|
||||
const sendColumn = async (
|
||||
column: KanbanColumn,
|
||||
expectedEventId?: string,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const parsed = parseKanbanColumn(column);
|
||||
assertCurrentEvent(StateEvent.BotsuKanbanColumn, parsed.id, expectedEventId, requireAbsent);
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.BotsuKanbanColumn as any, parsed, parsed.id);
|
||||
};
|
||||
|
||||
const sendCard = async (
|
||||
card: KanbanCard,
|
||||
expectedEventId?: string,
|
||||
requireAbsent = false
|
||||
) => {
|
||||
const parsed = parseKanbanCard(card);
|
||||
const columnEvent = getStateEvent(room, StateEvent.BotsuKanbanColumn, parsed.columnId);
|
||||
try {
|
||||
const column = parseKanbanColumn(columnEvent?.getContent());
|
||||
if (column.deleted || column.id !== parsed.columnId) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
assertCurrentEvent(StateEvent.BotsuKanbanCard, parsed.id, expectedEventId, requireAbsent);
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.BotsuKanbanCard as any, parsed, parsed.id);
|
||||
};
|
||||
|
||||
const runSave = async (operation: () => Promise<void>) => {
|
||||
if (saveLock.current) return;
|
||||
saveLock.current = true;
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await operation();
|
||||
} catch (cause) {
|
||||
console.error('[BotsuKanban] Matrix state update failed', cause);
|
||||
setError(
|
||||
cause instanceof Error && cause.message === 'Kanban state changed remotely'
|
||||
? 'Une modification distante est arrivée. Rouvrez la carte puis réessayez.'
|
||||
: "La modification n'a pas pu être synchronisée."
|
||||
);
|
||||
refresh();
|
||||
} finally {
|
||||
saveLock.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNewCard = (columnId: string) => setDraft({ ...EMPTY_CARD, columnId });
|
||||
|
||||
const openCard = (card: KanbanCard) =>
|
||||
setDraft({
|
||||
id: card.id,
|
||||
expectedEventId: board.cardEventIds.get(card.id),
|
||||
columnId: card.columnId,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
assignee: card.assignee,
|
||||
dueDate: card.dueDate,
|
||||
labels: [...card.labels],
|
||||
labelsInput: card.labels.join(', '),
|
||||
});
|
||||
|
||||
const saveCard = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canEditCards || !draft || !draft.title.trim()) return;
|
||||
let labels: string[];
|
||||
try {
|
||||
labels = labelsFromInput(draft.labelsInput);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Étiquettes invalides.');
|
||||
return;
|
||||
}
|
||||
const existing = draft.id ? board.cards.find((card) => card.id === draft.id) : undefined;
|
||||
const id = existing?.id ?? `card_${crypto.randomUUID()}`;
|
||||
const position =
|
||||
existing?.position ?? nextKanbanCardPosition(board.cards, draft.columnId);
|
||||
void runSave(async () => {
|
||||
await sendCard(
|
||||
{
|
||||
version: 1,
|
||||
id,
|
||||
columnId: draft.columnId,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
assignee: draft.assignee,
|
||||
dueDate: draft.dueDate,
|
||||
labels,
|
||||
position,
|
||||
deleted: false,
|
||||
},
|
||||
draft.expectedEventId,
|
||||
!existing
|
||||
);
|
||||
setDraft(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteCard = () => {
|
||||
if (!canEditCards || !draft?.id) return;
|
||||
const card = board.cards.find((item) => item.id === draft.id);
|
||||
if (!card || !window.confirm(`Supprimer « ${card.title} » ?`)) return;
|
||||
void runSave(async () => {
|
||||
await sendCard({ ...card, deleted: true }, draft.expectedEventId);
|
||||
setDraft(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const addColumn = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const title = columnTitle.trim();
|
||||
if (!canEditColumns || !title || board.columns.length >= KANBAN_MAX_COLUMNS) return;
|
||||
void runSave(async () => {
|
||||
await sendColumn(
|
||||
{
|
||||
version: 1,
|
||||
id: `col_${crypto.randomUUID()}`,
|
||||
title,
|
||||
position: board.columns.length,
|
||||
deleted: false,
|
||||
},
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
setColumnTitle('');
|
||||
setAddingColumn(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renameColumn = (column: KanbanColumn) => {
|
||||
if (!canEditColumns) return;
|
||||
const title = window.prompt('Nom de la colonne', column.title)?.trim();
|
||||
if (!title || title === column.title) return;
|
||||
void runSave(() =>
|
||||
sendColumn({ ...column, title }, board.columnEventIds.get(column.id))
|
||||
);
|
||||
};
|
||||
|
||||
const deleteColumn = (column: KanbanColumn) => {
|
||||
if (!canEditColumns) return;
|
||||
const columnCards = board.cards.filter((card) => card.columnId === column.id);
|
||||
if (columnCards.length > 0) {
|
||||
setError('Déplacez ou supprimez les cartes avant de retirer cette colonne.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Supprimer la colonne « ${column.title} » ?`)) return;
|
||||
void runSave(async () => {
|
||||
if (readBoardState(room).cards.some((card) => card.columnId === column.id)) {
|
||||
throw new Error('Kanban state changed remotely');
|
||||
}
|
||||
await sendColumn(
|
||||
{ ...column, deleted: true },
|
||||
board.columnEventIds.get(column.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const moveCard = (cardId: string, columnId: string, index: number) => {
|
||||
if (!canEditCards || saveLock.current) return;
|
||||
let updates: KanbanCard[];
|
||||
try {
|
||||
updates = moveKanbanCard(board.cards, cardId, columnId, index);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (updates.length === 0) return;
|
||||
setBoard((current) => {
|
||||
const next = new Map(current.cards.map((card) => [card.id, card]));
|
||||
updates.forEach((card) => next.set(card.id, card));
|
||||
return { ...current, cards: Array.from(next.values()).sort(byPosition) };
|
||||
});
|
||||
void runSave(() =>
|
||||
Promise.all(
|
||||
updates.map((card) => sendCard(card, board.cardEventIds.get(card.id)))
|
||||
).then(() => undefined)
|
||||
);
|
||||
};
|
||||
|
||||
const dropCard = (event: DragEvent, columnId: string, index: number) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const cardId = event.dataTransfer.getData('text/plain');
|
||||
if (cardId) moveCard(cardId, columnId, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="botsu-kanban"
|
||||
aria-label="Tableau Kanban partagé"
|
||||
style={kanbanThemeStyle as CSSProperties}
|
||||
>
|
||||
{(error || board.invalidCount > 0) && (
|
||||
<div className="botsu-kanban__alert" role="alert">
|
||||
{error ?? `${board.invalidCount} élément(s) Matrix invalide(s) ignoré(s).`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="botsu-kanban__board">
|
||||
{board.columns.map((column) => {
|
||||
const cards = board.cards
|
||||
.filter((card) => card.columnId === column.id)
|
||||
.sort(byPosition);
|
||||
return (
|
||||
<section
|
||||
className="botsu-kanban__column"
|
||||
key={column.id}
|
||||
aria-labelledby={`kanban-column-${column.id}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => dropCard(event, column.id, cards.length)}
|
||||
>
|
||||
<header className="botsu-kanban__column-header">
|
||||
<h2 id={`kanban-column-${column.id}`}>{column.title}</h2>
|
||||
<span aria-label={`${cards.length} cartes`}>{cards.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => renameColumn(column)}
|
||||
aria-label={`Renommer ${column.title}`}
|
||||
title="Renommer"
|
||||
disabled={saving || !canEditColumns}
|
||||
>
|
||||
<PencilSimpleIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteColumn(column)}
|
||||
aria-label={`Supprimer ${column.title}`}
|
||||
title="Supprimer"
|
||||
disabled={saving || !canEditColumns}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="botsu-kanban__cards">
|
||||
{cards.map((card, index) => (
|
||||
<article
|
||||
className="botsu-kanban__card"
|
||||
key={card.id}
|
||||
draggable={!saving && canEditCards}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', card.id);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => dropCard(event, column.id, index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCard(card)}
|
||||
aria-label={`Ouvrir ${card.title}`}
|
||||
disabled={saving}
|
||||
>
|
||||
<strong>{card.title}</strong>
|
||||
{card.labels.length > 0 && (
|
||||
<span className="botsu-kanban__labels">
|
||||
{card.labels.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{(card.assignee || card.dueDate) && (
|
||||
<span className="botsu-kanban__meta">
|
||||
{card.assignee && <span>{card.assignee}</span>}
|
||||
{card.dueDate && <time dateTime={card.dueDate}>{card.dueDate}</time>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="botsu-kanban__add-card"
|
||||
type="button"
|
||||
onClick={() => openNewCard(column.id)}
|
||||
disabled={!canEditCards || saving || board.cards.length >= KANBAN_MAX_CARDS}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" size={16} />
|
||||
<span>Ajouter une carte</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section className="botsu-kanban__new-column">
|
||||
{addingColumn ? (
|
||||
<form onSubmit={addColumn}>
|
||||
<label htmlFor="kanban-new-column">Nouvelle colonne</label>
|
||||
<input
|
||||
id="kanban-new-column"
|
||||
value={columnTitle}
|
||||
onChange={(event) => setColumnTitle(event.target.value)}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
required
|
||||
disabled={!canEditColumns || saving}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Ajouter la colonne"
|
||||
disabled={!canEditColumns || saving}
|
||||
>
|
||||
<CheckIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Annuler l'ajout de colonne"
|
||||
onClick={() => setAddingColumn(false)}
|
||||
>
|
||||
<XIcon aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingColumn(true)}
|
||||
disabled={!canEditColumns || saving || board.columns.length >= KANBAN_MAX_COLUMNS}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" size={16} />
|
||||
<span>Ajouter une colonne</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: !saving,
|
||||
onDeactivate: closeEditor,
|
||||
escapeDeactivates: saving ? false : stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
flexHeight
|
||||
className="botsu-kanban__editor"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="kanban-card-title"
|
||||
style={kanbanThemeStyle as CSSProperties}
|
||||
>
|
||||
<form onSubmit={saveCard}>
|
||||
<header>
|
||||
<h2 id="kanban-card-title">{draft.id ? 'Carte' : 'Nouvelle carte'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
aria-label="Fermer"
|
||||
disabled={saving}
|
||||
>
|
||||
<XIcon aria-hidden="true" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="botsu-kanban__editor-fields">
|
||||
<label>
|
||||
<span>
|
||||
Titre
|
||||
<small>Obligatoire</small>
|
||||
</span>
|
||||
<Input
|
||||
id="kanban-card-title-input"
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.currentTarget.value })}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
maxLength={160}
|
||||
placeholder="Que faut-il faire ?"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<TextArea
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.description}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, description: event.currentTarget.value })
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
resize="Vertical"
|
||||
maxLength={4000}
|
||||
rows={6}
|
||||
placeholder="Ajoutez du contexte, des liens ou les critères de réussite…"
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
<div className="botsu-kanban__field-row">
|
||||
<label>
|
||||
<span>Responsable</span>
|
||||
<div className="botsu-kanban__select">
|
||||
<select
|
||||
value={draft.assignee}
|
||||
onChange={(event) => setDraft({ ...draft, assignee: event.target.value })}
|
||||
disabled={!canEditCards || saving}
|
||||
>
|
||||
<option value="">Personne</option>
|
||||
{members.map((member) => (
|
||||
<option key={member.id} value={member.id}>{member.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Échéance</span>
|
||||
<Input
|
||||
className="botsu-kanban__field-control"
|
||||
type="date"
|
||||
value={draft.dueDate}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, dueDate: event.currentTarget.value })
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>
|
||||
Étiquettes
|
||||
<small>Séparées par des virgules</small>
|
||||
</span>
|
||||
<Input
|
||||
className="botsu-kanban__field-control"
|
||||
value={draft.labelsInput}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
labelsInput: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="500"
|
||||
radii="400"
|
||||
placeholder="design, urgent"
|
||||
autoComplete="off"
|
||||
maxLength={210}
|
||||
disabled={!canEditCards || saving}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<footer>
|
||||
{canEditCards && draft.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="botsu-kanban__danger"
|
||||
onClick={deleteCard}
|
||||
disabled={saving}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" size={16} />
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
<button type="button" onClick={closeEditor} disabled={saving}>
|
||||
Annuler
|
||||
</button>
|
||||
{canEditCards && (
|
||||
<button type="submit" disabled={saving}>
|
||||
{!saving && <CheckIcon aria-hidden="true" size={16} />}
|
||||
{saving ? 'Synchronisation…' : 'Enregistrer'}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (url: URL) => readFile(url, 'utf8');
|
||||
|
||||
test('registers Kanban as a first-class Matrix room type in every creation path', async () => {
|
||||
const [roomTypes, createTypes, selector, createRoom, modal, spaceItem, roomUtils] =
|
||||
await Promise.all([
|
||||
read(new URL('../../types/matrix/room.ts', import.meta.url)),
|
||||
read(new URL('../../app/components/create-room/types.ts', import.meta.url)),
|
||||
read(new URL('../../app/components/create-room/CreateRoomTypeSelector.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/create-room/CreateRoom.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/create-room/CreateRoomModal.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/lobby/SpaceItem.tsx', import.meta.url)),
|
||||
read(new URL('../../app/utils/room.ts', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(roomTypes, /Kanban = 'net\.botsu\.kanban'/);
|
||||
assert.match(roomTypes, /BotsuKanbanColumn = 'net\.botsu\.kanban\.column'/);
|
||||
assert.match(roomTypes, /BotsuKanbanCard = 'net\.botsu\.kanban\.card'/);
|
||||
assert.match(createTypes, /KanbanRoom = 'kanban'/);
|
||||
assert.match(selector, /CreateRoomType\.KanbanRoom/);
|
||||
assert.match(selector, />\s*Kanban Room\s*</);
|
||||
assert.match(createRoom, /RoomType\.Kanban/);
|
||||
assert.match(modal, /New Kanban room/);
|
||||
assert.match(spaceItem, /handleCreateRoom\(CreateRoomType\.KanbanRoom\)/);
|
||||
assert.match(roomUtils, /isKanbanRoom/);
|
||||
assert.match(roomUtils, /type !== RoomType\.Kanban/);
|
||||
});
|
||||
|
||||
test('direct create route preserves the Kanban type query parameter', async () => {
|
||||
const directCreate = await read(
|
||||
new URL('../../app/pages/client/home/CreateRoom.tsx', import.meta.url)
|
||||
);
|
||||
assert.match(directCreate, /CreateRoomType\.KanbanRoom/);
|
||||
});
|
||||
|
||||
test('creates editable default state and renders the Kanban room instead of a timeline', async () => {
|
||||
const [utils, room, header, view] = await Promise.all([
|
||||
read(new URL('../../app/components/create-room/utils.ts', import.meta.url)),
|
||||
read(new URL('../../app/features/room/Room.tsx', import.meta.url)),
|
||||
read(new URL('../../app/features/room/RoomViewHeader.tsx', import.meta.url)),
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(utils, /createKanbanRoomPowerLevelsOverride/);
|
||||
assert.match(utils, /StateEvent\.BotsuKanbanColumn[^]*?: 0/);
|
||||
assert.match(utils, /StateEvent\.BotsuKanbanCard[^]*?: 0/);
|
||||
assert.match(utils, /KANBAN_DEFAULT_COLUMNS/);
|
||||
assert.match(room, /isKanbanRoom/);
|
||||
assert.match(room, /BotsuKanbanRoomView key=\{room\.roomId\}/);
|
||||
assert.match(header, /isKanbanRoom/);
|
||||
assert.match(view, /RoomStateEvent\.Events/);
|
||||
assert.match(view, /getStateEvent/);
|
||||
assert.match(view, /expectedEventId/);
|
||||
assert.match(view, /usePowerLevelsContext/);
|
||||
assert.match(view, /canEdit/);
|
||||
assert.match(view, /if \(!saving\) setDraft\(undefined\)/);
|
||||
assert.match(view, /aria-label=\{`Ouvrir \$\{card\.title\}`\}[^]*?disabled=\{saving\}/);
|
||||
assert.match(view, /aria-label="Fermer"[^]*?disabled=\{saving\}/);
|
||||
assert.match(view, /sendStateEvent/);
|
||||
assert.doesNotMatch(view, /dangerouslySetInnerHTML/);
|
||||
});
|
||||
|
||||
test('Kanban room exposes cards, metadata, drag and drop, columns, and accessible controls', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /draggable/);
|
||||
assert.match(view, /onDrop/);
|
||||
assert.match(view, /Ajouter une carte/);
|
||||
assert.match(view, /Ajouter une colonne/);
|
||||
assert.match(view, /Description/);
|
||||
assert.match(view, /Responsable/);
|
||||
assert.match(view, /Échéance/);
|
||||
assert.match(view, /Étiquettes/);
|
||||
assert.match(view, /aria-label/);
|
||||
assert.match(css, /--kanban-radius/);
|
||||
assert.match(css, /overflow-x:\s*auto/);
|
||||
});
|
||||
|
||||
test('Kanban follows Cinny and BOTSU theme tokens and fills Safari flex layouts', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /color\.Background\.Container/);
|
||||
assert.match(view, /color\.Surface\.Container/);
|
||||
assert.match(view, /color\.Primary\.Main/);
|
||||
assert.match(view, /vars\.outline\.FocusRing/);
|
||||
assert.match(view, /style=\{kanbanThemeStyle/);
|
||||
assert.match(css, /flex:\s*1 1 0%/);
|
||||
assert.match(css, /width:\s*100%/);
|
||||
assert.match(css, /min-width:\s*0/);
|
||||
assert.match(css, /inline-size:\s*100%/);
|
||||
assert.match(css, /min-inline-size:\s*0/);
|
||||
assert.match(css, /align-self:\s*stretch/);
|
||||
assert.match(css, /var\(--kanban-surface-raised\)/);
|
||||
assert.match(css, /var\(--kanban-focus\)/);
|
||||
});
|
||||
|
||||
test('card editing uses the same centered floating modal pattern as Cinny settings', async () => {
|
||||
const view = await read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url));
|
||||
|
||||
assert.match(view, /<Overlay open backdrop=\{<OverlayBackdrop \/>\}>/);
|
||||
assert.match(view, /<OverlayCenter>/);
|
||||
assert.match(view, /<FocusTrap/);
|
||||
assert.match(view, /<Modal[^]*?flexHeight[^]*?className="botsu-kanban__editor"/);
|
||||
assert.doesNotMatch(view, /botsu-kanban__editor-backdrop/);
|
||||
});
|
||||
|
||||
test('card editor fields use polished Cinny form controls instead of raw inputs', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read(new URL('./BotsuKanbanRoomView.tsx', import.meta.url)),
|
||||
read(new URL('./kanban.css', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.match(view, /\bInput\b/);
|
||||
assert.match(view, /<Input[^]*?variant="SurfaceVariant"/);
|
||||
assert.match(view, /<TextArea[^]*?variant="SurfaceVariant"/);
|
||||
assert.match(view, /className="botsu-kanban__select"/);
|
||||
assert.match(css, /\.botsu-kanban__field-row/);
|
||||
assert.match(css, /\.botsu-kanban__select:focus-within/);
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
.botsu-kanban {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
flex: 1 1 0%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
max-inline-size: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
color: var(--kanban-text);
|
||||
background: var(--kanban-canvas);
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.botsu-kanban *,
|
||||
.botsu-kanban *::before,
|
||||
.botsu-kanban *::after,
|
||||
.botsu-kanban__editor,
|
||||
.botsu-kanban__editor *,
|
||||
.botsu-kanban__editor *::before,
|
||||
.botsu-kanban__editor *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botsu-kanban button,
|
||||
.botsu-kanban input,
|
||||
.botsu-kanban textarea,
|
||||
.botsu-kanban select,
|
||||
.botsu-kanban__editor button,
|
||||
.botsu-kanban__editor input,
|
||||
.botsu-kanban__editor textarea,
|
||||
.botsu-kanban__editor select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.botsu-kanban button,
|
||||
.botsu-kanban__editor button {
|
||||
min-height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--kanban-radius);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-kanban button:disabled,
|
||||
.botsu-kanban__editor button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.botsu-kanban button:focus-visible,
|
||||
.botsu-kanban input:focus-visible,
|
||||
.botsu-kanban textarea:focus-visible,
|
||||
.botsu-kanban select:focus-visible,
|
||||
.botsu-kanban__editor button:focus-visible {
|
||||
outline: 2px solid var(--kanban-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.botsu-kanban__alert {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 1rem;
|
||||
top: 0.75rem;
|
||||
max-width: 34rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: var(--kanban-text);
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-left: 3px solid var(--kanban-critical);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.botsu-kanban__board {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.botsu-kanban__column,
|
||||
.botsu-kanban__new-column {
|
||||
flex: 0 0 clamp(17rem, 30%, 22rem);
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.botsu-kanban__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 8rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
overflow: hidden;
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface);
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--kanban-text) 5%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--kanban-border);
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header > span {
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header button {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
padding: 0;
|
||||
color: var(--kanban-muted);
|
||||
border-radius: var(--kanban-radius);
|
||||
}
|
||||
|
||||
.botsu-kanban__column-header button:hover,
|
||||
.botsu-kanban__add-card:hover,
|
||||
.botsu-kanban__new-column > button:hover {
|
||||
color: var(--kanban-text);
|
||||
background: color-mix(in srgb, var(--kanban-accent) 12%, var(--kanban-surface-raised));
|
||||
}
|
||||
|
||||
.botsu-kanban__cards {
|
||||
min-height: 1rem;
|
||||
flex: 1 1 auto;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
}
|
||||
|
||||
.botsu-kanban__card {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__card[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.botsu-kanban__card[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.botsu-kanban__card > button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.botsu-kanban__card > button:hover {
|
||||
background: color-mix(in srgb, var(--kanban-accent) 8%, var(--kanban-surface-raised));
|
||||
}
|
||||
|
||||
.botsu-kanban__card:has(> button:hover) {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 55%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__card strong {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.botsu-kanban__labels,
|
||||
.botsu-kanban__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__labels > span {
|
||||
padding: 0.15rem 0.38rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
color: var(--kanban-text);
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 42%, var(--kanban-border));
|
||||
background: color-mix(in srgb, var(--kanban-accent) 12%, transparent);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__meta {
|
||||
justify-content: space-between;
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__add-card,
|
||||
.botsu-kanban__new-column > button {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--kanban-muted);
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.botsu-kanban__add-card {
|
||||
border-top: 1px solid var(--kanban-border) !important;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column {
|
||||
border: 1px dashed var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: color-mix(in srgb, var(--kanban-surface) 72%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column label {
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__new-column form button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.botsu-kanban input,
|
||||
.botsu-kanban textarea,
|
||||
.botsu-kanban select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--kanban-border);
|
||||
color: var(--kanban-text);
|
||||
border-radius: var(--kanban-radius);
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban input::placeholder,
|
||||
.botsu-kanban textarea::placeholder {
|
||||
color: var(--kanban-muted);
|
||||
}
|
||||
|
||||
.botsu-kanban textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor {
|
||||
width: calc(100vw - 2rem);
|
||||
max-width: min(42rem, calc(100vw - 2rem));
|
||||
max-height: calc(100dvh - 2rem);
|
||||
color: var(--kanban-text);
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface);
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 28%);
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor form {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header,
|
||||
.botsu-kanban__editor footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem 0.9rem;
|
||||
border-bottom: 1px solid var(--kanban-border);
|
||||
background: color-mix(in srgb, var(--kanban-surface-raised) 54%, var(--kanban-surface));
|
||||
}
|
||||
|
||||
.botsu-kanban__editor h2 {
|
||||
margin: 0;
|
||||
color: var(--kanban-text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor header button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor-fields {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem 1.25rem 1.2rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--kanban-border) transparent;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
color: var(--kanban-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label > span {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor label small {
|
||||
color: color-mix(in srgb, var(--kanban-muted) 78%, transparent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control {
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control:hover {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 38%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__field-control:focus-within {
|
||||
border-color: var(--kanban-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kanban-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
color: var(--kanban-text);
|
||||
background: var(--kanban-surface-raised);
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-kanban__select::after {
|
||||
position: absolute;
|
||||
right: 0.9rem;
|
||||
top: 50%;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
color: var(--kanban-muted);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
}
|
||||
|
||||
.botsu-kanban__select:hover {
|
||||
border-color: color-mix(in srgb, var(--kanban-accent) 38%, var(--kanban-border));
|
||||
}
|
||||
|
||||
.botsu-kanban__select:focus-within {
|
||||
border-color: var(--kanban-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kanban-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.botsu-kanban__select select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 3rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0.7rem 2.5rem 0.7rem 0.85rem;
|
||||
color: inherit;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-kanban__select select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
padding: 0.9rem 1.25rem 1rem;
|
||||
border-top: 1px solid var(--kanban-border);
|
||||
background: color-mix(in srgb, var(--kanban-surface-raised) 54%, var(--kanban-surface));
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer button {
|
||||
min-height: 2.2rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--kanban-border);
|
||||
border-radius: var(--kanban-radius);
|
||||
background: var(--kanban-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer button[type='submit'] {
|
||||
color: var(--kanban-on-accent);
|
||||
border-color: var(--kanban-accent);
|
||||
background: var(--kanban-accent);
|
||||
}
|
||||
|
||||
.botsu-kanban__danger {
|
||||
color: var(--kanban-critical) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.botsu-kanban__board {
|
||||
padding: 0.75rem;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.botsu-kanban__column,
|
||||
.botsu-kanban__new-column {
|
||||
flex-basis: calc(100% - 1rem);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.botsu-kanban__editor {
|
||||
width: calc(100vw - 1.5rem);
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: calc(100dvh - 1.5rem);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor form {
|
||||
max-height: calc(100dvh - 1.5rem);
|
||||
}
|
||||
|
||||
.botsu-kanban__field-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.botsu-kanban__editor footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
KANBAN_DEFAULT_COLUMNS,
|
||||
type KanbanCard,
|
||||
moveKanbanCard,
|
||||
nextKanbanCardPosition,
|
||||
parseKanbanCard,
|
||||
parseKanbanColumn,
|
||||
} from './model.ts';
|
||||
|
||||
const column = {
|
||||
version: 1,
|
||||
id: 'todo',
|
||||
title: 'À faire',
|
||||
position: 0,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
const card: KanbanCard = {
|
||||
version: 1,
|
||||
id: 'card_1',
|
||||
columnId: 'todo',
|
||||
title: 'Première carte',
|
||||
description: '',
|
||||
assignee: '',
|
||||
dueDate: '',
|
||||
labels: ['design'],
|
||||
position: 0,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
test('parses bounded Kanban columns and cards into fresh values', () => {
|
||||
const parsedColumn = parseKanbanColumn(column);
|
||||
const parsedCard = parseKanbanCard(card);
|
||||
|
||||
assert.deepEqual(parsedColumn, column);
|
||||
assert.deepEqual(parsedCard, card);
|
||||
assert.notEqual(parsedColumn, column);
|
||||
assert.notEqual(parsedCard, card);
|
||||
assert.notEqual(parsedCard.labels, card.labels);
|
||||
});
|
||||
|
||||
test('rejects malformed, oversized, accessor-backed, and extra Kanban data', () => {
|
||||
assert.throws(() => parseKanbanColumn({ ...column, title: 'x'.repeat(81) }));
|
||||
assert.throws(() => parseKanbanColumn({ ...column, extra: true }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: Array(7).fill('x') }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: Array(1) }));
|
||||
const accessorLabels: string[] = [];
|
||||
Object.defineProperty(accessorLabels, '0', {
|
||||
enumerable: true,
|
||||
get: () => 'piège',
|
||||
});
|
||||
Object.defineProperty(accessorLabels, 'length', { value: 1 });
|
||||
assert.throws(() => parseKanbanCard({ ...card, labels: accessorLabels }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, dueDate: '08/08/2026' }));
|
||||
assert.throws(() => parseKanbanCard({ ...card, id: '../secret' }));
|
||||
|
||||
const accessorCard = { ...card } as Record<string, unknown>;
|
||||
Object.defineProperty(accessorCard, 'title', { enumerable: true, get: () => 'piège' });
|
||||
assert.throws(() => parseKanbanCard(accessorCard));
|
||||
});
|
||||
|
||||
test('provides the three minimal default columns', () => {
|
||||
assert.deepEqual(
|
||||
KANBAN_DEFAULT_COLUMNS.map((item) => item.title),
|
||||
['À faire', 'En cours', 'Terminé']
|
||||
);
|
||||
});
|
||||
|
||||
test('moves a card across columns with one state update and stable ordering', () => {
|
||||
const cards = [
|
||||
{ ...card, id: 'a', position: 0 },
|
||||
{ ...card, id: 'b', position: 1 },
|
||||
{ ...card, id: 'c', columnId: 'doing', position: 0 },
|
||||
];
|
||||
|
||||
const updates = moveKanbanCard(cards, 'b', 'doing', 0);
|
||||
assert.equal(updates.length, 1);
|
||||
const merged = new Map(cards.map((item) => [item.id, item]));
|
||||
updates.forEach((item) => merged.set(item.id, item));
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(merged.values())
|
||||
.filter((item) => item.columnId === 'todo')
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => [item.id, item.position]),
|
||||
[['a', 0]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(merged.values())
|
||||
.filter((item) => item.columnId === 'doing')
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => item.id),
|
||||
['b', 'c']
|
||||
);
|
||||
assert.equal(merged.get('c')?.position, 0);
|
||||
});
|
||||
|
||||
test('places a new card after the last ranked card without renumbering', () => {
|
||||
assert.equal(nextKanbanCardPosition([{ ...card, position: 2048 }], 'todo'), 3072);
|
||||
assert.equal(nextKanbanCardPosition([], 'todo'), 0);
|
||||
});
|
||||
|
||||
test('adjusts a same-column downward drop before the original target', () => {
|
||||
const cards = [
|
||||
{ ...card, id: 'a', position: 0 },
|
||||
{ ...card, id: 'b', position: 1024 },
|
||||
{ ...card, id: 'c', position: 2048 },
|
||||
];
|
||||
const [moved] = moveKanbanCard(cards, 'a', 'todo', 2);
|
||||
assert.ok(moved);
|
||||
const merged = cards.map((item) => (item.id === moved.id ? moved : item));
|
||||
assert.deepEqual(merged.sort((a, b) => a.position - b.position).map((item) => item.id), [
|
||||
'b',
|
||||
'a',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
test('refuses moves to unknown columns or for deleted cards', () => {
|
||||
const cards = [{ ...card, deleted: true }];
|
||||
assert.throws(() => moveKanbanCard(cards, card.id, 'todo', 0));
|
||||
assert.throws(() => moveKanbanCard([{ ...card }], card.id, '', 0));
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
export const KANBAN_MAX_COLUMNS = 12;
|
||||
export const KANBAN_MAX_CARDS = 500;
|
||||
export const KANBAN_MAX_LABELS = 6;
|
||||
|
||||
export type KanbanColumn = {
|
||||
version: 1;
|
||||
id: string;
|
||||
title: string;
|
||||
position: number;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type KanbanCard = {
|
||||
version: 1;
|
||||
id: string;
|
||||
columnId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee: string;
|
||||
dueDate: string;
|
||||
labels: string[];
|
||||
position: number;
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/i;
|
||||
const MATRIX_USER_ID_PATTERN = /^@[^\s:]{1,128}:[^\s:]{1,255}$/;
|
||||
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
const ownDataRecord = (value: unknown, keys: readonly string[], name: string) => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} must be an object`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${name} must be a plain object`);
|
||||
}
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (ownKeys.some((key) => typeof key !== 'string') || ownKeys.length !== keys.length) {
|
||||
throw new TypeError(`${name} has unsupported fields`);
|
||||
}
|
||||
const expected = new Set(keys);
|
||||
if (ownKeys.some((key) => typeof key !== 'string' || !expected.has(key))) {
|
||||
throw new TypeError(`${name} has unsupported fields`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
for (const key of keys) {
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError(`${name}.${key} must be an enumerable data field`);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(keys.map((key) => [key, descriptors[key]?.value])) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
|
||||
const boundedString = (
|
||||
value: unknown,
|
||||
name: string,
|
||||
max: number,
|
||||
{ empty = false }: { empty?: boolean } = {}
|
||||
) => {
|
||||
if (typeof value !== 'string' || value.length > max || (!empty && value.trim().length === 0)) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const entityId = (value: unknown, name: string) => {
|
||||
const id = boundedString(value, name, 64);
|
||||
if (!ID_PATTERN.test(id)) throw new TypeError(`${name} is invalid`);
|
||||
return id;
|
||||
};
|
||||
|
||||
const columnPosition = (value: unknown, name: string) => {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > 9999) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
};
|
||||
|
||||
const cardPosition = (value: unknown, name: string) => {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
value < -1_000_000_000_000 ||
|
||||
value > 1_000_000_000_000
|
||||
) {
|
||||
throw new TypeError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const bool = (value: unknown, name: string) => {
|
||||
if (typeof value !== 'boolean') throw new TypeError(`${name} is invalid`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const dueDate = (value: unknown) => {
|
||||
const date = boundedString(value, 'KanbanCard.dueDate', 10, { empty: true });
|
||||
if (date === '') return date;
|
||||
const match = DATE_PATTERN.exec(date);
|
||||
if (!match) throw new TypeError('KanbanCard.dueDate is invalid');
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year ||
|
||||
parsed.getUTCMonth() !== month - 1 ||
|
||||
parsed.getUTCDate() !== day
|
||||
) {
|
||||
throw new TypeError('KanbanCard.dueDate is invalid');
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
const labels = (value: unknown) => {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Array.prototype ||
|
||||
value.length > KANBAN_MAX_LABELS
|
||||
) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
const expectedKeys = new Set([
|
||||
'length',
|
||||
...Array.from({ length: value.length }, (_unused, index) => String(index)),
|
||||
]);
|
||||
if (
|
||||
ownKeys.length !== value.length + 1 ||
|
||||
ownKeys.some((key) => typeof key !== 'string' || !expectedKeys.has(key))
|
||||
) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
const parsed = Array.from({ length: value.length }, (_unused, index) => {
|
||||
const descriptor = descriptors[String(index)];
|
||||
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('KanbanCard.labels is invalid');
|
||||
}
|
||||
return boundedString(descriptor.value, 'KanbanCard.label', 32);
|
||||
});
|
||||
if (new Set(parsed.map((label) => label.toLocaleLowerCase())).size !== parsed.length) {
|
||||
throw new TypeError('KanbanCard.labels must be unique');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseKanbanColumn = (value: unknown): KanbanColumn => {
|
||||
const record = ownDataRecord(
|
||||
value,
|
||||
['version', 'id', 'title', 'position', 'deleted'],
|
||||
'KanbanColumn'
|
||||
);
|
||||
if (record.version !== 1) throw new TypeError('KanbanColumn.version is unsupported');
|
||||
return {
|
||||
version: 1,
|
||||
id: entityId(record.id, 'KanbanColumn.id'),
|
||||
title: boundedString(record.title, 'KanbanColumn.title', 80),
|
||||
position: columnPosition(record.position, 'KanbanColumn.position'),
|
||||
deleted: bool(record.deleted, 'KanbanColumn.deleted'),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseKanbanCard = (value: unknown): KanbanCard => {
|
||||
const record = ownDataRecord(
|
||||
value,
|
||||
[
|
||||
'version',
|
||||
'id',
|
||||
'columnId',
|
||||
'title',
|
||||
'description',
|
||||
'assignee',
|
||||
'dueDate',
|
||||
'labels',
|
||||
'position',
|
||||
'deleted',
|
||||
],
|
||||
'KanbanCard'
|
||||
);
|
||||
if (record.version !== 1) throw new TypeError('KanbanCard.version is unsupported');
|
||||
const parsedAssignee = boundedString(record.assignee, 'KanbanCard.assignee', 384, {
|
||||
empty: true,
|
||||
});
|
||||
if (parsedAssignee && !MATRIX_USER_ID_PATTERN.test(parsedAssignee)) {
|
||||
throw new TypeError('KanbanCard.assignee is invalid');
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
id: entityId(record.id, 'KanbanCard.id'),
|
||||
columnId: entityId(record.columnId, 'KanbanCard.columnId'),
|
||||
title: boundedString(record.title, 'KanbanCard.title', 160),
|
||||
description: boundedString(record.description, 'KanbanCard.description', 4000, {
|
||||
empty: true,
|
||||
}),
|
||||
assignee: parsedAssignee,
|
||||
dueDate: dueDate(record.dueDate),
|
||||
labels: labels(record.labels),
|
||||
position: cardPosition(record.position, 'KanbanCard.position'),
|
||||
deleted: bool(record.deleted, 'KanbanCard.deleted'),
|
||||
};
|
||||
};
|
||||
|
||||
export const KANBAN_DEFAULT_COLUMNS: readonly KanbanColumn[] = [
|
||||
{ version: 1, id: 'todo', title: 'À faire', position: 0, deleted: false },
|
||||
{ version: 1, id: 'doing', title: 'En cours', position: 1, deleted: false },
|
||||
{ version: 1, id: 'done', title: 'Terminé', position: 2, deleted: false },
|
||||
];
|
||||
|
||||
export const nextKanbanCardPosition = (
|
||||
cards: readonly KanbanCard[],
|
||||
columnId: string
|
||||
): number => {
|
||||
entityId(columnId, 'columnId');
|
||||
const positions = cards
|
||||
.filter((card) => !card.deleted && card.columnId === columnId)
|
||||
.map((card) => card.position);
|
||||
return positions.length === 0 ? 0 : Math.max(...positions) + 1024;
|
||||
};
|
||||
|
||||
export const moveKanbanCard = (
|
||||
cards: readonly KanbanCard[],
|
||||
cardId: string,
|
||||
targetColumnId: string,
|
||||
targetIndex: number
|
||||
): KanbanCard[] => {
|
||||
entityId(targetColumnId, 'targetColumnId');
|
||||
if (!Number.isSafeInteger(targetIndex) || targetIndex < 0) {
|
||||
throw new TypeError('targetIndex is invalid');
|
||||
}
|
||||
const moving = cards.find((card) => card.id === cardId);
|
||||
if (!moving || moving.deleted) throw new TypeError('card is unavailable');
|
||||
|
||||
const targetWithMoving = cards
|
||||
.filter((card) => !card.deleted && card.columnId === targetColumnId)
|
||||
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
|
||||
const sourceIndex = targetWithMoving.findIndex((card) => card.id === moving.id);
|
||||
const targetCards = targetWithMoving.filter((card) => card.id !== moving.id);
|
||||
const adjustedTargetIndex =
|
||||
moving.columnId === targetColumnId && sourceIndex >= 0 && sourceIndex < targetIndex
|
||||
? targetIndex - 1
|
||||
: targetIndex;
|
||||
const insertionIndex = Math.min(adjustedTargetIndex, targetCards.length);
|
||||
const previous = targetCards[insertionIndex - 1];
|
||||
const next = targetCards[insertionIndex];
|
||||
let nextPosition = 0;
|
||||
if (previous && next) nextPosition = (previous.position + next.position) / 2;
|
||||
else if (previous) nextPosition = previous.position + 1024;
|
||||
else if (next) nextPosition = next.position - 1024;
|
||||
|
||||
if (moving.columnId === targetColumnId && moving.position === nextPosition) return [];
|
||||
return [{ ...moving, columnId: targetColumnId, position: nextPosition }];
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -24,6 +25,11 @@ const pixelCanvasUrl = new URL('../start/BotsuPixelCanvas.tsx', import.meta.url)
|
||||
const startCssUrl = new URL('../start/start.css', import.meta.url);
|
||||
const godotUrl = new URL('./BotsuGodot.tsx', import.meta.url);
|
||||
const viteConfigUrl = new URL('../../../vite.config.js', import.meta.url);
|
||||
const authLayoutUrl = new URL('../../app/pages/auth/AuthLayout.tsx', import.meta.url);
|
||||
const welcomePageUrl = new URL('../../app/pages/client/WelcomePage.tsx', import.meta.url);
|
||||
const aboutPageUrl = new URL('../../app/features/settings/about/About.tsx', import.meta.url);
|
||||
const botsuBrandUrl = new URL('../../../public/res/botsu-b.png', import.meta.url);
|
||||
const indexHtmlUrl = new URL('../../../index.html', import.meta.url);
|
||||
|
||||
const getRuleBody = (css: string, selector: string): string => {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -32,6 +38,34 @@ const getRuleBody = (css: string, selector: string): string => {
|
||||
return match[1];
|
||||
};
|
||||
|
||||
test('every former Cinny logo surface uses the supplied BOTSU image', async () => {
|
||||
const [auth, welcome, about, nonUi, brand, indexHtml] = await Promise.all([
|
||||
readFile(authLayoutUrl, 'utf8'),
|
||||
readFile(welcomePageUrl, 'utf8'),
|
||||
readFile(aboutPageUrl, 'utf8'),
|
||||
readFile(clientNonUiUrl, 'utf8'),
|
||||
readFile(botsuBrandUrl),
|
||||
readFile(indexHtmlUrl, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
createHash('sha256').update(brand).digest('hex'),
|
||||
'01ba2d35ced4d030d88a7d31597b370894356aed973507b04fff839ba637b382'
|
||||
);
|
||||
for (const source of [auth, welcome, about, nonUi]) {
|
||||
assert.ok(source.includes('botsu-b.png'));
|
||||
assert.equal(source.includes('cinny.svg'), false);
|
||||
}
|
||||
assert.ok(nonUi.includes('botsu-b-unread.png'));
|
||||
assert.ok(nonUi.includes('botsu-b-highlight.png'));
|
||||
assert.ok(indexHtml.includes('rel="icon"'));
|
||||
assert.ok(indexHtml.includes('type="image/png"'));
|
||||
assert.ok(indexHtml.includes('sizes="32x32"'));
|
||||
assert.ok(indexHtml.includes('href="/public/favicon-32x32.png"'));
|
||||
assert.equal(indexHtml.includes('rel="shortcut icon"'), false);
|
||||
assert.equal(indexHtml.includes('href="./public/'), false);
|
||||
});
|
||||
|
||||
test('BOTSU shell leaves room for the active call footer', async () => {
|
||||
const css = await readFile(shellCssUrl, 'utf8');
|
||||
const shellRule = getRuleBody(css, '.botsu-shell');
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { CSSProperties, useMemo, useState } from 'react';
|
||||
import { color, config, vars } from 'folds';
|
||||
import type { Room } from 'matrix-js-sdk/lib/models/room';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChatCircleTextIcon,
|
||||
ImageIcon,
|
||||
PlusIcon,
|
||||
@@ -87,20 +87,20 @@ function StoryboardCardView({
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
aria-label="Déplacer vers la gauche"
|
||||
aria-label="Déplacer vers le haut"
|
||||
disabled={index === 0}
|
||||
type="button"
|
||||
onClick={() => onMove(index - 1)}
|
||||
>
|
||||
<ArrowLeftIcon aria-hidden="true" size={17} />
|
||||
<ArrowUpIcon aria-hidden="true" size={17} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Déplacer vers la droite"
|
||||
aria-label="Déplacer vers le bas"
|
||||
disabled={index === total - 1}
|
||||
type="button"
|
||||
onClick={() => onMove(index + 1)}
|
||||
>
|
||||
<ArrowRightIcon aria-hidden="true" size={17} />
|
||||
<ArrowDownIcon aria-hidden="true" size={17} />
|
||||
</button>
|
||||
<button aria-label="Supprimer la carte" type="button" onClick={onRemove}>
|
||||
<TrashIcon aria-hidden="true" size={17} />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import type { IconSrc } from 'folds';
|
||||
|
||||
const REGULAR_SCROLL_PATH =
|
||||
'M96,104a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H104A8,8,0,0,1,96,104Zm8,40h64a8,8,0,0,0,0-16H104a8,8,0,0,0,0,16Zm128,48a32,32,0,0,1-32,32H88a32,32,0,0,1-32-32V64a16,16,0,0,0-32,0c0,5.74,4.83,9.62,4.88,9.66h0A8,8,0,0,1,24,88a7.89,7.89,0,0,1-4.79-1.61h0C18.05,85.54,8,77.61,8,64A32,32,0,0,1,40,32H176a32,32,0,0,1,32,32V168h8a8,8,0,0,1,4.8,1.6C222,170.46,232,178.39,232,192ZM96.26,173.48A8.07,8.07,0,0,1,104,168h88V64a16,16,0,0,0-16-16H67.69A31.71,31.71,0,0,1,72,64V192a16,16,0,0,0,32,0c0-5.74-4.83-9.62-4.88-9.66A7.82,7.82,0,0,1,96.26,173.48ZM216,192a12.58,12.58,0,0,0-3.23-8h-94a26.92,26.92,0,0,1,1.21,8,31.82,31.82,0,0,1-4.29,16H200A16,16,0,0,0,216,192Z';
|
||||
|
||||
const FILLED_SCROLL_PATH =
|
||||
'M220.8,169.6A8,8,0,0,0,216,168h-8V64a32,32,0,0,0-32-32H40A32,32,0,0,0,8,64C8,77.61,18.05,85.54,19.2,86.4h0A7.89,7.89,0,0,0,24,88a8,8,0,0,0,4.87-14.33h0C28.83,73.62,24,69.74,24,64a16,16,0,0,1,32,0V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32C232,178.39,222,170.46,220.8,169.6ZM104,96h64a8,8,0,0,1,0,16H104a8,8,0,0,1,0-16Zm-8,40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H104A8,8,0,0,1,96,136Zm104,72H107.71A31.82,31.82,0,0,0,112,192a26.92,26.92,0,0,0-1.21-8h102a12.58,12.58,0,0,1,3.23,8A16,16,0,0,1,200,208Z';
|
||||
|
||||
export const StoryRoomIcon: IconSrc = (filled) => (
|
||||
<g transform="scale(0.09375)">
|
||||
<path d={filled ? FILLED_SCROLL_PATH : REGULAR_SCROLL_PATH} fill="currentColor" />
|
||||
</g>
|
||||
);
|
||||
@@ -31,8 +31,8 @@ test('room view renders the collaborative storyboard and Matrix-backed card even
|
||||
assert.match(view, /StateEvent\.BotsuStoryboardCard/);
|
||||
assert.match(view, /uploadContent\(/);
|
||||
assert.match(view, /sendStateEvent\(/);
|
||||
assert.match(view, /Déplacer vers la gauche/);
|
||||
assert.match(view, /Déplacer vers la droite/);
|
||||
assert.match(view, /Déplacer vers le haut/);
|
||||
assert.match(view, /Déplacer vers le bas/);
|
||||
assert.match(
|
||||
model,
|
||||
/StoryboardCardKind\s*=\s*'image'\s*\|\s*'sound'\s*\|\s*'dialogue'\s*\|\s*'scene'/
|
||||
@@ -42,9 +42,44 @@ test('room view renders the collaborative storyboard and Matrix-backed card even
|
||||
assert.match(view, /color\.Primary\.Main/);
|
||||
assert.match(view, /storyThemeStyle/);
|
||||
assert.match(css, /width:\s*100%/);
|
||||
assert.match(css, /grid-template-columns:\s*repeat\(auto-fit,/);
|
||||
assert.match(css, /var\(--story-canvas\)/);
|
||||
assert.match(css, /var\(--story-accent\)/);
|
||||
assert.doesNotMatch(css, /min-width:\s*max-content/);
|
||||
assert.match(createUtils, /StateEvent\.BotsuStoryboardCard/);
|
||||
});
|
||||
|
||||
test('story cards form a centered vertical conversation rail', async () => {
|
||||
const [view, css] = await Promise.all([
|
||||
read('./BotsuStoryboardRoomView.tsx'),
|
||||
read('./storyboard.css'),
|
||||
]);
|
||||
|
||||
assert.match(view, /ArrowUpIcon/);
|
||||
assert.match(view, /ArrowDownIcon/);
|
||||
assert.doesNotMatch(view, /Déplacer vers la gauche/);
|
||||
assert.doesNotMatch(view, /Déplacer vers la droite/);
|
||||
assert.match(
|
||||
css,
|
||||
/\.botsu-storyboard-track\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column[^}]*margin-inline:\s*auto/s
|
||||
);
|
||||
assert.match(css, /\.botsu-storyboard-card\s*\{[^}]*align-self:\s*center/s);
|
||||
assert.match(css, /overflow-y:\s*auto/);
|
||||
assert.match(css, /scrollbar-gutter:\s*stable both-edges/);
|
||||
assert.match(css, /content-visibility:\s*auto/);
|
||||
assert.doesNotMatch(css, /grid-template-columns:\s*repeat\(auto-fit,/);
|
||||
});
|
||||
|
||||
test('Story Rooms use the shared parchment icon in creation and room navigation', async () => {
|
||||
const [icon, roomUtils, createRoom] = await Promise.all([
|
||||
read('./StoryRoomIcon.tsx'),
|
||||
read('../../app/utils/room.ts'),
|
||||
read('../../app/features/create-room/CreateRoom.tsx'),
|
||||
]);
|
||||
|
||||
assert.match(icon, /export const StoryRoomIcon:\s*IconSrc/);
|
||||
assert.match(icon, /transform="scale\(0\.09375\)"/);
|
||||
assert.match(roomUtils, /RoomType\.Storyboard\) return StoryRoomIcon/);
|
||||
assert.match(createRoom, /CreateRoomType\.StoryboardRoom\) return StoryRoomIcon/);
|
||||
assert.doesNotMatch(roomUtils, /RoomType\.Storyboard\) return icons\.Play/);
|
||||
assert.doesNotMatch(createRoom, /CreateRoomType\.StoryboardRoom\) return Icons\.Play/);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
.botsu-storyboard {
|
||||
--story-rail-width: 38rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
padding: clamp(12px, 2.2vw, 28px);
|
||||
color: var(--story-text);
|
||||
background: var(--story-canvas);
|
||||
scrollbar-color: var(--story-border) transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
font-family: var(--font-secondary, Inter, sans-serif);
|
||||
}
|
||||
|
||||
@@ -18,7 +23,6 @@
|
||||
border: 1px solid var(--story-border);
|
||||
border-radius: var(--story-radius);
|
||||
background: var(--story-surface);
|
||||
box-shadow: 0 10px 30px color-mix(in srgb, var(--story-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.botsu-storyboard-toolbar {
|
||||
@@ -26,13 +30,15 @@
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: min(100%, var(--story-rail-width));
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-inline: auto;
|
||||
padding: 8px 10px 8px 14px;
|
||||
background: var(--story-surface-raised);
|
||||
}
|
||||
|
||||
.botsu-storyboard-toolbar > div {
|
||||
@@ -94,10 +100,10 @@
|
||||
|
||||
.botsu-storyboard-composer {
|
||||
display: grid;
|
||||
width: min(100%, 52rem);
|
||||
width: min(100%, var(--story-rail-width));
|
||||
box-sizing: border-box;
|
||||
gap: 10px;
|
||||
margin: 18px 0 24px;
|
||||
margin: 18px auto 24px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
@@ -141,25 +147,46 @@
|
||||
}
|
||||
|
||||
.botsu-storyboard-track {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: min(100%, var(--story-rail-width));
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: clamp(12px, 1.8vw, 20px);
|
||||
gap: clamp(14px, 2vw, 20px);
|
||||
margin-inline: auto;
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
|
||||
.botsu-storyboard-track::before {
|
||||
position: absolute;
|
||||
inset-block: 24px 32px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--story-border);
|
||||
content: '';
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.botsu-storyboard-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: min(100%, 32rem);
|
||||
min-width: 0;
|
||||
max-width: 32rem;
|
||||
min-height: 300px;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-self: stretch;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
scroll-margin-block: 5rem;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
|
||||
.botsu-storyboard-card:hover {
|
||||
border-color: color-mix(in srgb, var(--story-accent) 45%, var(--story-border));
|
||||
}
|
||||
|
||||
.botsu-storyboard-card > header {
|
||||
@@ -200,17 +227,21 @@
|
||||
|
||||
.botsu-storyboard-card__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.botsu-storyboard-card__body h2 {
|
||||
margin: 0 0 10px;
|
||||
color: var(--story-text);
|
||||
font-size: clamp(18px, 2vw, 22px);
|
||||
font-size: clamp(17px, 2vw, 20px);
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.botsu-storyboard-card__body h2:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.botsu-storyboard-card__body p {
|
||||
margin: 0;
|
||||
color: var(--story-text);
|
||||
@@ -242,9 +273,16 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.botsu-storyboard-card > footer button:last-child {
|
||||
margin-left: auto;
|
||||
color: var(--story-critical);
|
||||
}
|
||||
|
||||
.botsu-storyboard-empty {
|
||||
display: grid;
|
||||
width: min(100%, var(--story-rail-width));
|
||||
min-height: 55%;
|
||||
margin-inline: auto;
|
||||
color: var(--story-muted);
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
@@ -257,6 +295,9 @@
|
||||
}
|
||||
|
||||
.botsu-storyboard-error {
|
||||
width: min(100%, var(--story-rail-width));
|
||||
box-sizing: border-box;
|
||||
margin: 16px auto 0;
|
||||
padding: 9px 12px;
|
||||
color: var(--story-critical);
|
||||
border: 1px solid var(--story-critical);
|
||||
@@ -273,11 +314,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.botsu-storyboard-track {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.botsu-storyboard-card {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import type { IconSrc } from 'folds';
|
||||
|
||||
const REGULAR_CUBE_PATH =
|
||||
'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32h0l80.34,44L128,120,47.66,76ZM40,90l80,43.78v85.79L40,175.82Zm96,129.57V133.82L216,90v85.78Z';
|
||||
|
||||
const FILLED_CUBE_PATH =
|
||||
'M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,120,47.65,76,128,32l80.35,44Zm8,99.64V133.83l80-43.78v85.76Z';
|
||||
|
||||
export const VoxelRoomIcon: IconSrc = (filled) => (
|
||||
<g transform="scale(0.09375)">
|
||||
<path d={filled ? FILLED_CUBE_PATH : REGULAR_CUBE_PATH} fill="currentColor" />
|
||||
</g>
|
||||
);
|
||||
@@ -17,6 +17,18 @@ const colorModel = read('./voxel-color.ts');
|
||||
const embeddedBundle = read('../../../vendor/voxelier/assets/index-B558jG0T.js');
|
||||
const embeddedCss = read('../../../vendor/voxelier/botsu-embedded.css');
|
||||
const embeddedHtml = read('../../../vendor/voxelier/index.html');
|
||||
const voxelIcon = read('./VoxelRoomIcon.tsx');
|
||||
const roomUtils = read('../../app/utils/room.ts');
|
||||
const createRoom = read('../../app/features/create-room/CreateRoom.tsx');
|
||||
|
||||
test('Voxel Rooms use the shared cube icon in creation and room navigation', () => {
|
||||
assert.match(voxelIcon, /export const VoxelRoomIcon:\s*IconSrc/);
|
||||
assert.match(voxelIcon, /transform="scale\(0\.09375\)"/);
|
||||
assert.match(roomUtils, /RoomType\.Voxel\) return VoxelRoomIcon/);
|
||||
assert.match(createRoom, /CreateRoomType\.VoxelRoom\) return VoxelRoomIcon/);
|
||||
assert.doesNotMatch(roomUtils, /RoomType\.Voxel\) return icons\.Hash/);
|
||||
assert.doesNotMatch(createRoom, /CreateRoomType\.VoxelRoom\) return Icons\.Hash/);
|
||||
});
|
||||
|
||||
test('Voxel Rooms provide their editor controls to the Matrix room header', () => {
|
||||
assert.match(room, /<VoxelEditorProvider key=\{room\.roomId\}>/);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
FormEventHandler,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -11,9 +12,13 @@ import Hls from 'hls.js';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { color, config } from 'folds';
|
||||
import {
|
||||
ArrowClockwiseIcon,
|
||||
ArrowCounterClockwiseIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
ChatCircleIcon,
|
||||
CheckCircleIcon,
|
||||
ClosedCaptioningIcon,
|
||||
CornersInIcon,
|
||||
CornersOutIcon,
|
||||
FilmSlateIcon,
|
||||
LinkIcon,
|
||||
@@ -21,8 +26,10 @@ import {
|
||||
PlayIcon,
|
||||
SpeakerHighIcon,
|
||||
SpeakerSlashIcon,
|
||||
TwitchLogoIcon,
|
||||
UsersThreeIcon,
|
||||
XIcon,
|
||||
YoutubeLogoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {
|
||||
createEmptyBotsuWatchSession,
|
||||
@@ -40,7 +47,13 @@ import {
|
||||
type BotsuWatchSnapshot,
|
||||
} from './model';
|
||||
import { createBrowserJellyfinClient, type JellyfinItem, type JellyfinPlayback } from './jellyfin';
|
||||
import { createJellyfinWatchUrl, normalizeWatchSourceUrl, parseWatchSource } from './providers';
|
||||
import {
|
||||
createJellyfinWatchUrl,
|
||||
createTwitchAccountUrl,
|
||||
createYouTubePremiumUrl,
|
||||
normalizeWatchSourceUrl,
|
||||
parseWatchSource,
|
||||
} from './providers';
|
||||
import { WatchEmbedPlayer, type WatchPlaybackController } from './WatchEmbedPlayer';
|
||||
import './watch-room.css';
|
||||
|
||||
@@ -63,6 +76,9 @@ type WatchPreferences = {
|
||||
type WatchThemeStyle = CSSProperties & Record<`--botsu-watch-${string}`, string>;
|
||||
|
||||
const WATCH_PREFERENCES_KEY = 'botsu.watch.preferences.v1';
|
||||
const YOUTUBE_ACCOUNT_MODE_KEY = 'botsu.watch.youtube-account.v1';
|
||||
const TWITCH_ACCOUNT_MODE_KEY = 'botsu.watch.twitch-account.v1';
|
||||
const WATCH_CONTROLS_IDLE_MS = 2_800;
|
||||
const EMPTY_SESSION = createEmptyBotsuWatchSession();
|
||||
|
||||
const watchThemeStyle = {
|
||||
@@ -99,6 +115,22 @@ const loadWatchPreferences = (): WatchPreferences => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadYouTubeAccountMode = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(YOUTUBE_ACCOUNT_MODE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTwitchAccountMode = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(TWITCH_ACCOUNT_MODE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readWatchRoomState = (room: Room, now = Date.now()): WatchRoomState => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.BotsuWatchSession, '');
|
||||
if (!event || Array.isArray(event)) {
|
||||
@@ -372,11 +404,174 @@ function SourceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
type AccountProvider = 'youtube' | 'twitch';
|
||||
|
||||
const accountProviderCopy = {
|
||||
youtube: {
|
||||
title: 'YouTube Premium',
|
||||
introTitle: 'Profitez de la session YouTube de ce navigateur',
|
||||
introText:
|
||||
'Votre abonnement reste lié à YouTube. BOTSU ne demande jamais votre mot de passe ni l’accès à votre compte Google.',
|
||||
embedText:
|
||||
'Active les cookies YouTube pour retrouver Premium si vous êtes connecté dans ce navigateur. Les règles anti-cookies du navigateur peuvent toutefois le bloquer.',
|
||||
fallbackTitle: 'Ouverture sécurisée sur YouTube',
|
||||
fallbackText:
|
||||
'L’isolation de cette version de BOTSU empêche un lecteur connecté. YouTube peut s’ouvrir à la position commune dans un onglet où votre compte Premium reste actif.',
|
||||
privacyText:
|
||||
'Ce réglage reste sur cet appareil. Aucun cookie, jeton Google ou statut Premium n’est partagé dans Matrix.',
|
||||
loginUrl: 'https://www.youtube.com/',
|
||||
loginLabel: 'Se connecter à YouTube',
|
||||
openLabel: 'Ouvrir avec YouTube Premium',
|
||||
},
|
||||
twitch: {
|
||||
title: 'Compte Twitch',
|
||||
introTitle: 'Utilisez la session Twitch de ce navigateur',
|
||||
introText:
|
||||
'Vos abonnements et Twitch Turbo restent gérés par Twitch. BOTSU ne demande jamais votre mot de passe ni l’accès à votre compte.',
|
||||
embedText:
|
||||
'Active les cookies Twitch pour retrouver votre session, Turbo et les avantages de vos abonnements. Les règles anti-cookies du navigateur peuvent toutefois le bloquer.',
|
||||
fallbackTitle: 'Ouverture sécurisée sur Twitch',
|
||||
fallbackText:
|
||||
'L’isolation de cette version de BOTSU empêche un lecteur connecté. Twitch peut s’ouvrir sur la chaîne ou à la position commune de la VOD dans un onglet où votre compte reste actif.',
|
||||
privacyText:
|
||||
'Ce réglage reste sur cet appareil. Aucun cookie, jeton Twitch ou statut Turbo/abonnement n’est partagé dans Matrix.',
|
||||
loginUrl: 'https://www.twitch.tv/login',
|
||||
loginLabel: 'Se connecter à Twitch',
|
||||
openLabel: 'Ouvrir avec mon compte Twitch',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function ProviderAccountDialog({
|
||||
provider,
|
||||
accountMode,
|
||||
canEmbedAccount,
|
||||
accountUrl,
|
||||
onAccountModeChange,
|
||||
onClose,
|
||||
onOpenProvider,
|
||||
}: {
|
||||
provider: AccountProvider;
|
||||
accountMode: boolean;
|
||||
canEmbedAccount: boolean;
|
||||
accountUrl: string;
|
||||
onAccountModeChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
onOpenProvider: (url: string) => void;
|
||||
}) {
|
||||
const copy = accountProviderCopy[provider];
|
||||
const dialogTitleId = `botsu-watch-${provider}-account-title`;
|
||||
const Logo = provider === 'youtube' ? YoutubeLogoIcon : TwitchLogoIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="botsu-watch-source-overlay" role="presentation" onMouseDown={onClose}>
|
||||
<section
|
||||
aria-labelledby={dialogTitleId}
|
||||
aria-modal="true"
|
||||
className={`botsu-watch-source-dialog botsu-watch-provider-dialog botsu-watch-provider-dialog--${provider}`}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="botsu-watch-eyebrow">LECTURE PERSONNELLE</span>
|
||||
<h2 id={dialogTitleId}>{copy.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Fermer"
|
||||
className="botsu-watch-icon-button"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<XIcon aria-hidden size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="botsu-watch-provider-intro">
|
||||
<Logo aria-hidden size={38} weight="fill" />
|
||||
<div>
|
||||
<strong>{copy.introTitle}</strong>
|
||||
<p>{copy.introText}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEmbedAccount ? (
|
||||
<div className="botsu-watch-provider-option">
|
||||
<div>
|
||||
<strong>Compte dans le lecteur intégré</strong>
|
||||
<p>{copy.embedText}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={accountMode}
|
||||
className="botsu-watch-button botsu-watch-provider-toggle"
|
||||
onClick={() => onAccountModeChange(!accountMode)}
|
||||
type="button"
|
||||
>
|
||||
{accountMode ? 'Activé' : 'Activer'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="botsu-watch-provider-option" role="status">
|
||||
<div>
|
||||
<strong>{copy.fallbackTitle}</strong>
|
||||
<p>{copy.fallbackText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="botsu-watch-source-note botsu-watch-provider-note">{copy.privacyText}</p>
|
||||
|
||||
<footer className="botsu-watch-provider-actions">
|
||||
{canEmbedAccount && (
|
||||
<button
|
||||
className="botsu-watch-button"
|
||||
onClick={() => onOpenProvider(copy.loginUrl)}
|
||||
type="button"
|
||||
>
|
||||
{copy.loginLabel}
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
<button className="botsu-watch-button" onClick={onClose} type="button">
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-button--primary"
|
||||
onClick={() => onOpenProvider(accountUrl)}
|
||||
type="button"
|
||||
>
|
||||
<Logo aria-hidden size={18} weight="fill" />
|
||||
{copy.openLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotsuWatchRoomView({
|
||||
room,
|
||||
chatContent,
|
||||
chatOpen,
|
||||
onChatOpenChange,
|
||||
}: {
|
||||
room: Room;
|
||||
chatContent?: ReactNode;
|
||||
chatOpen: boolean;
|
||||
onChatOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const playbackRef = useRef<WatchPlaybackController>();
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const controlsIdleTimerRef = useRef<number>();
|
||||
const hlsRef = useRef<Hls>();
|
||||
const jellyfinClient = useMemo(createBrowserJellyfinClient, []);
|
||||
const [watchState, setWatchState] = useState<WatchRoomState>(() => {
|
||||
@@ -395,6 +590,10 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
const [captions, setCaptions] = useState(false);
|
||||
const [captionsAvailable, setCaptionsAvailable] = useState(false);
|
||||
const [sourceDialog, setSourceDialog] = useState(false);
|
||||
const [youtubeAccountDialog, setYoutubeAccountDialog] = useState(false);
|
||||
const [youtubeAccountMode, setYoutubeAccountMode] = useState(loadYouTubeAccountMode);
|
||||
const [twitchAccountDialog, setTwitchAccountDialog] = useState(false);
|
||||
const [twitchAccountMode, setTwitchAccountMode] = useState(loadTwitchAccountMode);
|
||||
const [sourceError, setSourceError] = useState<string>();
|
||||
const [mediaError, setMediaError] = useState<string>();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -403,6 +602,8 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
const [jellyfinSubtitleIndex, setJellyfinSubtitleIndex] = useState(-1);
|
||||
const [jellyfinSubtitleUrl, setJellyfinSubtitleUrl] = useState<string>();
|
||||
const [jellyfinTrackBusy, setJellyfinTrackBusy] = useState(false);
|
||||
const [immersive, setImmersive] = useState(false);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
|
||||
const session = watchState.snapshot.session;
|
||||
const source = session.source;
|
||||
@@ -428,6 +629,62 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
: undefined;
|
||||
const joinedMembers = useMemo(() => room.getJoinedMembers(), [room, watchState]);
|
||||
const canSeek = Number.isFinite(duration) && duration > 0;
|
||||
const canEmbedProviderAccount = !window.crossOriginIsolated;
|
||||
const youtubeAccountActive = youtubeAccountMode && canEmbedProviderAccount;
|
||||
const twitchAccountActive = twitchAccountMode && canEmbedProviderAccount;
|
||||
const accountDialogOpen = youtubeAccountDialog || twitchAccountDialog;
|
||||
|
||||
const clearControlsIdleTimer = useCallback(() => {
|
||||
if (controlsIdleTimerRef.current === undefined) return;
|
||||
window.clearTimeout(controlsIdleTimerRef.current);
|
||||
controlsIdleTimerRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
const scheduleControlsHide = useCallback(() => {
|
||||
clearControlsIdleTimer();
|
||||
if (!immersive || sourceDialog || accountDialogOpen) return;
|
||||
controlsIdleTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
controlsIdleTimerRef.current = undefined;
|
||||
}, WATCH_CONTROLS_IDLE_MS);
|
||||
}, [accountDialogOpen, clearControlsIdleTimer, immersive, sourceDialog]);
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
scheduleControlsHide();
|
||||
}, [scheduleControlsHide]);
|
||||
|
||||
const handleFullscreenToggle = useCallback(async () => {
|
||||
const watchRoom = roomRef.current;
|
||||
if (!watchRoom) return;
|
||||
if (document.fullscreenElement === watchRoom) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
await watchRoom.requestFullscreen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
const nextImmersive = document.fullscreenElement === roomRef.current;
|
||||
setImmersive(nextImmersive);
|
||||
setControlsVisible(true);
|
||||
if (!nextImmersive) clearControlsIdleTimer();
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, [clearControlsIdleTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!immersive) {
|
||||
setControlsVisible(true);
|
||||
clearControlsIdleTimer();
|
||||
return undefined;
|
||||
}
|
||||
revealControls();
|
||||
return clearControlsIdleTimer;
|
||||
}, [clearControlsIdleTimer, immersive, revealControls]);
|
||||
|
||||
const applyPlaybackState = useCallback(async (snapshot: BotsuWatchSnapshot) => {
|
||||
const player = playbackRef.current;
|
||||
@@ -642,6 +899,29 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(YOUTUBE_ACCOUNT_MODE_KEY, String(youtubeAccountMode));
|
||||
} catch {
|
||||
// The account mode remains available for this tab when storage is unavailable.
|
||||
}
|
||||
}, [youtubeAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(TWITCH_ACCOUNT_MODE_KEY, String(twitchAccountMode));
|
||||
} catch {
|
||||
// The account mode remains available for this tab when storage is unavailable.
|
||||
}
|
||||
}, [twitchAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDescriptor?.kind !== 'youtube') setYoutubeAccountDialog(false);
|
||||
if (sourceDescriptor?.kind !== 'twitch-live' && sourceDescriptor?.kind !== 'twitch-video') {
|
||||
setTwitchAccountDialog(false);
|
||||
}
|
||||
}, [sourceDescriptor?.kind]);
|
||||
|
||||
const sendSession = useCallback(
|
||||
async (nextValue: BotsuWatchSession) => {
|
||||
const next = parseBotsuWatchSession(nextValue);
|
||||
@@ -689,6 +969,20 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeekBy = async (offsetSeconds: number) => {
|
||||
const player = playbackRef.current;
|
||||
if (!player?.ready() || !source || !canSeek || sending) return;
|
||||
const nextPosition = Math.min(Math.max(player.currentTime() + offsetSeconds, 0), duration);
|
||||
player.seek(nextPosition);
|
||||
setPosition(nextPosition);
|
||||
setSeekDraft(undefined);
|
||||
try {
|
||||
await sendSession({ ...session, positionMs: Math.round(nextPosition * 1_000) });
|
||||
} catch {
|
||||
void applyPlaybackState(watchState.snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSeek = async () => {
|
||||
const player = playbackRef.current;
|
||||
if (!player?.ready() || seekDraft === undefined || !canSeek || sending) return;
|
||||
@@ -770,20 +1064,75 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
setCaptions(next);
|
||||
};
|
||||
|
||||
const openProvider = useCallback((url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!immersive || sourceDialog || accountDialogOpen) return undefined;
|
||||
const handleKeyboardShortcut = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
void handleTogglePlayback();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
void handleSeekBy(-10);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
void handleSeekBy(10);
|
||||
} else if (event.key.toLowerCase() === 'm') {
|
||||
setPreferences((current) => ({ ...current, muted: !current.muted }));
|
||||
} else if (event.key.toLowerCase() === 'c' && !jellyfinPlayback && captionsAvailable) {
|
||||
handleCaptionsToggle();
|
||||
} else if (event.key.toLowerCase() === 'f') {
|
||||
void handleFullscreenToggle();
|
||||
}
|
||||
revealControls();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyboardShortcut);
|
||||
return () => window.removeEventListener('keydown', handleKeyboardShortcut);
|
||||
});
|
||||
|
||||
const handleEnded = () => {
|
||||
if (session.paused || !Number.isFinite(duration)) return;
|
||||
void sendSession({ ...session, paused: true, positionMs: Math.round(duration * 1_000) });
|
||||
};
|
||||
|
||||
const displayPosition = seekDraft ?? position;
|
||||
const youtubePremiumUrl =
|
||||
sourceDescriptor?.kind === 'youtube'
|
||||
? createYouTubePremiumUrl(sourceDescriptor.videoId, displayPosition)
|
||||
: undefined;
|
||||
const twitchAccountUrl =
|
||||
sourceDescriptor?.kind === 'twitch-live' || sourceDescriptor?.kind === 'twitch-video'
|
||||
? createTwitchAccountUrl(sourceDescriptor, displayPosition)
|
||||
: undefined;
|
||||
const senderName = watchState.senderId
|
||||
? room.getMember(watchState.senderId)?.name ?? watchState.senderId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="botsu-watch-room" style={watchThemeStyle as CSSProperties}>
|
||||
<main className="botsu-watch-stage" ref={stageRef}>
|
||||
<div className="botsu-watch-statusbar">
|
||||
<div
|
||||
className={`botsu-watch-room${immersive ? ' botsu-watch-room--immersive' : ''}${
|
||||
immersive && !controlsVisible ? ' botsu-watch-room--controls-hidden' : ''
|
||||
}${chatOpen ? ' botsu-watch-room--chat-open' : ''}`}
|
||||
onFocusCapture={revealControls}
|
||||
onPointerDown={revealControls}
|
||||
onPointerMove={revealControls}
|
||||
ref={roomRef}
|
||||
style={watchThemeStyle as CSSProperties}
|
||||
>
|
||||
<main className="botsu-watch-stage">
|
||||
<div className="botsu-watch-statusbar botsu-watch-immersive-chrome">
|
||||
<div className="botsu-watch-sync-status">
|
||||
{sending ? (
|
||||
<ArrowsClockwiseIcon aria-hidden className="botsu-watch-spin" size={19} />
|
||||
@@ -800,7 +1149,7 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="botsu-watch-button"
|
||||
className="botsu-watch-button botsu-watch-status-source"
|
||||
onClick={() => setSourceDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
@@ -820,7 +1169,9 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
onError={setMediaError}
|
||||
onReady={() => void applyPlaybackState(watchState.snapshot)}
|
||||
source={embeddedSource}
|
||||
twitchCredentialMode={twitchAccountActive ? 'account' : 'anonymous'}
|
||||
volume={preferences.volume}
|
||||
youtubeCredentialMode={youtubeAccountActive ? 'account' : 'anonymous'}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
@@ -854,7 +1205,7 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
)}
|
||||
</video>
|
||||
)}
|
||||
<div className="botsu-watch-screen-title">
|
||||
<div className="botsu-watch-screen-title botsu-watch-immersive-chrome">
|
||||
<FilmSlateIcon aria-hidden size={18} />
|
||||
<span>{source.title}</span>
|
||||
</div>
|
||||
@@ -898,89 +1249,140 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="botsu-watch-control-dock">
|
||||
<section
|
||||
aria-labelledby="botsu-watch-shared-controls"
|
||||
className="botsu-watch-controls botsu-watch-controls--shared"
|
||||
>
|
||||
<span className="botsu-watch-eyebrow" id="botsu-watch-shared-controls">
|
||||
LECTURE PARTAGÉE
|
||||
</span>
|
||||
<div className="botsu-watch-shared-row">
|
||||
<button
|
||||
aria-label={
|
||||
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
|
||||
}
|
||||
className="botsu-watch-play-button"
|
||||
disabled={!source || sending}
|
||||
onClick={() => void handleTogglePlayback()}
|
||||
type="button"
|
||||
>
|
||||
{session.paused ? (
|
||||
<PlayIcon aria-hidden size={24} weight="fill" />
|
||||
) : (
|
||||
<PauseIcon aria-hidden size={24} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
<span className="botsu-watch-time">{formatBotsuWatchTime(displayPosition)}</span>
|
||||
<input
|
||||
aria-label="Position commune"
|
||||
disabled={!source || !canSeek || sending}
|
||||
max={canSeek ? duration : 1}
|
||||
min={0}
|
||||
onBlur={() => void commitSeek()}
|
||||
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
|
||||
onKeyUp={() => void commitSeek()}
|
||||
onPointerUp={() => void commitSeek()}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={canSeek ? Math.min(displayPosition, duration) : 0}
|
||||
/>
|
||||
<span className="botsu-watch-time">
|
||||
<div
|
||||
aria-label="Commandes du lecteur"
|
||||
className="botsu-watch-control-dock botsu-watch-immersive-chrome"
|
||||
role="group"
|
||||
>
|
||||
<div className="botsu-watch-progress-row">
|
||||
<input
|
||||
aria-label="Position commune"
|
||||
disabled={!source || !canSeek || sending}
|
||||
max={canSeek ? duration : 1}
|
||||
min={0}
|
||||
onBlur={() => void commitSeek()}
|
||||
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
|
||||
onKeyUp={() => void commitSeek()}
|
||||
onPointerUp={() => void commitSeek()}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={canSeek ? Math.min(displayPosition, duration) : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="botsu-watch-player-actions">
|
||||
<button
|
||||
aria-label="Reculer de 10 secondes"
|
||||
className="botsu-watch-icon-button botsu-watch-skip-button"
|
||||
disabled={!source || !canSeek || sending}
|
||||
onClick={() => void handleSeekBy(-10)}
|
||||
type="button"
|
||||
>
|
||||
<ArrowCounterClockwiseIcon aria-hidden size={25} />
|
||||
<span aria-hidden>10</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={
|
||||
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
|
||||
}
|
||||
className="botsu-watch-play-button"
|
||||
disabled={!source || sending}
|
||||
onClick={() => void handleTogglePlayback()}
|
||||
type="button"
|
||||
>
|
||||
{session.paused ? (
|
||||
<PlayIcon aria-hidden size={25} weight="fill" />
|
||||
) : (
|
||||
<PauseIcon aria-hidden size={25} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Avancer de 10 secondes"
|
||||
className="botsu-watch-icon-button botsu-watch-skip-button"
|
||||
disabled={!source || !canSeek || sending}
|
||||
onClick={() => void handleSeekBy(10)}
|
||||
type="button"
|
||||
>
|
||||
<ArrowClockwiseIcon aria-hidden size={25} />
|
||||
<span aria-hidden>10</span>
|
||||
</button>
|
||||
|
||||
<span className="botsu-watch-time botsu-watch-time--combined">
|
||||
{formatBotsuWatchTime(displayPosition)}
|
||||
<span aria-hidden> / </span>
|
||||
<span>
|
||||
{canSeek ? formatBotsuWatchTime(duration) : source ? 'EN DIRECT' : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<span aria-hidden className="botsu-watch-control-divider" />
|
||||
|
||||
<section
|
||||
aria-labelledby="botsu-watch-personal-controls"
|
||||
className="botsu-watch-controls botsu-watch-controls--personal"
|
||||
>
|
||||
<span className="botsu-watch-eyebrow" id="botsu-watch-personal-controls">
|
||||
APPAREIL
|
||||
</span>
|
||||
<div className="botsu-watch-personal-row">
|
||||
<div className="botsu-watch-volume">
|
||||
|
||||
<span aria-hidden className="botsu-watch-action-divider" />
|
||||
|
||||
<div className="botsu-watch-volume">
|
||||
<button
|
||||
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
|
||||
className="botsu-watch-icon-button"
|
||||
onClick={() => setPreferences((current) => ({ ...current, muted: !current.muted }))}
|
||||
type="button"
|
||||
>
|
||||
{preferences.muted || preferences.volume === 0 ? (
|
||||
<SpeakerSlashIcon aria-hidden size={22} />
|
||||
) : (
|
||||
<SpeakerHighIcon aria-hidden size={22} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
aria-label="Volume personnel"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const volume = Number(event.currentTarget.value);
|
||||
setPreferences({ volume, muted: volume === 0 });
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={preferences.volume}
|
||||
/>
|
||||
<output>{Math.round(preferences.volume * 100)}</output>
|
||||
</div>
|
||||
|
||||
<div className="botsu-watch-secondary-actions">
|
||||
{sourceDescriptor?.kind === 'youtube' && (
|
||||
<button
|
||||
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
|
||||
className="botsu-watch-icon-button"
|
||||
onClick={() =>
|
||||
setPreferences((current) => ({ ...current, muted: !current.muted }))
|
||||
}
|
||||
aria-label="Compte YouTube Premium"
|
||||
aria-pressed={youtubeAccountActive}
|
||||
className="botsu-watch-button botsu-watch-account-button botsu-watch-account-button--youtube"
|
||||
onClick={() => setYoutubeAccountDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
{preferences.muted || preferences.volume === 0 ? (
|
||||
<SpeakerSlashIcon aria-hidden size={22} />
|
||||
) : (
|
||||
<SpeakerHighIcon aria-hidden size={22} />
|
||||
)}
|
||||
<YoutubeLogoIcon aria-hidden size={20} weight="fill" />
|
||||
<span>Premium</span>
|
||||
</button>
|
||||
<input
|
||||
aria-label="Volume personnel"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const volume = Number(event.currentTarget.value);
|
||||
setPreferences({ volume, muted: volume === 0 ? true : false });
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={preferences.volume}
|
||||
/>
|
||||
<output>{Math.round(preferences.volume * 100)}</output>
|
||||
</div>
|
||||
)}
|
||||
{(sourceDescriptor?.kind === 'twitch-live' ||
|
||||
sourceDescriptor?.kind === 'twitch-video') && (
|
||||
<button
|
||||
aria-label="Compte Twitch"
|
||||
aria-pressed={twitchAccountActive}
|
||||
className="botsu-watch-button botsu-watch-account-button botsu-watch-account-button--twitch"
|
||||
onClick={() => setTwitchAccountDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<TwitchLogoIcon aria-hidden size={20} weight="fill" />
|
||||
<span>Compte</span>
|
||||
</button>
|
||||
)}
|
||||
{!jellyfinPlayback && (
|
||||
<button
|
||||
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
|
||||
aria-pressed={captions}
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!captionsAvailable}
|
||||
onClick={handleCaptionsToggle}
|
||||
type="button"
|
||||
>
|
||||
<ClosedCaptioningIcon aria-hidden size={22} />
|
||||
</button>
|
||||
)}
|
||||
<label className="botsu-watch-quality">
|
||||
<span className="botsu-watch-visually-hidden">Qualité personnelle</span>
|
||||
<select
|
||||
@@ -1043,32 +1445,47 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{!jellyfinPlayback && (
|
||||
<button
|
||||
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
|
||||
aria-pressed={captions}
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!captionsAvailable}
|
||||
onClick={handleCaptionsToggle}
|
||||
type="button"
|
||||
>
|
||||
<ClosedCaptioningIcon aria-hidden size={22} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Plein écran"
|
||||
aria-label={chatOpen ? 'Fermer la discussion' : 'Ouvrir la discussion'}
|
||||
aria-pressed={chatOpen}
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!source}
|
||||
onClick={() => void stageRef.current?.requestFullscreen()}
|
||||
onClick={() => onChatOpenChange(!chatOpen)}
|
||||
type="button"
|
||||
>
|
||||
<CornersOutIcon aria-hidden size={22} />
|
||||
<ChatCircleIcon aria-hidden size={23} weight={chatOpen ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-control-source"
|
||||
onClick={() => setSourceDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<LinkIcon aria-hidden size={18} />
|
||||
{source ? 'Changer le flux' : 'Ajouter un flux'}
|
||||
</button>
|
||||
<button
|
||||
aria-label={immersive ? 'Quitter le plein écran' : 'Plein écran'}
|
||||
className="botsu-watch-icon-button"
|
||||
disabled={!source}
|
||||
onClick={() => void handleFullscreenToggle()}
|
||||
type="button"
|
||||
>
|
||||
{immersive ? (
|
||||
<CornersInIcon aria-hidden size={22} />
|
||||
) : (
|
||||
<CornersOutIcon aria-hidden size={22} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{chatOpen && chatContent && (
|
||||
<aside aria-label="Discussion de la Watch Room" className="botsu-watch-chat-panel">
|
||||
{chatContent}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{sourceDialog && (
|
||||
<SourceDialog
|
||||
busy={sending}
|
||||
@@ -1079,6 +1496,28 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
|
||||
session={session}
|
||||
/>
|
||||
)}
|
||||
{youtubeAccountDialog && youtubePremiumUrl && (
|
||||
<ProviderAccountDialog
|
||||
accountUrl={youtubePremiumUrl}
|
||||
accountMode={youtubeAccountMode}
|
||||
canEmbedAccount={canEmbedProviderAccount}
|
||||
onAccountModeChange={setYoutubeAccountMode}
|
||||
onClose={() => setYoutubeAccountDialog(false)}
|
||||
onOpenProvider={openProvider}
|
||||
provider="youtube"
|
||||
/>
|
||||
)}
|
||||
{twitchAccountDialog && twitchAccountUrl && (
|
||||
<ProviderAccountDialog
|
||||
accountMode={twitchAccountMode}
|
||||
accountUrl={twitchAccountUrl}
|
||||
canEmbedAccount={canEmbedProviderAccount}
|
||||
onAccountModeChange={setTwitchAccountMode}
|
||||
onClose={() => setTwitchAccountDialog(false)}
|
||||
onOpenProvider={openProvider}
|
||||
provider="twitch"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ type EmbedProps = {
|
||||
controllerRef: MutableRefObject<WatchPlaybackController | undefined>;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
youtubeCredentialMode?: 'anonymous' | 'account';
|
||||
twitchCredentialMode?: 'anonymous' | 'account';
|
||||
onReady: () => void;
|
||||
onEnded: () => void;
|
||||
onError: (message: string) => void;
|
||||
@@ -134,6 +136,8 @@ export function WatchEmbedPlayer({
|
||||
controllerRef,
|
||||
volume,
|
||||
muted,
|
||||
youtubeCredentialMode = 'anonymous',
|
||||
twitchCredentialMode = 'anonymous',
|
||||
onReady,
|
||||
onEnded,
|
||||
onError,
|
||||
@@ -170,7 +174,7 @@ export function WatchEmbedPlayer({
|
||||
rel: '0',
|
||||
});
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('credentialless', '');
|
||||
if (youtubeCredentialMode !== 'account') iframe.setAttribute('credentialless', '');
|
||||
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
|
||||
iframe.allowFullscreen = true;
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
@@ -220,6 +224,7 @@ export function WatchEmbedPlayer({
|
||||
parent: [window.location.hostname],
|
||||
autoplay: false,
|
||||
muted,
|
||||
credentialless: twitchCredentialMode !== 'account',
|
||||
};
|
||||
if (source.kind === 'twitch-live') options.channel = source.channel;
|
||||
else options.video = source.videoId;
|
||||
@@ -262,7 +267,7 @@ export function WatchEmbedPlayer({
|
||||
destroy?.();
|
||||
if (controllerRef.current) controllerRef.current = undefined;
|
||||
};
|
||||
}, [controllerRef, source]);
|
||||
}, [controllerRef, source, twitchCredentialMode, youtubeCredentialMode]);
|
||||
|
||||
return <div className="botsu-watch-embed-player" ref={hostRef} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
createJellyfinWatchUrl,
|
||||
createTwitchAccountUrl,
|
||||
createYouTubePremiumUrl,
|
||||
normalizeWatchSourceUrl,
|
||||
parseWatchSource,
|
||||
type WatchSourceDescriptor,
|
||||
@@ -31,6 +33,19 @@ test('recognizes bounded YouTube videos across canonical URL forms', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a safe Premium watch URL at the synchronized position', () => {
|
||||
assert.equal(
|
||||
createYouTubePremiumUrl('dQw4w9WgXcQ', 91.8),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=91s'
|
||||
);
|
||||
assert.equal(
|
||||
createYouTubePremiumUrl('dQw4w9WgXcQ', 0),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||
);
|
||||
assert.throws(() => createYouTubePremiumUrl('../account', 12), /video id/);
|
||||
assert.throws(() => createYouTubePremiumUrl('dQw4w9WgXcQ', Number.NaN), /position/);
|
||||
});
|
||||
|
||||
test('recognizes Twitch live channels and VODs without accepting reserved paths', () => {
|
||||
expectSource('https://www.twitch.tv/chris_live', {
|
||||
kind: 'twitch-live',
|
||||
@@ -46,6 +61,29 @@ test('recognizes Twitch live channels and VODs without accepting reserved paths'
|
||||
});
|
||||
});
|
||||
|
||||
test('creates safe Twitch account URLs for live channels and synchronized VODs', () => {
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-live', channel: 'twitchdev' }, 91.8),
|
||||
'https://www.twitch.tv/twitchdev'
|
||||
);
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-video', videoId: 'v123456789' }, 3_723.9),
|
||||
'https://www.twitch.tv/videos/123456789?t=1h2m3s'
|
||||
);
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-video', videoId: 'v123456789' }),
|
||||
'https://www.twitch.tv/videos/123456789'
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createTwitchAccountUrl(
|
||||
{ kind: 'twitch-video', videoId: 'v123456789' },
|
||||
Number.POSITIVE_INFINITY
|
||||
),
|
||||
/position/
|
||||
);
|
||||
});
|
||||
|
||||
test('creates and parses a token-free Jellyfin item reference for the fixed friend server', () => {
|
||||
const itemId = 'aea05d9c7069e326d09184d7cb3d2d57';
|
||||
const url = createJellyfinWatchUrl(itemId);
|
||||
|
||||
@@ -75,6 +75,45 @@ export const createJellyfinWatchUrl = (itemId: string): string => {
|
||||
return `${JELLYFIN_WATCH_ORIGIN}/Items/${itemId}`;
|
||||
};
|
||||
|
||||
export const createYouTubePremiumUrl = (videoId: string, positionSeconds = 0): string => {
|
||||
if (!YOUTUBE_VIDEO_ID.test(videoId)) throw new TypeError('YouTube video id is invalid');
|
||||
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
||||
throw new TypeError('YouTube playback position is invalid');
|
||||
}
|
||||
const url = new URL('https://www.youtube.com/watch');
|
||||
url.searchParams.set('v', videoId);
|
||||
const start = Math.floor(positionSeconds);
|
||||
if (start > 0) url.searchParams.set('t', `${start}s`);
|
||||
return url.href;
|
||||
};
|
||||
|
||||
type TwitchWatchSource = Extract<WatchSourceDescriptor, { kind: 'twitch-live' | 'twitch-video' }>;
|
||||
|
||||
const formatTwitchPosition = (positionSeconds: number): string => {
|
||||
const totalSeconds = Math.floor(positionSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours > 0 ? `${hours}h` : ''}${
|
||||
minutes > 0 || hours > 0 ? `${minutes}m` : ''
|
||||
}${seconds}s`;
|
||||
};
|
||||
|
||||
export const createTwitchAccountUrl = (source: TwitchWatchSource, positionSeconds = 0): string => {
|
||||
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
||||
throw new TypeError('Twitch playback position is invalid');
|
||||
}
|
||||
if (source.kind === 'twitch-live') {
|
||||
if (!TWITCH_CHANNEL.test(source.channel)) throw new TypeError('Twitch channel is invalid');
|
||||
return `https://www.twitch.tv/${source.channel}`;
|
||||
}
|
||||
const videoId = source.videoId.startsWith('v') ? source.videoId.slice(1) : '';
|
||||
if (!TWITCH_VIDEO_ID.test(videoId)) throw new TypeError('Twitch video id is invalid');
|
||||
const url = new URL(`https://www.twitch.tv/videos/${videoId}`);
|
||||
if (positionSeconds >= 1) url.searchParams.set('t', formatTwitchPosition(positionSeconds));
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const parseWatchSource = (value: string): WatchSourceDescriptor => {
|
||||
const url = parseUrl(value);
|
||||
const youtubeVideoId = parseYouTubeVideoId(url);
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
.botsu-watch-stage {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: clamp(12px, 2.2vw, 28px);
|
||||
@@ -176,31 +177,19 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.botsu-watch-controls {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-control-dock {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) 1px minmax(320px, 1fr);
|
||||
width: min(100%, 980px);
|
||||
display: flex;
|
||||
width: min(100%, 1120px);
|
||||
margin: 0 auto;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: var(--botsu-watch-radius);
|
||||
background: var(--botsu-watch-surface-raised);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botsu-watch-control-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--botsu-watch-border);
|
||||
}
|
||||
|
||||
.botsu-watch-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
@@ -210,42 +199,45 @@
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row,
|
||||
.botsu-watch-personal-row,
|
||||
.botsu-watch-progress-row,
|
||||
.botsu-watch-player-actions,
|
||||
.botsu-watch-secondary-actions,
|
||||
.botsu-watch-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row {
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
.botsu-watch-progress-row {
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.botsu-watch-personal-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
.botsu-watch-player-actions {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.botsu-watch-personal-row .botsu-watch-quality select {
|
||||
max-width: 220px;
|
||||
.botsu-watch-secondary-actions {
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.botsu-watch-volume {
|
||||
min-width: 130px;
|
||||
flex: 1 1 260px;
|
||||
gap: 10px;
|
||||
width: clamp(128px, 14vw, 190px);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.botsu-watch-volume output {
|
||||
width: 2.2rem;
|
||||
width: 1.9rem;
|
||||
color: var(--botsu-watch-muted);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row input[type='range'],
|
||||
.botsu-watch-progress-row input[type='range'],
|
||||
.botsu-watch-volume input[type='range'] {
|
||||
min-width: 0;
|
||||
height: 3px;
|
||||
@@ -254,17 +246,38 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row input[type='range']:disabled {
|
||||
.botsu-watch-progress-row input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.botsu-watch-progress-row input[type='range']:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.botsu-watch-time {
|
||||
min-width: 3.7rem;
|
||||
flex: 0 0 auto;
|
||||
color: var(--botsu-watch-muted);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-watch-time--combined {
|
||||
min-width: 7.6rem;
|
||||
color: var(--botsu-watch-text);
|
||||
}
|
||||
|
||||
.botsu-watch-time--combined > span:last-child {
|
||||
color: var(--botsu-watch-muted);
|
||||
}
|
||||
|
||||
.botsu-watch-action-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
margin: 0 4px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--botsu-watch-border);
|
||||
}
|
||||
|
||||
.botsu-watch-play-button,
|
||||
@@ -319,6 +332,26 @@
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--youtube svg,
|
||||
.botsu-watch-provider-dialog--youtube svg {
|
||||
color: #ff4040;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--twitch svg,
|
||||
.botsu-watch-provider-dialog--twitch svg {
|
||||
color: #a970ff;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--youtube[aria-pressed='true'] {
|
||||
border-color: #ff5b5b;
|
||||
color: var(--botsu-watch-text);
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--twitch[aria-pressed='true'] {
|
||||
border-color: #a970ff;
|
||||
color: var(--botsu-watch-text);
|
||||
}
|
||||
|
||||
.botsu-watch-play-button:hover:not(:disabled),
|
||||
.botsu-watch-icon-button:hover:not(:disabled),
|
||||
.botsu-watch-button:hover:not(:disabled),
|
||||
@@ -354,14 +387,217 @@
|
||||
}
|
||||
|
||||
.botsu-watch-quality {
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-quality select {
|
||||
min-width: 112px;
|
||||
min-width: 104px;
|
||||
max-width: 170px;
|
||||
padding: 0 34px 0 12px;
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.botsu-watch-skip-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.botsu-watch-skip-button span {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.botsu-watch-control-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-panel {
|
||||
position: relative;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
width: min(28.5rem, 38vw);
|
||||
min-width: 19rem;
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
color: #f5f5f5;
|
||||
background: #050505;
|
||||
border-left: 1px solid rgb(255 255 255 / 18%);
|
||||
box-sizing: border-box;
|
||||
color-scheme: dark;
|
||||
--bg-surface-low: #050505;
|
||||
--bg-surface: #080808;
|
||||
--bg-surface-high: #0d0d0d;
|
||||
--bg-surface-hover: #151515;
|
||||
--bg-surface-active: #1d1d1d;
|
||||
--bg-surface-border: rgb(255 255 255 / 18%);
|
||||
--tc-surface-high: #fff;
|
||||
--tc-surface-normal: #f5f5f5;
|
||||
--tc-surface-low: #a3a3a3;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-panel > * {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.botsu-watch-room--chat-open .botsu-watch-player-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-room--chat-open .botsu-watch-secondary-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-room--chat-open .botsu-watch-quality {
|
||||
flex: 1 1 9rem;
|
||||
}
|
||||
|
||||
.botsu-watch-room--chat-open .botsu-watch-quality select {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.botsu-watch-immersive-chrome {
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
color: #f5f5f5;
|
||||
background: #000;
|
||||
--botsu-watch-canvas: #000;
|
||||
--botsu-watch-surface: #050505;
|
||||
--botsu-watch-surface-raised: #080808;
|
||||
--botsu-watch-text: #f5f5f5;
|
||||
--botsu-watch-muted: #a3a3a3;
|
||||
--botsu-watch-border: rgb(255 255 255 / 22%);
|
||||
--botsu-watch-accent: #f5f5f5;
|
||||
--botsu-watch-on-accent: #050505;
|
||||
--botsu-watch-radius: 2px;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-stage {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-statusbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 22px;
|
||||
right: 28px;
|
||||
width: auto;
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-status-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-screen-title {
|
||||
z-index: 6;
|
||||
top: 24px;
|
||||
left: 28px;
|
||||
max-width: min(46%, 38rem);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-screen-title svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-control-dock {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 52px 28px 24px;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(to top, #000 20%, rgb(0 0 0 / 86%) 62%, transparent 100%);
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-player-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-play-button {
|
||||
width: 56px;
|
||||
min-height: 52px;
|
||||
border-color: rgb(255 255 255 / 72%);
|
||||
background: rgb(5 5 5 / 72%);
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-icon-button,
|
||||
.botsu-watch-room:fullscreen .botsu-watch-button,
|
||||
.botsu-watch-room:fullscreen .botsu-watch-quality select {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-control-source {
|
||||
display: inline-flex;
|
||||
border-left-color: rgb(255 255 255 / 20%);
|
||||
}
|
||||
|
||||
.botsu-watch-room--controls-hidden {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.botsu-watch-room--controls-hidden .botsu-watch-immersive-chrome {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.botsu-watch-room--controls-hidden .botsu-watch-statusbar,
|
||||
.botsu-watch-room--controls-hidden .botsu-watch-screen-title {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.botsu-watch-room--controls-hidden .botsu-watch-control-dock {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.botsu-watch-source-overlay {
|
||||
@@ -410,6 +646,65 @@
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-dialog {
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-dialog--twitch {
|
||||
width: min(660px, 100%);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro,
|
||||
.botsu-watch-provider-option {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro strong,
|
||||
.botsu-watch-provider-option strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro p,
|
||||
.botsu-watch-provider-option p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--botsu-watch-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-option {
|
||||
padding: 14px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: var(--botsu-watch-radius);
|
||||
background: var(--botsu-watch-surface);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-toggle[aria-pressed='true'],
|
||||
.botsu-watch-provider-toggle[aria-pressed='true']:hover:not(:disabled) {
|
||||
border-color: var(--botsu-watch-accent);
|
||||
background: var(--botsu-watch-accent);
|
||||
color: var(--botsu-watch-on-accent);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-note {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -568,24 +863,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.botsu-watch-room:fullscreen .botsu-watch-player-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-secondary-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.botsu-watch-stage {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.botsu-watch-screen {
|
||||
flex: 0 0 auto;
|
||||
min-height: 240px;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.botsu-watch-control-dock {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.botsu-watch-player-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-control-divider {
|
||||
.botsu-watch-secondary-actions {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.botsu-watch-room:fullscreen .botsu-watch-control-dock {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,28 +923,47 @@
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.botsu-watch-shared-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 54px minmax(90px, 1fr) 58px;
|
||||
gap: 8px;
|
||||
.botsu-watch-player-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.botsu-watch-play-button {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.botsu-watch-personal-row {
|
||||
flex-wrap: wrap;
|
||||
.botsu-watch-time--combined {
|
||||
min-width: 6.8rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.botsu-watch-volume {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
width: min(100%, 180px);
|
||||
}
|
||||
|
||||
.botsu-watch-quality {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.botsu-watch-control-source {
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button {
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-room--chat-open .botsu-watch-stage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-source-overlay {
|
||||
@@ -646,10 +982,21 @@
|
||||
.botsu-watch-source-dialog footer span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-option {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-actions .botsu-watch-button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.botsu-watch-spin {
|
||||
.botsu-watch-spin,
|
||||
.botsu-watch-immersive-chrome {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ const statePath = new URL('../../app/state/callEmbed.ts', import.meta.url);
|
||||
const embedPath = new URL('./WatchEmbedPlayer.tsx', import.meta.url);
|
||||
const jellyfinPath = new URL('./jellyfin.ts', import.meta.url);
|
||||
const twitchSdkPath = new URL('../../../vendor/twitch/embed-v1.js', import.meta.url);
|
||||
const twitchReadmePath = new URL('../../../vendor/twitch/README.md', import.meta.url);
|
||||
|
||||
test('Watch Room follows the Cinny voice-room layout and opens chat in the shared side panel', async () => {
|
||||
test('Watch Room follows the Cinny voice-room layout and owns its fullscreen-capable chat panel', async () => {
|
||||
const [watchView, watchCss, room, header, state] = await Promise.all([
|
||||
readFile(watchViewPath, 'utf8'),
|
||||
readFile(watchCssPath, 'utf8'),
|
||||
@@ -25,8 +26,12 @@ test('Watch Room follows the Cinny voice-room layout and opens chat in the share
|
||||
assert.doesNotMatch(watchView, /ChatsCircleIcon/);
|
||||
|
||||
assert.match(state, /watchChatAtom = atom<boolean>\(false\)/);
|
||||
assert.match(room, /watchView && watchChat/);
|
||||
assert.match(room, /<CallChatView onClose=\{\(\) => setWatchChat\(false\)\}/);
|
||||
assert.match(watchView, /chatContent/);
|
||||
assert.doesNotMatch(watchView, /CallChatView/);
|
||||
assert.match(room, /chatContent=\{/);
|
||||
assert.match(room, /title="Discussion"/);
|
||||
assert.match(room, /subtitle="Watch Room"/);
|
||||
assert.doesNotMatch(room, /watchView && watchChat/);
|
||||
assert.match(header, /function WatchChatButton/);
|
||||
assert.match(header, /isWatchRoom\(room\) && <WatchChatButton \/>/);
|
||||
assert.match(header, /Icons\.Message/);
|
||||
@@ -37,12 +42,41 @@ test('Watch Room follows the Cinny voice-room layout and opens chat in the share
|
||||
assert.doesNotMatch(watchCss, /grid-template-columns: minmax\(0, 1fr\) minmax\(320px, 380px\)/);
|
||||
});
|
||||
|
||||
test('Watch Room provides an OLED fullscreen player with fading VLC controls and inline chat', async () => {
|
||||
const [watchView, watchCss, room] = await Promise.all([
|
||||
readFile(watchViewPath, 'utf8'),
|
||||
readFile(watchCssPath, 'utf8'),
|
||||
readFile(roomPath, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(watchView, /const WATCH_CONTROLS_IDLE_MS = 2_800/);
|
||||
assert.match(watchView, /document\.fullscreenElement === roomRef\.current/);
|
||||
assert.match(watchView, /document\.exitFullscreen\(\)/);
|
||||
assert.match(watchView, /setControlsVisible\(false\)/);
|
||||
assert.match(watchView, /onPointerMove=\{revealControls\}/);
|
||||
assert.match(watchView, /<ArrowCounterClockwiseIcon/);
|
||||
assert.match(watchView, /<ArrowClockwiseIcon/);
|
||||
assert.match(watchView, /<ChatCircleIcon/);
|
||||
assert.match(watchView, /<CornersInIcon/);
|
||||
assert.match(watchView, /chatOpen && chatContent/);
|
||||
assert.match(room, /<CallChatView/);
|
||||
assert.match(watchView, /chatOpen \? 'Fermer la discussion' : 'Ouvrir la discussion'/);
|
||||
assert.doesNotMatch(room, /!callView && watchView && watchChat/);
|
||||
|
||||
assert.match(watchCss, /\.botsu-watch-room:fullscreen/);
|
||||
assert.match(watchCss, /\.botsu-watch-room--controls-hidden/);
|
||||
assert.match(watchCss, /cursor: none/);
|
||||
assert.match(watchCss, /\.botsu-watch-chat-panel/);
|
||||
assert.match(watchCss, /background: #000/);
|
||||
});
|
||||
|
||||
test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin playback', async () => {
|
||||
const [watchView, embed, jellyfin, twitchSdk] = await Promise.all([
|
||||
const [watchView, embed, jellyfin, twitchSdk, twitchReadme] = await Promise.all([
|
||||
readFile(watchViewPath, 'utf8'),
|
||||
readFile(embedPath, 'utf8'),
|
||||
readFile(jellyfinPath, 'utf8'),
|
||||
readFile(twitchSdkPath, 'utf8'),
|
||||
readFile(twitchReadmePath, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(watchView, /<WatchEmbedPlayer/);
|
||||
@@ -58,10 +92,26 @@ test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin pl
|
||||
assert.doesNotMatch(embed, /script\.crossOrigin = 'anonymous'/);
|
||||
assert.match(embed, /found\?\.remove\(\)/);
|
||||
assert.match(embed, /scripts\.delete\(src\)/);
|
||||
assert.match(embed, /youtubeCredentialMode/);
|
||||
assert.match(embed, /youtubeCredentialMode !== 'account'/);
|
||||
assert.match(embed, /iframe\.setAttribute\('credentialless', ''\)/);
|
||||
assert.match(watchView, /YOUTUBE_ACCOUNT_MODE_KEY/);
|
||||
assert.match(watchView, /Compte YouTube Premium/);
|
||||
assert.match(watchView, /window\.crossOriginIsolated/);
|
||||
assert.match(watchView, /createYouTubePremiumUrl/);
|
||||
assert.match(watchView, /Ce réglage reste sur cet appareil/);
|
||||
assert.match(embed, /\/vendor\/twitch\/embed-v1\.js/);
|
||||
assert.match(embed, /parent: \[window\.location\.hostname\]/);
|
||||
assert.match(embed, /twitchCredentialMode/);
|
||||
assert.match(embed, /credentialless: twitchCredentialMode !== 'account'/);
|
||||
assert.match(twitchSdk, /setAttribute\(['"]credentialless['"], ['"]['"]\)/);
|
||||
assert.match(twitchSdk, /delete playerOptions\.credentialless/);
|
||||
assert.match(twitchSdk, /credentialless &&/);
|
||||
assert.match(twitchReadme, /BOTSU-only `credentialless` player option/);
|
||||
assert.match(watchView, /TWITCH_ACCOUNT_MODE_KEY/);
|
||||
assert.match(watchView, /Compte Twitch/);
|
||||
assert.match(watchView, /createTwitchAccountUrl/);
|
||||
assert.match(watchView, /Aucun cookie, jeton Twitch/);
|
||||
assert.match(jellyfin, /Username: JELLYFIN_USERNAME, Pw: ''/);
|
||||
assert.match(jellyfin, /window\.sessionStorage/);
|
||||
assert.doesNotMatch(watchView, /api_key=/);
|
||||
|
||||
@@ -46,6 +46,8 @@ export enum StateEvent {
|
||||
BotsuVoxel = 'net.botsu.voxel',
|
||||
BotsuWatchSession = 'net.botsu.watch.session',
|
||||
BotsuStoryboardCard = 'net.botsu.storyboard.card',
|
||||
BotsuKanbanColumn = 'net.botsu.kanban.column',
|
||||
BotsuKanbanCard = 'net.botsu.kanban.card',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
@@ -64,6 +66,7 @@ export enum RoomType {
|
||||
Voxel = 'net.botsu.voxel',
|
||||
Watch = 'net.botsu.watch',
|
||||
Storyboard = 'net.botsu.storyboard',
|
||||
Kanban = 'net.botsu.kanban',
|
||||
}
|
||||
|
||||
export type MSpaceChildContent = {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"src/botsu/documents/*.tsx",
|
||||
"src/botsu/drawing/*.tsx",
|
||||
"src/botsu/moodboard/*.tsx",
|
||||
"src/botsu/kanban/*.tsx",
|
||||
"src/botsu/profile/*.tsx",
|
||||
"src/botsu/presence/*.tsx",
|
||||
"src/botsu/workspace/*.tsx",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"src/botsu/drawing/*.ts",
|
||||
"src/botsu/moodboard/model.ts",
|
||||
"src/botsu/moodboard/controller.ts",
|
||||
"src/botsu/kanban/*.ts",
|
||||
"src/botsu/profile/*.ts",
|
||||
"src/botsu/presence/*.ts",
|
||||
"src/botsu/start/model.ts",
|
||||
|
||||
@@ -4,6 +4,6 @@ Source: https://player.twitch.tv/js/embed/v1.js
|
||||
|
||||
Vendored for the BOTSU Watch Room because `test.botsu.net` and `client.botsu.net` run with cross-origin isolation. The upstream SDK response does not expose CORS/CORP headers, so loading it directly is blocked by COEP.
|
||||
|
||||
Local change: the iframe created by the SDK receives the HTML `credentialless` attribute before its `src` is assigned. This preserves cross-origin isolation while allowing Twitch's official player and command API to run.
|
||||
Local change: the SDK accepts a BOTSU-only `credentialless` player option, removes it before building Twitch's query string, and adds the HTML `credentialless` attribute before `src` unless that option is `false`. This preserves cross-origin isolation by default while allowing an explicitly enabled local account mode on pages that are not cross-origin isolated.
|
||||
|
||||
When refreshing the upstream file, reapply that single change and rerun `src/botsu/watch/watch-ui.test.ts` plus the live provider probe.
|
||||
When refreshing the upstream file, reapply that conditional iframe change and rerun `src/botsu/watch/watch-ui.test.ts` plus the live provider probe.
|
||||
|
||||
@@ -603,14 +603,17 @@
|
||||
}
|
||||
var m = r(663);
|
||||
function g(e, t) {
|
||||
const credentialless = false !== e.credentialless,
|
||||
playerOptions = Object.assign({}, e);
|
||||
delete playerOptions.credentialless;
|
||||
const r = `${`https://${t}.twitch.tv`}?${m.stringify(
|
||||
Object.assign(Object.assign({}, e), {
|
||||
Object.assign(Object.assign({}, playerOptions), {
|
||||
parent: b(e.parent),
|
||||
referrer: document.location.href,
|
||||
})
|
||||
)}`,
|
||||
n = document.createElement('iframe');
|
||||
n.setAttribute('credentialless', ''),
|
||||
credentialless && n.setAttribute('credentialless', ''),
|
||||
n.setAttribute('src', r),
|
||||
n.setAttribute('allowfullscreen', ''),
|
||||
n.setAttribute('scrolling', 'no'),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile, readdir, unlink } from "node:fs/promises";
|
||||
import { link, mkdir, readFile, rename, writeFile, readdir, unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
TcgAcquisitionMethod,
|
||||
@@ -39,6 +39,28 @@ const writeJsonAtomic = async (path: string, value: unknown): Promise<void> => {
|
||||
await rename(tmpPath, path);
|
||||
};
|
||||
|
||||
const writeJsonExclusive = async (path: string, value: unknown): Promise<void> => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmpPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let published = false;
|
||||
let operationFailed = false;
|
||||
try {
|
||||
await writeFile(tmpPath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
await link(tmpPath, path);
|
||||
published = true;
|
||||
} catch (error) {
|
||||
operationFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await unlink(tmpPath);
|
||||
} catch (cleanupError) {
|
||||
const missing = (cleanupError as NodeJS.ErrnoException).code === "ENOENT";
|
||||
if (!missing && !operationFailed && !published) throw cleanupError;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tryReadJsonFile = async (path: string): Promise<unknown> => {
|
||||
let raw: string;
|
||||
try {
|
||||
@@ -298,12 +320,12 @@ export class TcgCardStore {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to register a first discovery. Uses an atomic file write (rename)
|
||||
* to ensure only one writer wins — if the file already exists, the existing
|
||||
* discovery is returned and the caller's claim is rejected.
|
||||
* Attempt to register a first discovery. Uses an atomic hard-link
|
||||
* create-if-absent operation so only one writer wins. If the destination
|
||||
* already exists, the existing discovery is returned.
|
||||
*
|
||||
* This is the unique constraint: one file per combinationId, created
|
||||
* atomically. Concurrent calls will race, and only one rename succeeds.
|
||||
* atomically. Concurrent calls race, and only one hard link succeeds.
|
||||
*/
|
||||
async claimDiscovery(
|
||||
combinationId: string,
|
||||
@@ -346,11 +368,9 @@ export class TcgCardStore {
|
||||
},
|
||||
};
|
||||
}
|
||||
// Race window: another process might create the file between our check
|
||||
// and write. Atomic rename handles this: we write to a temp file, then
|
||||
// try to rename. On most systems rename overwrites, but we use a
|
||||
// pre-check + exclusive create pattern.
|
||||
await writeJsonAtomic(path, { version: DISCOVERY_VERSION, ...record });
|
||||
// Linking a complete unique temporary file gives the destination a true
|
||||
// atomic create-if-absent operation: only one concurrent claimant wins.
|
||||
await writeJsonExclusive(path, { version: DISCOVERY_VERSION, ...record });
|
||||
return { discovered: true, record };
|
||||
} catch (error) {
|
||||
// If another process created the file between check and write, read it
|
||||
|
||||