feat(botsu): park documents behind files
This commit is contained in:
@@ -78,6 +78,7 @@ import { CallStatusRenderer } from './CallStatusRenderer';
|
||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||
import { BotsuEmbed, BotsuFrame, BotsuLauncher, BotsuNav, BotsuServices } from '../../botsu/shell';
|
||||
import { BotsuDocumentEditor, BotsuDocuments } from '../../botsu/documents';
|
||||
import { DocumentSyncProvider } from '../../botsu/documents/DocumentSyncContext';
|
||||
import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
@@ -136,15 +137,17 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
<ClientBindAtoms>
|
||||
<ClientNonUIFeatures>
|
||||
<CallEmbedProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<DocumentSyncProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
</DocumentSyncProvider>
|
||||
<CallStatusRenderer />
|
||||
</CallEmbedProvider>
|
||||
<SearchModalRenderer />
|
||||
|
||||
@@ -37,7 +37,7 @@ test('catalogue identifiers and native paths are unique', () => {
|
||||
|
||||
test('catalogue records live and planned applications explicitly', () => {
|
||||
assert.equal(getApp('discussions')?.availability, 'available');
|
||||
assert.equal(getApp('documents')?.availability, 'available');
|
||||
assert.equal(getApp('documents')?.availability, 'planned');
|
||||
assert.equal(getApp('tables')?.launch.mode, 'iframe');
|
||||
assert.equal(getApp('transfers')?.launch.mode, 'external');
|
||||
assert.equal(getApp('missing'), undefined);
|
||||
|
||||
@@ -34,9 +34,9 @@ export const botsuApps: readonly BotsuApp[] = [
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
description: 'Brouillons BOTSU natifs, collaboration Yjs en préparation.',
|
||||
description: 'Pause: socle fichiers prioritaire avant les documents collaboratifs.',
|
||||
roles: ['member', 'admin'],
|
||||
availability: 'available',
|
||||
availability: 'planned',
|
||||
launch: { mode: 'native', path: '/botsu/documents/' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { CSSProperties, useMemo, useRef } from 'react';
|
||||
import { color, config, vars } from 'folds';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { BotsuPresence } from '../presence/BotsuPresence';
|
||||
import { DocumentSyncProvider } from '../documents/DocumentSyncContext';
|
||||
import { PixelCanvasProvider } from '../start/PixelCanvasContext';
|
||||
import '@botsu/ui/styles.css';
|
||||
import './shell.css';
|
||||
@@ -49,22 +48,20 @@ export function BotsuFrame() {
|
||||
return (
|
||||
<div ref={rootRef} className="botsu-theme botsu-shell" style={botsuCinnyThemeVars as CSSProperties}>
|
||||
<PixelCanvasProvider>
|
||||
<DocumentSyncProvider>
|
||||
<div className="botsu-suite-strip">
|
||||
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.to}>
|
||||
{index > 0 && <span aria-hidden="true">/</span>}
|
||||
<Link to={crumb.to}>{crumb.label}</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
<BotsuPresence />
|
||||
</div>
|
||||
<main className="botsu-shell-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</DocumentSyncProvider>
|
||||
<div className="botsu-suite-strip">
|
||||
<nav className="botsu-breadcrumb" aria-label="Chemin BOTSU">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.to}>
|
||||
{index > 0 && <span aria-hidden="true">/</span>}
|
||||
<Link to={crumb.to}>{crumb.label}</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
<BotsuPresence />
|
||||
</div>
|
||||
<main className="botsu-shell-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</PixelCanvasProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,8 +56,7 @@ test('BOTSU shell keeps a minimal presence strip above the application surface',
|
||||
assert.match(frame, /'--botsu-color-accent': color\.Primary\.Main/);
|
||||
assert.match(frame, /'--botsu-font-family': 'var\(--font-secondary\)'/);
|
||||
assert.match(frame, /style=\{botsuCinnyThemeVars as CSSProperties\}/);
|
||||
assert.match(frame, /<DocumentSyncProvider>/);
|
||||
assert.ok(frame.indexOf('<DocumentSyncProvider>') < frame.indexOf('<BotsuPresence />'));
|
||||
assert.doesNotMatch(frame, /DocumentSyncProvider/);
|
||||
assert.match(frame, /<Link to=\{crumb\.to\}>\{crumb\.label\}<\/Link>/);
|
||||
assert.doesNotMatch(frame, /const isBotsuHome|className="botsu-draw-toggle"/);
|
||||
assert.equal(frame.match(/<BotsuPresence \/>/g)?.length, 1);
|
||||
@@ -256,6 +255,7 @@ test('BOTSU route restores the default left page panel', async () => {
|
||||
assert.match(nav, /<BotsuNavLabel icon=\{getAppNavIcon\(app\.id\)\}>/);
|
||||
assert.match(nav, /<BotsuNavLabel icon=\{getServiceNavIcon\(service\.label\)\}>/);
|
||||
assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/);
|
||||
assert.match(nav, /APP_NAV_IDS = new Set\(\['discussions', 'documents', 'tables', 'files'\]\)/);
|
||||
assert.match(nav, /appId === 'documents'\) return Icons\.File/);
|
||||
assert.match(nav, /appId === 'tables'\) return Icons\.Category/);
|
||||
assert.match(nav, /appId === 'files'\) return Icons\.Attachment/);
|
||||
@@ -313,7 +313,7 @@ test('BOTSU start page exposes a single-screen searchable draw surface', async (
|
||||
assert.doesNotMatch(startPage, /presence-api|espace de dessin communautaire/);
|
||||
assert.match(startPage, /<BotsuPresence \/>/);
|
||||
assert.match(startPage, /<PixelCanvasProvider>/);
|
||||
assert.match(startPage, /récents/);
|
||||
assert.doesNotMatch(startPage, /botsu-start-recents|récents/);
|
||||
assert.doesNotMatch(startPage, /botsu-start-section--apps|botsu-start-section--services/);
|
||||
assert.doesNotMatch(startCss, /botsu-start-links|botsu-start-section/);
|
||||
assert.match(startPage, /variant="background"/);
|
||||
|
||||
@@ -6,13 +6,8 @@ import { botsuApps, getVisibleApps, type BotsuRole } from '../apps/catalog';
|
||||
import { buildLaunchPlan } from '../apps/launcher';
|
||||
import { BotsuPresence } from '../presence/BotsuPresence';
|
||||
import { PixelCanvasProvider } from './PixelCanvasContext';
|
||||
import {
|
||||
getOptionalBotsuDocumentPath,
|
||||
listBotsuDocumentDrafts,
|
||||
type BotsuDocumentDraft,
|
||||
} from '../documents/model';
|
||||
import { getBotsuEmbedPath } from '../../app/pages/pathUtils';
|
||||
import { formatBotsuClock, getRecentBotsuDocuments, searchBotsuStartItems } from './model';
|
||||
import { formatBotsuClock, searchBotsuStartItems } from './model';
|
||||
import { BotsuPixelCanvas } from './BotsuPixelCanvas';
|
||||
import {
|
||||
createCookieController,
|
||||
@@ -92,25 +87,7 @@ const botsuCinnyThemeVars = {
|
||||
'--botsu-font-family': 'var(--font-secondary)',
|
||||
} satisfies BotsuThemeStyle;
|
||||
|
||||
const readLocalDrafts = (): BotsuDocumentDraft[] => {
|
||||
try {
|
||||
return listBotsuDocumentDrafts(window.localStorage);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
function StartSearchAction({ item }: { item: ReturnType<typeof searchBotsuStartItems>[number] }) {
|
||||
if (item.kind === 'document') {
|
||||
const path = getOptionalBotsuDocumentPath(item.document.id);
|
||||
return path ? (
|
||||
<Link className="botsu-start-result" to={path}>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const plan = buildLaunchPlan(item.app);
|
||||
const content = (
|
||||
<>
|
||||
@@ -146,7 +123,6 @@ export function BotsuStartPage() {
|
||||
const mx = useMatrixClient();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [query, setQuery] = useState('');
|
||||
const [drafts] = useState(readLocalDrafts);
|
||||
const [cookies, setCookies] = useState<CookieClientState>(createInitialCookieState);
|
||||
const [cookieHudVisible, setCookieHudVisible] = useState(false);
|
||||
const [averageClicksPerSecond, setAverageClicksPerSecond] = useState(0);
|
||||
@@ -158,8 +134,7 @@ export function BotsuStartPage() {
|
||||
const startPageRef = useRef<HTMLDivElement>(null);
|
||||
const reducedMotionRef = useRef(false);
|
||||
const apps = useMemo(() => getVisibleApps(new Set<BotsuRole>(['member']), botsuApps), []);
|
||||
const recentDocuments = useMemo(() => getRecentBotsuDocuments(drafts, 3), [drafts]);
|
||||
const results = useMemo(() => searchBotsuStartItems(query, apps, drafts), [apps, drafts, query]);
|
||||
const results = useMemo(() => searchBotsuStartItems(query, apps, []), [apps, query]);
|
||||
const cookieVelocity = averageClicksPerSecond * cookies.cookiesPerClick + cookies.cookiesPerSecond;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -319,25 +294,6 @@ export function BotsuStartPage() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{recentDocuments.length > 0 && (
|
||||
<section className="botsu-start-recents" aria-labelledby="botsu-start-recents-title">
|
||||
<h3 id="botsu-start-recents-title">récents</h3>
|
||||
<ul>
|
||||
{recentDocuments.map((draft) => {
|
||||
const path = getOptionalBotsuDocumentPath(draft.id);
|
||||
return path ? (
|
||||
<li key={draft.id}>
|
||||
<Link to={path}>
|
||||
<strong>{draft.title}</strong>
|
||||
<span>document</span>
|
||||
</Link>
|
||||
</li>
|
||||
) : null;
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
{cookieHudVisible && (
|
||||
<footer className="botsu-start-cookie-footer" aria-live="polite" role="status">
|
||||
|
||||
@@ -46,14 +46,14 @@ test('selects recent local documents without mutating the source', () => {
|
||||
assert.throws(() => getRecentBotsuDocuments(source, 0), TypeError);
|
||||
});
|
||||
|
||||
test('searches available applications and local documents with bounded normalized input', () => {
|
||||
test('searches available applications without exposing paused local documents', () => {
|
||||
assert.deepEqual(
|
||||
searchBotsuStartItems(' DOCUMENTS ', apps, drafts).map((item) => [item.kind, item.label]),
|
||||
[['app', 'Documents']]
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchBotsuStartItems('reunion', apps, drafts).map((item) => [item.kind, item.label]),
|
||||
[['document', 'Réunion produit']]
|
||||
[]
|
||||
);
|
||||
assert.deepEqual(searchBotsuStartItems('tableaux', apps, drafts), []);
|
||||
assert.deepEqual(searchBotsuStartItems('', apps, drafts), []);
|
||||
|
||||
@@ -46,20 +46,13 @@ export const formatBotsuClock = (date: Date): string => {
|
||||
};
|
||||
|
||||
export type BotsuStartSearchItem =
|
||||
| {
|
||||
kind: 'app';
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
app: BotsuApp;
|
||||
}
|
||||
| {
|
||||
kind: 'document';
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
document: BotsuDocumentDraft;
|
||||
};
|
||||
{
|
||||
kind: 'app';
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
app: BotsuApp;
|
||||
};
|
||||
|
||||
const normalizeSearchText = (value: string): string =>
|
||||
value
|
||||
@@ -88,6 +81,7 @@ export const searchBotsuStartItems = (
|
||||
if (!needle) return [];
|
||||
|
||||
const appItems: BotsuStartSearchItem[] = apps.flatMap((app) => {
|
||||
if (app.id === 'documents') return [];
|
||||
if (app.availability !== 'available') return [];
|
||||
const haystack = normalizeSearchText(`${app.label} ${app.description}`);
|
||||
if (!haystack.includes(needle)) return [];
|
||||
@@ -102,18 +96,5 @@ export const searchBotsuStartItems = (
|
||||
];
|
||||
});
|
||||
|
||||
const documentItems: BotsuStartSearchItem[] = drafts.flatMap((draft) => {
|
||||
if (!normalizeSearchText(draft.title).includes(needle)) return [];
|
||||
return [
|
||||
{
|
||||
kind: 'document',
|
||||
id: draft.id,
|
||||
label: draft.title,
|
||||
description: 'Document local récent',
|
||||
document: draft,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...appItems, ...documentItems].slice(0, MAXIMUM_SEARCH_RESULTS);
|
||||
return appItems.slice(0, MAXIMUM_SEARCH_RESULTS);
|
||||
};
|
||||
|
||||
@@ -247,8 +247,7 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
|
||||
|
||||
.botsu-start-search-block input:focus-visible,
|
||||
.botsu-start-result:focus-visible,
|
||||
.botsu-start-web-result:focus-visible,
|
||||
.botsu-start-recents a:focus-visible {
|
||||
.botsu-start-web-result:focus-visible {
|
||||
outline: 2px solid var(--botsu-color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -310,65 +309,6 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-start-recents {
|
||||
grid-row: 4;
|
||||
align-self: start;
|
||||
width: min(36rem, 100%);
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: clamp(0.2rem, 2vh, 1rem);
|
||||
}
|
||||
|
||||
.botsu-start-recents h3 {
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--botsu-color-text) 42%, transparent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.botsu-start-recents ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.botsu-start-recents a {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 0.1rem 0;
|
||||
color: color-mix(in srgb, var(--botsu-color-text) 62%, transparent);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.botsu-start-recents strong,
|
||||
.botsu-start-recents span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.botsu-start-recents strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.botsu-start-recents span {
|
||||
color: color-mix(in srgb, var(--botsu-color-text) 36%, transparent);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.botsu-start-recents a:hover {
|
||||
color: var(--botsu-color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.28em;
|
||||
}
|
||||
|
||||
.botsu-pixel-canvas-frame {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
@@ -484,10 +424,6 @@ body:has([data-botsu-call-status]) .botsu-start-cookie-footer {
|
||||
gap: 0.75rem;
|
||||
padding-block: 1.4rem;
|
||||
}
|
||||
|
||||
.botsu-start-recents {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
|
||||
@@ -18,11 +18,9 @@ import { getOptionalBotsuDocumentPath } from '../documents/model';
|
||||
import { readMatrixWorkspaceState } from './state';
|
||||
import { canEditMatrixWorkspace } from './permissions';
|
||||
import {
|
||||
addLocalDocumentWorkspaceDraftTab,
|
||||
addWorkspaceDraftTab,
|
||||
buildMatrixWorkspaceEvent,
|
||||
createWorkspaceSubmissionLock,
|
||||
getSuggestedWorkspaceDocumentTitle,
|
||||
hasLiveWorkspaceEventConflict,
|
||||
readWorkspaceEditorSnapshot,
|
||||
removeWorkspaceDraftTab,
|
||||
@@ -102,27 +100,6 @@ export function BotsuWorkspaceBar({ room }: { room: Room }) {
|
||||
);
|
||||
};
|
||||
|
||||
const addDocumentTab = () => {
|
||||
try {
|
||||
setDraftTabs(
|
||||
(current) =>
|
||||
addLocalDocumentWorkspaceDraftTab(
|
||||
current,
|
||||
window.localStorage,
|
||||
() => crypto.randomUUID(),
|
||||
() => crypto.randomUUID(),
|
||||
Date.now,
|
||||
getSuggestedWorkspaceDocumentTitle(room)
|
||||
).tabs
|
||||
);
|
||||
setError(undefined);
|
||||
} catch {
|
||||
setError({
|
||||
kind: 'validation',
|
||||
message: 'Impossible de créer le document local. Vérifie le stockage du navigateur.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submit: FormEventHandler<HTMLFormElement> = async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -293,16 +270,7 @@ export function BotsuWorkspaceBar({ room }: { room: Room }) {
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
aria-label="Créer et attacher un document"
|
||||
className={css.WorkspaceIconButton}
|
||||
title="Créer et attacher un document"
|
||||
type="button"
|
||||
onClick={addDocumentTab}
|
||||
disabled={draftTabs.length >= MAXIMUM_WORKSPACE_TABS}
|
||||
>
|
||||
d+
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Fermer la configuration Botsu"
|
||||
className={css.WorkspaceIconButton}
|
||||
|
||||
@@ -195,9 +195,9 @@ test('conversation workspace bar stays discreet with integrated controls', async
|
||||
assert.match(bar, />\s*\+\s*<\/button>/);
|
||||
assert.match(bar, />\s*×\s*<\/button>/);
|
||||
assert.match(bar, />\s*✓\s*<\/button>/);
|
||||
assert.match(bar, /aria-label="Créer et attacher un document"/);
|
||||
assert.match(bar, /getSuggestedWorkspaceDocumentTitle\(room\)/);
|
||||
assert.match(bar, /addLocalDocumentWorkspaceDraftTab/);
|
||||
assert.doesNotMatch(bar, /aria-label="Créer et attacher un document"/);
|
||||
assert.doesNotMatch(bar, /getSuggestedWorkspaceDocumentTitle\(room\)/);
|
||||
assert.doesNotMatch(bar, /addLocalDocumentWorkspaceDraftTab/);
|
||||
assert.match(bar, /<Link className=\{css\.WorkspaceTabLink\} to=\{documentPath\}>/);
|
||||
assert.match(css, /export const WorkspaceIconButton = style/);
|
||||
assert.match(css, /backgroundColor: 'transparent'/);
|
||||
|
||||
Reference in New Issue
Block a user