Docs: a workspace knowledge base people and agents write together
- services/docs (g1t-docs-service): spaces (workspace, team, private) with view, comment, edit and manage roles and linked projects; a page tree with favorites, recent, trash, templates and export; each page a live Yjs document in a Durable Object, saved as Markdown for the read view, search (FTS5), backlinks and history with diffs and restore; files in R2 behind a FileStore - The editor: slash menu, headings, lists, to-dos, toggles, callouts, tables, code, diagrams, math, live embeds of issues, pull requests, threads and pages, @people and agents, [[page links, dates, uploads, live cursors and faces, inline comments - Agents read, search, write and edit pages for the person they act for and only what everyone reading can see; where a space doesn't let agents edit, their edits are suggestions people accept or reject inline, attributed in history - Agents' tools: search_docs, read_page, list_doc_spaces, edit_page, create_page; available to people without Code too - Docs mode in the rail is real: home with a project filter, sidebar with space trees; the guide is rewritten
| 82 | 82 | { label: 'Routines', slug: 'guides/agent-routines' }, | |
| 83 | 83 | { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' }, | |
| 84 | 84 | { label: 'What agents can do for whom', slug: 'guides/agent-access' }, | |
| 85 | − | { label: 'Docs', slug: 'guides/docs', badge: { text: 'Soon', variant: 'default' } }, | |
| 85 | + | { label: 'Docs', slug: 'guides/docs' }, | |
| 86 | 86 | { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } }, | |
| 87 | 87 | ], | |
| 88 | 88 | }, |
| 1 | 1 | --- | |
| 2 | 2 | title: Docs | |
| 3 | − | description: Docs is coming to g1t. A workspace's specs, runbooks and decisions, written together, read by agents and kept current by them. | |
| 3 | + | description: Write a workspace's specs, runbooks, decisions and onboarding together, live, with your agents. Spaces and pages, a block editor with diagrams and math, comments, suggestions from agents you accept or reject, full history, templates, search by project, and Markdown export. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | + | import { Steps } from '@astrojs/starlight/components'; | |
| 6 | 7 | import Soon from '../../../components/Soon.astro'; | |
| 7 | 8 | import Aside from '../../../components/Aside.astro'; | |
| 8 | 9 | ||
| 9 | − | <Soon /> | |
| 10 | + | **Docs is the workspace's written knowledge**: specs, runbooks, decisions, | |
| 11 | + | onboarding and meeting notes, in the same place as the conversations and the | |
| 12 | + | code they describe. People and agents write it together. Agents read it before | |
| 13 | + | they answer, and when they change a page, you see exactly what they changed | |
| 14 | + | and decide whether it stays. | |
| 15 | + | ||
| 16 | + | Docs is open to every member of a workspace, on every plan. Open **Docs** in | |
| 17 | + | the rail on the left, or go to `g1t.sh/<workspace>/-/docs`. | |
| 18 | + | ||
| 19 | + | ## How it fits together | |
| 20 | + | ||
| 21 | + | | | | | |
| 22 | + | | --- | --- | | |
| 23 | + | | **Spaces** | Where pages live: one for the whole workspace, one per team or project, or a private one for a few people. Each space decides who can read and write it, and how agents change it. | | |
| 24 | + | | **Pages** | A tree inside each space. A page can hold other pages. Every page has an icon, an optional cover, owners, linked projects, history, comments and backlinks. | | |
| 25 | + | | **The editor** | Blocks you add with `/`: text, headings, lists, to-dos, toggles, callouts, code, tables, images and files, Mermaid diagrams, math, and live cards for issues, pull requests, channels, projects and other pages. | | |
| 26 | + | | **Agents** | Read the pages the person they work for can read. Suggest changes you accept or reject inline, or edit directly where a space allows it. Every change is attributed in the history. | | |
| 27 | + | ||
| 28 | + | Docs is its own mode, not a tab in a project. To see the pages about one | |
| 29 | + | project, filter Docs by that project. | |
| 30 | + | ||
| 31 | + | ## Spaces | |
| 32 | + | ||
| 33 | + | Every workspace starts with a **General** space for things everyone should | |
| 34 | + | know. Make more from **New space** on Docs' home, or **+** beside **Spaces** | |
| 35 | + | in the sidebar. | |
| 36 | + | ||
| 37 | + | A space is for one of three audiences: | |
| 38 | + | ||
| 39 | + | | Who it's for | Who can open it | | |
| 40 | + | | --- | --- | | |
| 41 | + | | **Everyone in the workspace** | Every member, with the space's base role. | | |
| 42 | + | | **A team** | Members of that team, with the space's base role. | | |
| 43 | + | | **Only its members** | The people, agents and teams listed in the space, and nobody else. Workspace owners don't see a private space unless they're added. | | |
| 44 | + | ||
| 45 | + | Whoever makes a space has full access to it. Owners of the workspace have full | |
| 46 | + | access to every workspace and team space. | |
| 47 | + | ||
| 48 | + | ### Roles | |
| 49 | + | ||
| 50 | + | Each person's role in a space is the highest that applies to them: the | |
| 51 | + | space's base role (for everyone, or for the team), and any role they were | |
| 52 | + | given in the space's **Members**, directly or through a team. | |
| 53 | + | ||
| 54 | + | | Role | Can | | |
| 55 | + | | --- | --- | | |
| 56 | + | | **Can view** | Read pages, their history and their comments. | | |
| 57 | + | | **Can comment** | Read, and comment on passages or whole pages. | | |
| 58 | + | | **Can edit** | Write and organize pages, accept or reject suggestions, restore versions, and move pages to the trash. | | |
| 59 | + | | **Full access** | Edit, change the space's settings and members, and delete pages from the trash for good. | | |
| 60 | + | ||
| 61 | + | Change them in the space's **Settings** (the gear beside the space in the | |
| 62 | + | sidebar, or **Settings** on the space's page): | |
| 63 | + | ||
| 64 | + | - **Members**: add a person, an agent or a team with a role; change or remove | |
| 65 | + | it. A private space always keeps at least one person with full access. | |
| 66 | + | - **Agents in this space**: **Suggest changes** (the default) or **Edit | |
| 67 | + | directly**. See [Agents and pages](#agents-and-pages). | |
| 68 | + | - **Projects**: the repositories the space is about, as `owner/name`. Docs | |
| 69 | + | can be filtered by them. | |
| 70 | + | - **Archive this space** takes it out of the sidebar and search. Its pages and | |
| 71 | + | history are kept. The General space can't be archived. | |
| 72 | + | ||
| 73 | + | ## Pages | |
| 74 | + | ||
| 75 | + | Make a page with **New page** on Docs' home or a space's page, **+** beside a | |
| 76 | + | space or a page in the sidebar (a page inside it), or from a | |
| 77 | + | [template](#templates). Give it a title, and press **Enter** to start | |
| 78 | + | writing. | |
| 79 | + | ||
| 80 | + | Above the title, **Add icon** gives the page an emoji and **Add cover** a | |
| 81 | + | banner. Under the title you see who edited it last and when, its owners, the | |
| 82 | + | projects it's linked to, and how long it takes to read. | |
| 83 | + | ||
| 84 | + | ### Organize | |
| 85 | + | ||
| 86 | + | - **Drag** a page in the sidebar onto another to put it inside, or onto the | |
| 87 | + | top edge of one to put it before it. Drop it on a space's name to move it | |
| 88 | + | to the top of that space. | |
| 89 | + | - **⋯ → Move** does the same from the page, including to another space. Pages | |
| 90 | + | inside a page move with it. | |
| 91 | + | - **⋯ → Duplicate** copies a page, content and all, next to it. | |
| 92 | + | - A page's address ends in its id: `/<workspace>/-/docs/engineering/release-plan-pag_01j…`. | |
| 93 | + | Renaming or moving a page keeps every link to it working. | |
| 94 | + | ||
| 95 | + | ### Favorites and recent | |
| 96 | + | ||
| 97 | + | Star a page (the star at the top of it) to keep it under **Favorites** in the | |
| 98 | + | sidebar. **Recent** lists the pages you opened last. | |
| 99 | + | ||
| 100 | + | ### Trash | |
| 101 | + | ||
| 102 | + | **⋯ → Move to trash** puts a page, and the pages inside it, in the **Trash** | |
| 103 | + | (in the sidebar). Anyone who can edit the space can restore it from there, | |
| 104 | + | with what was inside it. Deleting from the trash removes the page, its | |
| 105 | + | history and its comments for good, and needs full access to the space. | |
| 10 | 106 | ||
| 11 | − | **Docs is the workspace's written knowledge**: specs, runbooks, decisions | |
| 12 | − | and onboarding, in the same place as the conversations and the code they | |
| 13 | − | describe. It is not built yet. This page says what it will do, so you can | |
| 14 | − | plan for it. | |
| 107 | + | ## The editor | |
| 108 | + | ||
| 109 | + | Type `/` anywhere for the block menu, and keep typing to filter it. | |
| 15 | 110 | ||
| 16 | − | <Aside type="note" title="Until then"> | |
| 17 | − | Keep documentation in your repositories (`README.md`, a `docs/` folder), | |
| 18 | − | where it changes through pull requests like any code. When Docs ships, a | |
| 19 | − | project's `docs/` folder can appear as a read-only space beside the | |
| 20 | − | workspace's own, with one search and one tree over both. | |
| 111 | + | | Block | How to add it | In Markdown | | |
| 112 | + | | --- | --- | --- | | |
| 113 | + | | Text, Heading 1–3 | `/text`, `/h1`, or type `#`, `##`, `###` and a space | `#`, `##`, `###` | | |
| 114 | + | | Bulleted, numbered and to-do lists | `/bullet`, `/numbered`, `/todo`, or type `-`, `1.`, `[]` | `-`, `1.`, `- [ ]` | | |
| 115 | + | | Toggle | `/toggle` | `<details>` | | |
| 116 | + | | Quote | `/quote`, or type `>` | `>` | | |
| 117 | + | | Callout (info, warning, success) | `/callout`, `/warning`, `/tip`. Click its icon to change its kind. | `> [!NOTE]`, `> [!WARNING]`, `> [!TIP]`, `> [!CAUTION]` | | |
| 118 | + | | Divider | `/divider`, or type `---` | `---` | | |
| 119 | + | | Code, with syntax highlighting | `/code`, or type ` ``` ` and a language | fenced code | | |
| 120 | + | | Table | `/table` | GitHub tables | | |
| 121 | + | | Image, video, audio, file | `/image`, `/video`, `/audio`, `/file`: upload one, or paste a link | `![]()`, links | | |
| 122 | + | | Diagram | `/diagram` or `/mermaid`. **Edit source** to change it; it's drawn as you type. | ` ```mermaid ` | | |
| 123 | + | | Math | `/math` or `/latex`. Click the formula to change it. | `$$ … $$` | | |
| 124 | + | | Embed from g1t | `/embed`, then paste the address of an issue, pull request, channel, project or page. The card shows its live title and state (open, merged, closed). | a link | | |
| 125 | + | | Date | `/date` | the date | | |
| 126 | + | ||
| 127 | + | Inline: | |
| 128 | + | ||
| 129 | + | - **Bold**, *italic*, underline, ~~strikethrough~~, `code` and links from the | |
| 130 | + | toolbar that appears when you select text, or with Markdown as you type | |
| 131 | + | (`**bold**`, `_italic_`, `` `code` ``) and the usual shortcuts | |
| 132 | + | (<kbd>⌘ B</kbd>, <kbd>⌘ I</kbd>, <kbd>⌘ U</kbd>). | |
| 133 | + | - **`@`** mentions a person or an agent. A person mentioned in a page is | |
| 134 | + | told, once, in their notifications. | |
| 135 | + | - **`[[`** links to another page by title. The page you link to lists yours | |
| 136 | + | under **Linked from**. | |
| 137 | + | ||
| 138 | + | Every block has a handle on its left when you hover it: drag it to move the | |
| 139 | + | block, or open its menu to change its type, color or delete it. Press | |
| 140 | + | <kbd>Tab</kbd> to nest a block under the one above it. | |
| 141 | + | ||
| 142 | + | **Pasting Markdown** turns it into blocks. **⋯ → Copy as Markdown** puts the | |
| 143 | + | whole page on your clipboard as Markdown. | |
| 144 | + | ||
| 145 | + | Images and files you add are kept with the workspace and served from | |
| 146 | + | `g1tusercontent.com`, never from `g1t.sh`. Each can be up to 25 MB. | |
| 147 | + | Anyone with a file's address can open it, as with any shared link, so don't | |
| 148 | + | put a page's files where the page itself shouldn't go. | |
| 149 | + | ||
| 150 | + | <Aside type="note" title="Who can edit"> | |
| 151 | + | People who can only view or comment see the page live, with everyone's | |
| 152 | + | changes as they happen, but can't change it. The editor says so under the | |
| 153 | + | title. | |
| 21 | 154 | </Aside> | |
| 22 | 155 | ||
| 23 | − | ## What it will be | |
| 156 | + | ## Writing together | |
| 157 | + | ||
| 158 | + | Pages are live: everyone with a page open sees each other's changes as they | |
| 159 | + | type, with a cursor and name for each person. The faces at the top of the page | |
| 160 | + | are who's on it now, agents included while they work on it. | |
| 161 | + | ||
| 162 | + | Edits merge however many people type at once, even in the same paragraph, and | |
| 163 | + | nothing is lost if your connection drops: the page says **Offline** and | |
| 164 | + | sends your changes when you're back. | |
| 165 | + | ||
| 166 | + | ## Comments | |
| 167 | + | ||
| 168 | + | - **On a passage**: select text and choose **Comment**. The passage is | |
| 169 | + | highlighted, and the thread stays attached to it as the page changes. | |
| 170 | + | - **On the whole page**: write in **Discussion** at the bottom of the page. | |
| 171 | + | - **All of them**: the comments button at the top of the page opens them | |
| 172 | + | beside the text, in the order they appear in it. | |
| 173 | + | ||
| 174 | + | Reply in a thread, react to a comment, and **Resolve** a thread when it's | |
| 175 | + | done (you can reopen it). `@`-mention someone in a comment to tell them; they | |
| 176 | + | are told only if they can read the page. | |
| 24 | 177 | ||
| 25 | − | - **Spaces** hold pages: one per team or project, plus one for the whole | |
| 26 | − | workspace. | |
| 27 | − | - Docs is its own mode in the workspace's rail, not a tab in a project. To | |
| 28 | − | see one project's pages, filter Docs by that project. | |
| 29 | − | - **Pages** are edited together, live, with mentions of people, agents, | |
| 30 | − | issues, pull requests, channels and other pages. | |
| 31 | − | - Every page has **history**, **comments**, **backlinks** and **owners**. | |
| 178 | + | Anyone who can comment can start threads, reply, react and resolve. Only a | |
| 179 | + | comment's author edits it; its author or an editor deletes it; editors delete | |
| 180 | + | whole threads. | |
| 32 | 181 | ||
| 33 | − | ## Agents and docs | |
| 182 | + | ## Agents and pages | |
| 34 | 183 | ||
| 35 | − | This is why Docs belongs next to Chat and Code: | |
| 184 | + | Agents work on Docs for a person, never on their own authority. An agent | |
| 185 | + | asked by you: | |
| 36 | 186 | ||
| 37 | − | | | | | |
| 187 | + | - **reads** only pages in spaces you can read. When it answers somewhere | |
| 188 | + | others will read the answer (a channel), it uses only pages everyone there | |
| 189 | + | can read. See [What agents can do for whom](/guides/agent-access/). | |
| 190 | + | - **suggests** a change where you can comment or edit. A suggestion is a | |
| 191 | + | tracked change: the blocks it replaces are struck through in place, and | |
| 192 | + | the card beside them (or above the page on a narrower screen) shows what | |
| 193 | + | it would write instead. | |
| 194 | + | - **edits directly** only where you can edit **and** the space's **Agents in | |
| 195 | + | this space** is **Edit directly**. Otherwise its edit becomes a | |
| 196 | + | suggestion. | |
| 197 | + | - **makes new pages** where you can edit, such as "write this up" in a | |
| 198 | + | thread. The page is yours: you're its owner, and it links back to where it | |
| 199 | + | came from. | |
| 200 | + | ||
| 201 | + | ### Accept or reject | |
| 202 | + | ||
| 203 | + | Each suggestion says which agent made it, who it was for, and why. | |
| 204 | + | ||
| 205 | + | - **Accept** applies it to the page, live for everyone on it. The history | |
| 206 | + | records it as "Suggested by @inky, accepted by @ana". | |
| 207 | + | - **Reject** drops it. Nothing on the page changes. | |
| 208 | + | - **Accept all** applies every open suggestion on the page, oldest first. | |
| 209 | + | ||
| 210 | + | Accepting needs edit access to the space. When the part of the page a | |
| 211 | + | suggestion changes was deleted meanwhile, the suggestion can't be applied and | |
| 212 | + | is marked stale. | |
| 213 | + | ||
| 214 | + | A page's owners are told when an agent suggests a change to it. | |
| 215 | + | ||
| 216 | + | <Aside type="tip" title="Ask in chat"> | |
| 217 | + | "@inky the rollout section of the release plan is out of date, fix it" in a | |
| 218 | + | channel works: Inky reads the page, and suggests or makes the change for | |
| 219 | + | you, depending on the space. | |
| 220 | + | </Aside> | |
| 221 | + | ||
| 222 | + | ## History | |
| 223 | + | ||
| 224 | + | **⋯ → History** lists every version of the page: when, who (people and agents), | |
| 225 | + | and what kind of change it was. Choose a version to see what changed in it, | |
| 226 | + | line by line. | |
| 227 | + | ||
| 228 | + | A new version is recorded: | |
| 229 | + | ||
| 230 | + | - after a burst of editing, at most every 10 minutes while people type; | |
| 231 | + | - for every change an agent makes, and every suggestion accepted; | |
| 232 | + | - when a version is restored. | |
| 233 | + | ||
| 234 | + | **Restore this version** makes the page what it was then, as a new version, | |
| 235 | + | so nothing after it is lost. Restoring needs edit access. | |
| 236 | + | ||
| 237 | + | ## Templates | |
| 238 | + | ||
| 239 | + | Start a page with its structure already in place: **Templates** in the | |
| 240 | + | sidebar, the template row on Docs' home, or ask an agent to use one. | |
| 241 | + | ||
| 242 | + | g1t's templates: | |
| 243 | + | ||
| 244 | + | | Template | For | | |
| 38 | 245 | | --- | --- | | |
| 39 | − | | **Agents read it** | Pages are indexed with the rest of the workspace's context, so what is written down reaches every answer. A space can be pinned to an agent as required reading. | | |
| 40 | − | | **Agents write it** | An agent with write access to a space edits pages directly. Without it, its edit is a **suggestion**: tracked changes a person accepts or rejects inline, like a review. Every edit is attributed and in the page's history. | | |
| 41 | − | | **Pages know what they describe** | A page can cite code: paths, symbols, endpoints, settings. When a merged pull request changes something a page cites, the page is marked possibly stale and its owners are told. If an agent owns the page, it drafts the update. | | |
| 42 | − | | **Conversations become pages** | "Write this up" in a thread makes a page from it, linked both ways. Decisions made in chat get a home. | | |
| 246 | + | | **Meeting notes** | Attendees, agenda, decisions, action items. | | |
| 247 | + | | **Spec / RFC** | Problem, goals and non-goals, design with a diagram, alternatives, rollout, open questions. | | |
| 248 | + | | **Decision record** | Context, the decision, options compared, consequences. | | |
| 249 | + | | **Runbook** | Symptoms, checks, the fix, when to escalate. | | |
| 250 | + | | **Onboarding** | A new teammate's first day and week, people to know, links. | | |
| 251 | + | | **Project brief** | Why, what done looks like, scope, team, milestones, risks. | | |
| 252 | + | | **Postmortem** | Severity, timeline, root cause, what went well and badly, action items. | | |
| 253 | + | | **Weekly update** | Shipped, in progress, next, blocked. | | |
| 254 | + | ||
| 255 | + | Save any page as one of your workspace's templates with **⋯ → Save as | |
| 256 | + | template**. Everyone in the workspace can use it. Whoever saved a template, | |
| 257 | + | or an owner, can delete it. | |
| 258 | + | ||
| 259 | + | ## Search and projects | |
| 260 | + | ||
| 261 | + | The search box at the top of the sidebar, and **Search docs** on Docs' home, | |
| 262 | + | look through the titles and text of every page you can read. Narrow it to one | |
| 263 | + | space or one project. | |
| 264 | + | ||
| 265 | + | A page belongs to a project when the page, or its space, is linked to the | |
| 266 | + | project. On Docs' home, **All projects** filters the recently edited pages and | |
| 267 | + | the spaces to one project. | |
| 268 | + | ||
| 269 | + | Agents search the same way, and only find what the people they answer can | |
| 270 | + | read. | |
| 43 | 271 | ||
| 44 | − | The **Technical Writer** [template](/guides/agents/#role-templates) is | |
| 45 | − | already available: hire Inky from it in a click. Today Inky works in chat; | |
| 46 | − | with Docs, Inky will keep a space current: | |
| 47 | − | updating pages after merges, writing release notes and the weekly summary, | |
| 48 | − | and turning incident threads into postmortems. | |
| 272 | + | ## Export | |
| 49 | 273 | ||
| 50 | − | ## How it will be stored | |
| 274 | + | - **⋯ → Export Markdown** downloads a page as a `.md` file. | |
| 275 | + | - **Export** on a space's page downloads the whole space as a zip of Markdown | |
| 276 | + | files, in folders that follow its page tree. | |
| 51 | 277 | ||
| 52 | − | Each space is a git repository in g1t's own storage, so history, blame and | |
| 53 | − | export come with it, agents edit pages with the same tools they use on | |
| 54 | − | code, and a self-hosted g1t keeps everything in git. | |
| 278 | + | Diagrams export as ` ```mermaid ` blocks, math as `$$` blocks, callouts as | |
| 279 | + | GitHub alerts, toggles as `<details>`, and embeds and page links as links, so | |
| 280 | + | the files read well anywhere Markdown does. | |
| 55 | 281 | ||
| 282 | + | ## Coming soon <Soon /> | |
| 283 | + | ||
| 284 | + | - **Pages that know what they describe.** A page that cites code (a path, an | |
| 285 | + | endpoint, a setting) is marked possibly stale when a merged pull request | |
| 286 | + | changes it, and its owners are told. When an agent owns the page, it drafts | |
| 287 | + | the update. | |
| 288 | + | - **"Write this up" from a thread** in one click, linked both ways. | |
| 289 | + | - **A documenter agent** that keeps a space current after merges and writes | |
| 290 | + | the weekly summary. | |
| 291 | + | - **A project's `docs/` folder as a read-only space**, in the same tree and | |
| 292 | + | search as your own spaces. Editing one of its pages opens a pull request. | |
| 293 | + | ||
| 56 | 294 | ## Pricing | |
| 57 | 295 | ||
| 58 | − | Docs will be included on every plan, like [Chat](/guides/chat/): pages, | |
| 59 | − | editing and history, with no seats. Files in pages are on the storage | |
| 60 | − | meter. See [pricing](https://g1t.sh/pricing). | |
| 296 | + | Docs is included on every plan: spaces, pages, live editing, comments, history | |
| 297 | + | and search, with no seats. Files in pages count toward storage. Agents' work | |
| 298 | + | on pages is charged to the agent, like any of its work; see | |
| 299 | + | [what an agent costs](/guides/agents/#what-an-agent-costs). |
| 24 | 24 | | --- | --- | | |
| 25 | 25 | | **[Chat](/guides/chat/)** | Channels, direct messages and threads, live. People and agents are members alike. | | |
| 26 | 26 | | **[Agents](/guides/agents/)** | Your workspace's colleagues: who they are, what they answer for, what they cost. `@g1t` orchestrates. | | |
| 27 | − | | **[Docs](/guides/docs/)** <Soon /> | Specs, runbooks and decisions, written together and kept current by agents. | | |
| 27 | + | | **[Docs](/guides/docs/)** | Specs, runbooks and decisions, written together live by people and agents, and searchable by project. | | |
| 28 | 28 | | **[Code](/concepts/overview/)** | Repositories, issues, pull requests, checks, the merge queue and deployments. | | |
| 29 | 29 | ||
| 30 | 30 | <CardGrid> |
| 694 | 694 | .card-md .prose pre { | |
| 695 | 695 | @apply p-2.5 text-xs leading-5; | |
| 696 | 696 | } | |
| 697 | + | ||
| 698 | + | /* | |
| 699 | + | * The Docs page editor (components/docs/editor.tsx) is BlockNote with its | |
| 700 | + | * shadcn components. Their classes are generated from the package, and | |
| 701 | + | * shadcn's token names map onto g1t's. `accent` and `muted` mean | |
| 702 | + | * something else in shadcn (a hover fill, a quiet fill), so inside the | |
| 703 | + | * editor they are redefined; g1t's own blocks there use the g1t tokens | |
| 704 | + | * directly. | |
| 705 | + | */ | |
| 706 | + | @source "../../../node_modules/@blocknote/shadcn/dist"; | |
| 707 | + | ||
| 708 | + | @theme { | |
| 709 | + | --color-background: var(--g1t-bg); | |
| 710 | + | --color-foreground: var(--g1t-fg); | |
| 711 | + | --color-popover: var(--g1t-surface); | |
| 712 | + | --color-popover-foreground: var(--g1t-fg); | |
| 713 | + | --color-card: var(--g1t-surface); | |
| 714 | + | --color-card-foreground: var(--g1t-fg); | |
| 715 | + | --color-primary: var(--g1t-accent); | |
| 716 | + | --color-primary-foreground: var(--g1t-bg); | |
| 717 | + | --color-secondary: var(--g1t-raised); | |
| 718 | + | --color-secondary-foreground: var(--g1t-fg); | |
| 719 | + | --color-muted-foreground: var(--g1t-muted); | |
| 720 | + | --color-accent-foreground: var(--g1t-fg); | |
| 721 | + | --color-destructive: var(--g1t-danger); | |
| 722 | + | --color-destructive-foreground: var(--g1t-bg); | |
| 723 | + | --color-border: var(--g1t-line); | |
| 724 | + | --color-input: var(--g1t-line); | |
| 725 | + | --color-ring: var(--g1t-accent); | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | .bn-shadcn { | |
| 729 | + | --color-accent: var(--g1t-raised); | |
| 730 | + | --color-muted: var(--g1t-raised); | |
| 731 | + | --bn-colors-editor-background: transparent; | |
| 732 | + | --bn-colors-editor-text: var(--g1t-fg); | |
| 733 | + | --bn-colors-menu-background: var(--g1t-surface); | |
| 734 | + | --bn-colors-menu-text: var(--g1t-fg); | |
| 735 | + | --bn-colors-hovered-background: var(--g1t-raised); | |
| 736 | + | --bn-colors-side-menu: var(--g1t-faint); | |
| 737 | + | --bn-colors-border: var(--g1t-line); | |
| 738 | + | --bn-border-radius: 8px; | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | .g1t-editor .bn-editor { | |
| 742 | + | font-family: var(--g1t-font-sans); | |
| 743 | + | font-size: 1rem; | |
| 744 | + | line-height: 1.65; | |
| 745 | + | background: transparent; | |
| 746 | + | color: var(--g1t-fg); | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | /* The drag handle and + sit in the margin, so the text lines up with the title. */ | |
| 750 | + | @media (min-width: 768px) { | |
| 751 | + | .g1t-editor .bn-container { | |
| 752 | + | margin-inline: -54px; | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | .g1t-editor .bn-block-content[data-content-type="heading"] h1, | |
| 757 | + | .g1t-editor .bn-block-content[data-content-type="heading"] h2, | |
| 758 | + | .g1t-editor .bn-block-content[data-content-type="heading"] h3 { | |
| 759 | + | font-family: var(--g1t-font-display); | |
| 760 | + | letter-spacing: -0.012em; | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | .g1t-editor .bn-inline-content code { | |
| 764 | + | font-family: var(--g1t-font-mono); | |
| 765 | + | font-size: 0.875em; | |
| 766 | + | padding: 0.1em 0.35em; | |
| 767 | + | border-radius: 4px; | |
| 768 | + | background: var(--g1t-raised); | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | .g1t-editor .bn-block-content[data-content-type="codeBlock"] { | |
| 772 | + | border-radius: 10px; | |
| 773 | + | background: var(--g1t-surface); | |
| 774 | + | border: 1px solid var(--g1t-line); | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | .g1t-editor .bn-thread-mark { | |
| 778 | + | background: color-mix(in srgb, var(--g1t-warn) 22%, transparent); | |
| 779 | + | border-bottom: 2px solid color-mix(in srgb, var(--g1t-warn) 60%, transparent); | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | .g1t-editor a { | |
| 783 | + | color: var(--g1t-accent); | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /* A page read before the editor loads: the same measure as the editor. */ | |
| 787 | + | .docs-read .prose { | |
| 788 | + | font-size: 1rem; | |
| 789 | + | line-height: 1.65; | |
| 790 | + | } | |
| 791 | + |
| 1 | + | /** | |
| 2 | + | * How Docs pages change things: a JSON request to `-/docs/api`, then the | |
| 3 | + | * sidebar and page load again so they show it. Returns the service's | |
| 4 | + | * `{ ok, value }` or `{ ok: false, error }`. | |
| 5 | + | */ | |
| 6 | + | import type { Result } from "@g1t/contracts"; | |
| 7 | + | import { useCallback, useState } from "react"; | |
| 8 | + | import { useRevalidator, useRouteLoaderData } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { DocsLayoutData } from "../../routes/workspace/docs/layout"; | |
| 11 | + | ||
| 12 | + | export function useDocsData(): DocsLayoutData | undefined { | |
| 13 | + | return useRouteLoaderData("routes/workspace/docs/layout") as DocsLayoutData | undefined; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | export async function docsRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> { | |
| 17 | + | try { | |
| 18 | + | const response = await fetch(`/${slug}/-/docs/api`, { | |
| 19 | + | method: "POST", | |
| 20 | + | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 21 | + | body: JSON.stringify({ intent, ...body }), | |
| 22 | + | }); | |
| 23 | + | return (await response.json()) as Result<T>; | |
| 24 | + | } catch { | |
| 25 | + | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } }; | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export async function docsQuery<T>(slug: string, query: Record<string, string>): Promise<Result<T>> { | |
| 30 | + | try { | |
| 31 | + | const response = await fetch(`/${slug}/-/docs/api?${new URLSearchParams(query)}`, { headers: { accept: "application/json" } }); | |
| 32 | + | return (await response.json()) as Result<T>; | |
| 33 | + | } catch { | |
| 34 | + | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } }; | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** `send(intent, body)`: makes the change, then reloads what shows it. `busy` while it works; `error` when it failed. */ | |
| 39 | + | export function useDocsAction(slug: string) { | |
| 40 | + | const revalidator = useRevalidator(); | |
| 41 | + | const [busy, setBusy] = useState(false); | |
| 42 | + | const [error, setError] = useState<string | null>(null); | |
| 43 | + | const send = useCallback( | |
| 44 | + | async <T>(intent: string, body: Record<string, unknown> = {}, options: { reload?: boolean } = {}): Promise<Result<T>> => { | |
| 45 | + | setBusy(true); | |
| 46 | + | setError(null); | |
| 47 | + | const result = await docsRequest<T>(slug, intent, body); | |
| 48 | + | setBusy(false); | |
| 49 | + | if (!result.ok) setError(result.error.message); | |
| 50 | + | else if (options.reload !== false) void revalidator.revalidate(); | |
| 51 | + | return result; | |
| 52 | + | }, | |
| 53 | + | [slug, revalidator], | |
| 54 | + | ); | |
| 55 | + | return { send, busy, error, setError }; | |
| 56 | + | } |
| 1 | + | /** | |
| 2 | + | * g1t's own blocks in the page editor, beside BlockNote's: callouts, | |
| 3 | + | * Mermaid diagrams, math, and cards for g1t things (an issue, a pull | |
| 4 | + | * request, a channel, a project, another page); and inline mentions of | |
| 5 | + | * people, agents and pages, and dates. The docs service reads the same | |
| 6 | + | * names and attributes when it writes Markdown (services/docs | |
| 7 | + | * src/markdown.ts), so keep the two in step. Browser-only: loaded with | |
| 8 | + | * the editor. | |
| 9 | + | */ | |
| 10 | + | import { BlockNoteSchema, createCodeBlockSpec, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core"; | |
| 11 | + | import { createReactBlockSpec, createReactInlineContentSpec } from "@blocknote/react"; | |
| 12 | + | import { codeBlockOptions } from "@blocknote/code-block"; | |
| 13 | + | import { AlertTriangle, CheckCircle2, CircleDot, FileText, GitPullRequest, Hash, Info, OctagonAlert, Package } from "lucide-react"; | |
| 14 | + | import { useEffect, useId, useState, type ReactNode } from "react"; | |
| 15 | + | ||
| 16 | + | /** What an embed shows, from the site (`-/docs/api?embed=`). */ | |
| 17 | + | export type EmbedCard = { kind: "issue" | "pull" | "channel" | "project" | "page" | "link"; title: string; subtitle: string | null; state: string | null; href: string }; | |
| 18 | + | ||
| 19 | + | const CALLOUTS = { | |
| 20 | + | info: { icon: <Info size={16} />, ring: "border-info/30 bg-info/8", text: "text-info" }, | |
| 21 | + | warning: { icon: <AlertTriangle size={16} />, ring: "border-warn/30 bg-warn/8", text: "text-warn" }, | |
| 22 | + | success: { icon: <CheckCircle2 size={16} />, ring: "border-success/30 bg-success/8", text: "text-success" }, | |
| 23 | + | danger: { icon: <OctagonAlert size={16} />, ring: "border-danger/30 bg-danger/8", text: "text-danger" }, | |
| 24 | + | } as const; | |
| 25 | + | ||
| 26 | + | export const Callout = createReactBlockSpec( | |
| 27 | + | { | |
| 28 | + | type: "callout", | |
| 29 | + | propSchema: { | |
| 30 | + | kind: { default: "info", values: ["info", "warning", "success", "danger"] }, | |
| 31 | + | backgroundColor: { default: "default" }, | |
| 32 | + | textColor: { default: "default" }, | |
| 33 | + | }, | |
| 34 | + | content: "inline", | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | render: ({ block, editor, contentRef }) => { | |
| 38 | + | const look = CALLOUTS[block.props.kind as keyof typeof CALLOUTS] ?? CALLOUTS.info; | |
| 39 | + | const next = { info: "warning", warning: "success", success: "danger", danger: "info" } as const; | |
| 40 | + | return ( | |
| 41 | + | <div className={`my-1 flex w-full gap-2.5 rounded-lg border px-3.5 py-2.5 ${look.ring}`}> | |
| 42 | + | <button | |
| 43 | + | type="button" | |
| 44 | + | contentEditable={false} | |
| 45 | + | aria-label="Change the callout's kind" | |
| 46 | + | disabled={!editor.isEditable} | |
| 47 | + | onClick={() => editor.updateBlock(block, { props: { kind: next[block.props.kind as keyof typeof next] ?? "info" } })} | |
| 48 | + | className={`mt-0.5 shrink-0 ${look.text}`} | |
| 49 | + | > | |
| 50 | + | {look.icon} | |
| 51 | + | </button> | |
| 52 | + | <div ref={contentRef} className="min-w-0 grow" /> | |
| 53 | + | </div> | |
| 54 | + | ); | |
| 55 | + | }, | |
| 56 | + | }, | |
| 57 | + | ); | |
| 58 | + | ||
| 59 | + | /** A diagram drawn from its Mermaid source, live as it is typed. */ | |
| 60 | + | function MermaidView({ code }: { code: string }) { | |
| 61 | + | const id = useId().replace(/[^a-zA-Z0-9]/g, ""); | |
| 62 | + | const [svg, setSvg] = useState<string | null>(null); | |
| 63 | + | const [error, setError] = useState<string | null>(null); | |
| 64 | + | useEffect(() => { | |
| 65 | + | let cancelled = false; | |
| 66 | + | const timer = setTimeout(async () => { | |
| 67 | + | try { | |
| 68 | + | const mermaid = (await import("mermaid")).default; | |
| 69 | + | mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict", fontFamily: "inherit" }); | |
| 70 | + | const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B"); | |
| 71 | + | if (!cancelled) { | |
| 72 | + | setSvg(svg); | |
| 73 | + | setError(null); | |
| 74 | + | } | |
| 75 | + | } catch (e) { | |
| 76 | + | if (!cancelled) setError(e instanceof Error ? e.message.split("\n")[0]! : "This diagram has an error."); | |
| 77 | + | } | |
| 78 | + | }, 250); | |
| 79 | + | return () => { | |
| 80 | + | cancelled = true; | |
| 81 | + | clearTimeout(timer); | |
| 82 | + | }; | |
| 83 | + | }, [code, id]); | |
| 84 | + | if (error) return <p className="text-xs text-danger">{error}</p>; | |
| 85 | + | if (!svg) return <p className="text-xs text-faint">Drawing…</p>; | |
| 86 | + | // Mermaid sanitizes what it draws (securityLevel "strict"). | |
| 87 | + | return <div className="flex justify-center overflow-x-auto [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function SourceEditor({ value, onChange, placeholder, mono = true, editable }: { value: string; onChange: (v: string) => void; placeholder: string; mono?: boolean; editable: boolean }) { | |
| 91 | + | const [draft, setDraft] = useState(value); | |
| 92 | + | useEffect(() => setDraft(value), [value]); | |
| 93 | + | if (!editable) return null; | |
| 94 | + | return ( | |
| 95 | + | <textarea | |
| 96 | + | value={draft} | |
| 97 | + | placeholder={placeholder} | |
| 98 | + | spellCheck={false} | |
| 99 | + | rows={Math.min(14, Math.max(2, draft.split("\n").length))} | |
| 100 | + | onChange={(e) => setDraft(e.target.value)} | |
| 101 | + | onBlur={() => draft !== value && onChange(draft)} | |
| 102 | + | onKeyDown={(e) => e.stopPropagation()} | |
| 103 | + | className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-[var(--g1t-accent)] ${mono ? "font-mono" : ""}`} | |
| 104 | + | /> | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | export const Mermaid = createReactBlockSpec( | |
| 109 | + | { type: "mermaid", propSchema: { code: { default: "flowchart LR\n A[Idea] --> B[Spec] --> C[Shipped]" } }, content: "none" }, | |
| 110 | + | { | |
| 111 | + | render: ({ block, editor }) => { | |
| 112 | + | const [editing, setEditing] = useState(false); | |
| 113 | + | return ( | |
| 114 | + | <div className="my-1 w-full rounded-lg border border-line bg-surface p-3" contentEditable={false}> | |
| 115 | + | <div className="mb-2 flex items-center justify-between text-[0.6875rem] font-medium tracking-wide text-faint uppercase"> | |
| 116 | + | <span>Diagram</span> | |
| 117 | + | {editor.isEditable && ( | |
| 118 | + | <button type="button" onClick={() => setEditing(!editing)} className="rounded px-1.5 py-0.5 normal-case hover:bg-raised hover:text-fg"> | |
| 119 | + | {editing ? "Done" : "Edit source"} | |
| 120 | + | </button> | |
| 121 | + | )} | |
| 122 | + | </div> | |
| 123 | + | {editing && <SourceEditor editable value={block.props.code} placeholder="flowchart LR A --> B" onChange={(code) => editor.updateBlock(block, { props: { code } })} />} | |
| 124 | + | <div className={editing ? "mt-3" : ""}> | |
| 125 | + | <MermaidView code={block.props.code} /> | |
| 126 | + | </div> | |
| 127 | + | </div> | |
| 128 | + | ); | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | ); | |
| 132 | + | ||
| 133 | + | /** A formula, typeset with KaTeX. */ | |
| 134 | + | function MathView({ expression, inline = false }: { expression: string; inline?: boolean }) { | |
| 135 | + | const [html, setHtml] = useState<string | null>(null); | |
| 136 | + | useEffect(() => { | |
| 137 | + | let cancelled = false; | |
| 138 | + | void (async () => { | |
| 139 | + | const [{ default: katex }] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]); | |
| 140 | + | const out = katex.renderToString(expression || "\\;", { throwOnError: false, displayMode: !inline, output: "html", trust: false, strict: "ignore" }); | |
| 141 | + | if (!cancelled) setHtml(out); | |
| 142 | + | })(); | |
| 143 | + | return () => { | |
| 144 | + | cancelled = true; | |
| 145 | + | }; | |
| 146 | + | }, [expression, inline]); | |
| 147 | + | if (html === null) return <code className="text-xs text-faint">{expression}</code>; | |
| 148 | + | // KaTeX escapes what it is given and allows no commands that run anything (trust: false). | |
| 149 | + | return <div className="overflow-x-auto text-fg" dangerouslySetInnerHTML={{ __html: html }} />; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | export const MathBlock = createReactBlockSpec( | |
| 153 | + | { type: "math", propSchema: { expression: { default: "e^{i\\pi} + 1 = 0" } }, content: "none" }, | |
| 154 | + | { | |
| 155 | + | render: ({ block, editor }) => { | |
| 156 | + | const [editing, setEditing] = useState(false); | |
| 157 | + | return ( | |
| 158 | + | <div className="group/math my-1 w-full rounded-lg px-3 py-2 hover:bg-surface" contentEditable={false}> | |
| 159 | + | {editing ? ( | |
| 160 | + | <SourceEditor editable value={block.props.expression} placeholder="\int_0^1 x^2\,dx" onChange={(expression) => editor.updateBlock(block, { props: { expression } })} /> | |
| 161 | + | ) : null} | |
| 162 | + | <button type="button" className="block w-full text-left" disabled={!editor.isEditable} onClick={() => setEditing(!editing)} aria-label={editing ? "Done editing the formula" : "Edit the formula"}> | |
| 163 | + | <MathView expression={block.props.expression} /> | |
| 164 | + | </button> | |
| 165 | + | </div> | |
| 166 | + | ); | |
| 167 | + | }, | |
| 168 | + | }, | |
| 169 | + | ); | |
| 170 | + | ||
| 171 | + | const EMBED_ICONS: Record<EmbedCard["kind"], ReactNode> = { | |
| 172 | + | issue: <CircleDot size={16} />, | |
| 173 | + | pull: <GitPullRequest size={16} />, | |
| 174 | + | channel: <Hash size={16} />, | |
| 175 | + | project: <Package size={16} />, | |
| 176 | + | page: <FileText size={16} />, | |
| 177 | + | link: <FileText size={16} />, | |
| 178 | + | }; | |
| 179 | + | ||
| 180 | + | /** The card for a g1t address, asked of the site as the reader (so it shows only what they may see). */ | |
| 181 | + | function EmbedView({ url, title }: { url: string; title: string }) { | |
| 182 | + | const [card, setCard] = useState<EmbedCard | null | "missing">(null); | |
| 183 | + | useEffect(() => { | |
| 184 | + | let cancelled = false; | |
| 185 | + | const workspace = window.location.pathname.split("/")[1] ?? ""; | |
| 186 | + | fetch(`/${workspace}/-/docs/api?embed=${encodeURIComponent(url)}`, { headers: { accept: "application/json" } }) | |
| 187 | + | .then((r) => r.json() as Promise<{ ok: boolean; value?: EmbedCard }>) | |
| 188 | + | .then((r) => !cancelled && setCard(r.ok && r.value ? r.value : "missing")) | |
| 189 | + | .catch(() => !cancelled && setCard("missing")); | |
| 190 | + | return () => { | |
| 191 | + | cancelled = true; | |
| 192 | + | }; | |
| 193 | + | }, [url]); | |
| 194 | + | const shown = card && card !== "missing" ? card : null; | |
| 195 | + | const state = shown?.state ?? null; | |
| 196 | + | const tone = state === "open" ? "text-success" : state === "merged" ? "text-merged" : state === "closed" ? "text-danger" : "text-[var(--g1t-muted)]"; | |
| 197 | + | return ( | |
| 198 | + | <a | |
| 199 | + | href={shown?.href ?? url} | |
| 200 | + | contentEditable={false} | |
| 201 | + | className="my-1 flex w-full items-center gap-3 rounded-lg border border-line bg-surface px-3.5 py-2.5 no-underline transition-colors hover:border-line-strong hover:bg-raised" | |
| 202 | + | > | |
| 203 | + | <span className={`shrink-0 ${tone}`}>{EMBED_ICONS[shown?.kind ?? "link"]}</span> | |
| 204 | + | <span className="min-w-0 grow"> | |
| 205 | + | <span className="block truncate text-sm font-medium text-fg">{shown?.title ?? (title || url)}</span> | |
| 206 | + | <span className="block truncate text-xs text-faint">{card === null ? "Loading…" : card === "missing" ? "You can't see this, or it's gone." : shown?.subtitle}</span> | |
| 207 | + | </span> | |
| 208 | + | {state && <span className={`shrink-0 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium capitalize ring-1 ring-current/30 ${tone}`}>{state}</span>} | |
| 209 | + | </a> | |
| 210 | + | ); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | export const Embed = createReactBlockSpec( | |
| 214 | + | { type: "embed", propSchema: { url: { default: "" }, title: { default: "" }, kind: { default: "link" } }, content: "none" }, | |
| 215 | + | { | |
| 216 | + | render: ({ block, editor }) => { | |
| 217 | + | const [draft, setDraft] = useState(""); | |
| 218 | + | if (!block.props.url) { | |
| 219 | + | if (!editor.isEditable) return <div contentEditable={false} />; | |
| 220 | + | return ( | |
| 221 | + | <form | |
| 222 | + | contentEditable={false} | |
| 223 | + | className="my-1 flex w-full gap-2 rounded-lg border border-dashed border-line p-2" | |
| 224 | + | onSubmit={(e) => { | |
| 225 | + | e.preventDefault(); | |
| 226 | + | const url = draft.trim(); | |
| 227 | + | if (url) editor.updateBlock(block, { props: { url } }); | |
| 228 | + | }} | |
| 229 | + | > | |
| 230 | + | <input | |
| 231 | + | autoFocus | |
| 232 | + | value={draft} | |
| 233 | + | onChange={(e) => setDraft(e.target.value)} | |
| 234 | + | onKeyDown={(e) => e.stopPropagation()} | |
| 235 | + | placeholder="Paste the address of an issue, pull request, channel, project or page" | |
| 236 | + | className="min-w-0 grow bg-transparent px-1.5 text-sm text-fg outline-none placeholder:text-faint" | |
| 237 | + | /> | |
| 238 | + | <button type="submit" className="rounded-md bg-raised px-2.5 py-1 text-xs font-medium text-fg hover:bg-line"> | |
| 239 | + | Embed | |
| 240 | + | </button> | |
| 241 | + | </form> | |
| 242 | + | ); | |
| 243 | + | } | |
| 244 | + | return <EmbedView url={block.props.url} title={block.props.title} />; | |
| 245 | + | }, | |
| 246 | + | }, | |
| 247 | + | ); | |
| 248 | + | ||
| 249 | + | /** A person, agent or page, inline. */ | |
| 250 | + | export const Mention = createReactInlineContentSpec( | |
| 251 | + | { | |
| 252 | + | type: "mention", | |
| 253 | + | propSchema: { kind: { default: "user" }, id: { default: "" }, name: { default: "" }, href: { default: "" } }, | |
| 254 | + | content: "none", | |
| 255 | + | }, | |
| 256 | + | { | |
| 257 | + | render: ({ inlineContent }) => { | |
| 258 | + | const p = inlineContent.props; | |
| 259 | + | if (p.kind === "page") { | |
| 260 | + | return ( | |
| 261 | + | <a href={p.href || "#"} className="rounded px-0.5 font-medium text-[var(--g1t-accent)] no-underline hover:underline"> | |
| 262 | + | <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" /> | |
| 263 | + | {p.name || "Untitled"} | |
| 264 | + | </a> | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | return <span className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--g1t-accent)_15%,transparent)] text-[var(--g1t-accent)]" : "bg-info/12 text-info"}`}>@{p.name}</span>; | |
| 268 | + | }, | |
| 269 | + | }, | |
| 270 | + | ); | |
| 271 | + | ||
| 272 | + | /** A date, inline. */ | |
| 273 | + | export const DateChip = createReactInlineContentSpec( | |
| 274 | + | { type: "date", propSchema: { date: { default: "" } }, content: "none" }, | |
| 275 | + | { | |
| 276 | + | render: ({ inlineContent }) => { | |
| 277 | + | const at = new Date(`${inlineContent.props.date}T12:00:00`); | |
| 278 | + | const label = Number.isNaN(at.getTime()) ? inlineContent.props.date : at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" }); | |
| 279 | + | return <span className="rounded bg-raised px-1 py-px text-[0.9em] text-fg-soft">{label}</span>; | |
| 280 | + | }, | |
| 281 | + | }, | |
| 282 | + | ); | |
| 283 | + | ||
| 284 | + | export const schema = BlockNoteSchema.create({ | |
| 285 | + | blockSpecs: { | |
| 286 | + | ...defaultBlockSpecs, | |
| 287 | + | codeBlock: createCodeBlockSpec(codeBlockOptions), | |
| 288 | + | callout: Callout(), | |
| 289 | + | mermaid: Mermaid(), | |
| 290 | + | math: MathBlock(), | |
| 291 | + | embed: Embed(), | |
| 292 | + | }, | |
| 293 | + | inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention, date: DateChip }, | |
| 294 | + | }); | |
| 295 | + | ||
| 296 | + | export type DocSchema = typeof schema; | |
| 297 | + | export type DocEditorInstance = typeof schema.BlockNoteEditor; |
| 1 | + | /** Lines standing in for a page while its editor loads (no editor code, so the server can draw it). */ | |
| 2 | + | export function EditorSkeleton() { | |
| 3 | + | return ( | |
| 4 | + | <div className="space-y-3 py-2" aria-busy="true" aria-label="Loading the editor"> | |
| 5 | + | {[92, 80, 86, 40].map((w, i) => ( | |
| 6 | + | <div key={i} className="h-4 animate-pulse rounded bg-raised motion-reduce:animate-none" style={{ width: `${w}%` }} /> | |
| 7 | + | ))} | |
| 8 | + | </div> | |
| 9 | + | ); | |
| 10 | + | } |
| 1 | + | /** | |
| 2 | + | * The page editor: BlockNote on the page's live Yjs document, with g1t's | |
| 3 | + | * own blocks (components/docs/blocks.tsx), live cursors and presence, | |
| 4 | + | * comments anchored to text, and agents' suggestions marked in place. | |
| 5 | + | * Loaded lazily and only in the browser (routes/workspace/docs/page.tsx), | |
| 6 | + | * so the page's server render stays light: until it loads, the reader | |
| 7 | + | * sees the page's Markdown. | |
| 8 | + | */ | |
| 9 | + | import "@blocknote/shadcn/style.css"; | |
| 10 | + | ||
| 11 | + | import { filterSuggestionItems, insertOrUpdateBlockForSlashMenu } from "@blocknote/core"; | |
| 12 | + | import { CommentsExtension, DefaultThreadStoreAuth, ThreadStoreAuth, type CommentData, type ThreadData } from "@blocknote/core/comments"; | |
| 13 | + | import { RESTYjsThreadStore, withCollaboration } from "@blocknote/core/yjs"; | |
| 14 | + | import { syntaxHighlighter } from "@blocknote/code-block"; | |
| 15 | + | import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react"; | |
| 16 | + | import { BlockNoteView } from "@blocknote/shadcn"; | |
| 17 | + | import type { DocFile, DocRole, DocSuggestion, DocsLiveEvent, Result } from "@g1t/contracts"; | |
| 18 | + | import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react"; | |
| 19 | + | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 20 | + | ||
| 21 | + | import { canDo, cursorColour } from "../../lib/docs"; | |
| 22 | + | import { schema, type DocEditorInstance } from "./blocks"; | |
| 23 | + | import { EditorSkeleton } from "./editor-skeleton"; | |
| 24 | + | import type { PageThread } from "./page-parts"; | |
| 25 | + | import { DocsProvider, type LiveStatus } from "./provider"; | |
| 26 | + | import { whoAre } from "./who"; | |
| 27 | + | ||
| 28 | + | /** Someone who can be mentioned: a person (by username) or an agent (by id). */ | |
| 29 | + | export type DocMentionable = { kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed?: string | null }; | |
| 30 | + | ||
| 31 | + | /** Someone on the page now. */ | |
| 32 | + | export type Presence = { client: number; key: string; name: string; kind: "user" | "agent"; color: string; avatar: string | null; me: boolean }; | |
| 33 | + | ||
| 34 | + | export type DocEditorProps = { | |
| 35 | + | slug: string; | |
| 36 | + | pageId: string; | |
| 37 | + | role: DocRole; | |
| 38 | + | me: { key: string; name: string; display_name: string; avatar: string | null }; | |
| 39 | + | mentionables: DocMentionable[]; | |
| 40 | + | usercontent: string; | |
| 41 | + | suggestions: DocSuggestion[]; | |
| 42 | + | showComments: boolean; | |
| 43 | + | onPresence?: (people: Presence[]) => void; | |
| 44 | + | onStatus?: (status: LiveStatus) => void; | |
| 45 | + | onEvent?: (event: DocsLiveEvent) => void; | |
| 46 | + | /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */ | |
| 47 | + | renderSuggestion?: (suggestion: DocSuggestion) => ReactNode; | |
| 48 | + | /** Comments on the whole page (not on a passage), live from the document. */ | |
| 49 | + | onPageThreads?: (threads: PageThread[]) => void; | |
| 50 | + | }; | |
| 51 | + | ||
| 52 | + | /** Nobody can do anything with threads: a reader's view. */ | |
| 53 | + | class ReadOnlyAuth extends ThreadStoreAuth { | |
| 54 | + | canCreateThread() { | |
| 55 | + | return false; | |
| 56 | + | } | |
| 57 | + | canAddComment(_t: ThreadData) { | |
| 58 | + | return false; | |
| 59 | + | } | |
| 60 | + | canUpdateComment(_c: CommentData) { | |
| 61 | + | return false; | |
| 62 | + | } | |
| 63 | + | canDeleteComment(_c: CommentData) { | |
| 64 | + | return false; | |
| 65 | + | } | |
| 66 | + | canDeleteThread(_t: ThreadData) { | |
| 67 | + | return false; | |
| 68 | + | } | |
| 69 | + | canResolveThread(_t: ThreadData) { | |
| 70 | + | return false; | |
| 71 | + | } | |
| 72 | + | canUnresolveThread(_t: ThreadData) { | |
| 73 | + | return false; | |
| 74 | + | } | |
| 75 | + | canAddReaction(_c: CommentData) { | |
| 76 | + | return false; | |
| 77 | + | } | |
| 78 | + | canDeleteReaction(_c: CommentData) { | |
| 79 | + | return false; | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | export default function DocEditor(props: DocEditorProps) { | |
| 84 | + | const { slug, pageId } = props; | |
| 85 | + | const [provider, setProvider] = useState<DocsProvider | null>(null); | |
| 86 | + | const [synced, setSynced] = useState(false); | |
| 87 | + | ||
| 88 | + | useEffect(() => { | |
| 89 | + | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 90 | + | const live = new DocsProvider(`${scheme}://${window.location.host}/${slug}/-/docs/live?page=${encodeURIComponent(pageId)}`); | |
| 91 | + | setProvider(live); | |
| 92 | + | setSynced(false); | |
| 93 | + | // Mount the editor once the document is here, so it never starts from | |
| 94 | + | // an empty page of its own; offline, after a moment, from what we have. | |
| 95 | + | const fallback = setTimeout(() => setSynced(true), 4000); | |
| 96 | + | const off = live.onStatus((status) => { | |
| 97 | + | props.onStatus?.(status); | |
| 98 | + | if (status === "synced") setSynced(true); | |
| 99 | + | }); | |
| 100 | + | const offEvent = live.onEvent((event) => props.onEvent?.(event)); | |
| 101 | + | return () => { | |
| 102 | + | clearTimeout(fallback); | |
| 103 | + | off(); | |
| 104 | + | offEvent(); | |
| 105 | + | live.destroy(); | |
| 106 | + | }; | |
| 107 | + | // The callbacks are read through props each time; reconnect only for a new page. | |
| 108 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 109 | + | }, [slug, pageId]); | |
| 110 | + | ||
| 111 | + | if (!provider || !synced) return <EditorSkeleton />; | |
| 112 | + | return <LiveEditor {...props} provider={provider} />; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, provider }: DocEditorProps & { provider: DocsProvider }) { | |
| 116 | + | const editable = canDo(role, "edit"); | |
| 117 | + | const colour = cursorColour(me.name); | |
| 118 | + | ||
| 119 | + | const threadStore = useMemo(() => { | |
| 120 | + | const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth(); | |
| 121 | + | return new RESTYjsThreadStore(`/${slug}/-/docs/threads/${pageId}`, {}, provider.doc.getMap("threads"), auth); | |
| 122 | + | }, [slug, pageId, role, me.key, editable, provider]); | |
| 123 | + | ||
| 124 | + | const editor = useCreateBlockNote( | |
| 125 | + | withCollaboration({ | |
| 126 | + | schema, | |
| 127 | + | collaboration: { | |
| 128 | + | fragment: provider.doc.getXmlFragment("document-store"), | |
| 129 | + | user: { name: me.display_name, color: colour, key: me.key, kind: "user", avatar: me.avatar ?? "" }, | |
| 130 | + | provider, | |
| 131 | + | showCursorLabels: "activity", | |
| 132 | + | }, | |
| 133 | + | extensions: [ | |
| 134 | + | CommentsExtension({ | |
| 135 | + | threadStore, | |
| 136 | + | resolveUsers: async (ids: string[]) => { | |
| 137 | + | const found = await whoAre(slug, ids); | |
| 138 | + | return ids.map((id) => { | |
| 139 | + | const p = found.get(id); | |
| 140 | + | const avatar = id === me.key ? me.avatar : (p?.avatar ?? null); | |
| 141 | + | return { id, username: id === me.key ? me.display_name : (p?.display_name ?? "Someone"), avatarUrl: avatar ? `${usercontent}/avatars/${avatar}` : "" }; | |
| 142 | + | }); | |
| 143 | + | }, | |
| 144 | + | }), | |
| 145 | + | syntaxHighlighter, | |
| 146 | + | ], | |
| 147 | + | uploadFile: async (file: File) => { | |
| 148 | + | const response = await fetch(`/${slug}/-/docs/upload?page=${encodeURIComponent(pageId)}&name=${encodeURIComponent(file.name)}`, { | |
| 149 | + | method: "POST", | |
| 150 | + | headers: { "content-type": file.type || "application/octet-stream" }, | |
| 151 | + | body: file, | |
| 152 | + | }); | |
| 153 | + | const result = (await response.json()) as Result<DocFile>; | |
| 154 | + | if (!result.ok) throw new Error(result.error.message); | |
| 155 | + | return `${usercontent}${result.value.url}`; | |
| 156 | + | }, | |
| 157 | + | }), | |
| 158 | + | [provider, threadStore], | |
| 159 | + | ) as unknown as DocEditorInstance; | |
| 160 | + | ||
| 161 | + | // Who is here: everyone's awareness state, people and agents. | |
| 162 | + | useEffect(() => { | |
| 163 | + | const read = () => { | |
| 164 | + | const out: Presence[] = []; | |
| 165 | + | provider.awareness.getStates().forEach((state, client) => { | |
| 166 | + | const user = (state as { user?: { name?: string; color?: string; key?: string; kind?: string; avatar?: string } }).user; | |
| 167 | + | if (!user?.name) return; | |
| 168 | + | out.push({ | |
| 169 | + | client, | |
| 170 | + | key: user.key ?? String(client), | |
| 171 | + | name: user.name, | |
| 172 | + | kind: user.kind === "agent" ? "agent" : "user", | |
| 173 | + | color: user.color ?? "#b8a6ff", | |
| 174 | + | avatar: user.avatar || null, | |
| 175 | + | me: client === provider.doc.clientID, | |
| 176 | + | }); | |
| 177 | + | }); | |
| 178 | + | // One face per person, however many tabs they have open. | |
| 179 | + | const seen = new Set<string>(); | |
| 180 | + | onPresence?.(out.filter((p) => !seen.has(p.key) && seen.add(p.key))); | |
| 181 | + | }; | |
| 182 | + | read(); | |
| 183 | + | provider.awareness.on("change", read); | |
| 184 | + | return () => provider.awareness.off("change", read); | |
| 185 | + | }, [provider, onPresence]); | |
| 186 | + | ||
| 187 | + | // Comments on the whole page, as the document holds them. | |
| 188 | + | useEffect(() => { | |
| 189 | + | if (!onPageThreads) return; | |
| 190 | + | const map = provider.doc.getMap("threads"); | |
| 191 | + | const read = () => { | |
| 192 | + | const out: PageThread[] = []; | |
| 193 | + | map.forEach((value) => { | |
| 194 | + | const t = value as { get(key: string): unknown }; | |
| 195 | + | if (typeof t?.get !== "function" || t.get("deletedAt")) return; | |
| 196 | + | const meta = (t.get("metadata") as { page_level?: boolean } | null) ?? {}; | |
| 197 | + | if (!meta.page_level) return; | |
| 198 | + | const comments = (t.get("comments") as { toArray(): { get(key: string): unknown }[] } | undefined)?.toArray() ?? []; | |
| 199 | + | out.push({ | |
| 200 | + | id: String(t.get("id")), | |
| 201 | + | resolved: !!t.get("resolved"), | |
| 202 | + | comments: comments | |
| 203 | + | .filter((c) => !c.get("deletedAt")) | |
| 204 | + | .map((c) => ({ id: String(c.get("id")), author: String(c.get("userId")), text: commentText(c.get("body")), created_at: Number(c.get("createdAt")) || 0 })), | |
| 205 | + | }); | |
| 206 | + | }); | |
| 207 | + | onPageThreads(out.sort((a, b) => (a.comments[0]?.created_at ?? 0) - (b.comments[0]?.created_at ?? 0))); | |
| 208 | + | }; | |
| 209 | + | read(); | |
| 210 | + | map.observeDeep(read); | |
| 211 | + | return () => map.unobserveDeep(read); | |
| 212 | + | }, [provider, onPageThreads]); | |
| 213 | + | ||
| 214 | + | // `[[` links a page, as `@` mentions someone. | |
| 215 | + | const searchPages = async (query: string) => { | |
| 216 | + | try { | |
| 217 | + | const r = await fetch(`/${slug}/-/docs/api?search=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }); | |
| 218 | + | const result = (await r.json()) as Result<{ id: string; title: string; icon: string | null; path: string; space_name: string }[]>; | |
| 219 | + | return result.ok ? result.value : []; | |
| 220 | + | } catch { | |
| 221 | + | return []; | |
| 222 | + | } | |
| 223 | + | }; | |
| 224 | + | ||
| 225 | + | const slashItems = (query: string) => { | |
| 226 | + | const insert = (block: { type: string; props?: Record<string, unknown> }) => () => insertOrUpdateBlockForSlashMenu(editor as never, block as never); | |
| 227 | + | const custom = [ | |
| 228 | + | { title: "Info callout", subtext: "A note set apart from the text", aliases: ["callout", "note", "info"], group: "Callouts", icon: <Info size={18} />, onItemClick: insert({ type: "callout", props: { kind: "info" } }) }, | |
| 229 | + | { title: "Warning callout", subtext: "Something to be careful about", aliases: ["callout", "warning", "caution"], group: "Callouts", icon: <AlertTriangle size={18} />, onItemClick: insert({ type: "callout", props: { kind: "warning" } }) }, | |
| 230 | + | { title: "Success callout", subtext: "A tip, or what good looks like", aliases: ["callout", "success", "tip"], group: "Callouts", icon: <CheckCircle2 size={18} />, onItemClick: insert({ type: "callout", props: { kind: "success" } }) }, | |
| 231 | + | { title: "Diagram", subtext: "A Mermaid diagram, drawn as you type", aliases: ["mermaid", "flowchart", "sequence", "chart"], group: "Advanced", icon: <Workflow size={18} />, onItemClick: insert({ type: "mermaid" }) }, | |
| 232 | + | { title: "Math", subtext: "A formula, typeset with KaTeX", aliases: ["math", "latex", "katex", "equation", "formula"], group: "Advanced", icon: <Sigma size={18} />, onItemClick: insert({ type: "math" }) }, | |
| 233 | + | { title: "Embed from g1t", subtext: "An issue, pull request, channel, project or page, live", aliases: ["embed", "issue", "pull", "pr", "channel", "project"], group: "g1t", icon: <GitPullRequest size={18} />, onItemClick: insert({ type: "embed" }) }, | |
| 234 | + | { | |
| 235 | + | title: "Mention", | |
| 236 | + | subtext: "A person or an agent", | |
| 237 | + | aliases: ["mention", "person", "agent", "@"], | |
| 238 | + | group: "g1t", | |
| 239 | + | icon: <AtSign size={18} />, | |
| 240 | + | onItemClick: () => editor.insertInlineContent("@" as never), | |
| 241 | + | }, | |
| 242 | + | { | |
| 243 | + | title: "Link to page", | |
| 244 | + | subtext: "Another page in Docs", | |
| 245 | + | aliases: ["link", "page", "[["], | |
| 246 | + | group: "g1t", | |
| 247 | + | icon: <Link2 size={18} />, | |
| 248 | + | onItemClick: () => editor.insertInlineContent("[[" as never), | |
| 249 | + | }, | |
| 250 | + | { | |
| 251 | + | title: "Date", | |
| 252 | + | subtext: "Today's date, which you can change", | |
| 253 | + | aliases: ["date", "today", "when"], | |
| 254 | + | group: "g1t", | |
| 255 | + | icon: <Calendar size={18} />, | |
| 256 | + | onItemClick: () => editor.insertInlineContent([{ type: "date", props: { date: new Date().toISOString().slice(0, 10) } }, " "] as never), | |
| 257 | + | }, | |
| 258 | + | ]; | |
| 259 | + | return filterSuggestionItems([...getDefaultReactSlashMenuItems(editor as never), ...custom], query); | |
| 260 | + | }; | |
| 261 | + | ||
| 262 | + | const mentionItems = (query: string) => { | |
| 263 | + | const q = query.toLowerCase(); | |
| 264 | + | return mentionables | |
| 265 | + | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) | |
| 266 | + | .slice(0, 8) | |
| 267 | + | .map((p) => ({ | |
| 268 | + | title: p.display_name, | |
| 269 | + | subtext: p.kind === "agent" ? `@${p.name} · agent` : `@${p.name}`, | |
| 270 | + | icon: <AtSign size={16} />, | |
| 271 | + | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: p.kind, id: p.id, name: p.name, href: "" } }, " "] as never), | |
| 272 | + | })); | |
| 273 | + | }; | |
| 274 | + | ||
| 275 | + | const pageItems = async (query: string) => { | |
| 276 | + | const pages = await searchPages(query); | |
| 277 | + | return pages.slice(0, 8).map((p) => ({ | |
| 278 | + | title: p.title || "Untitled", | |
| 279 | + | subtext: p.space_name, | |
| 280 | + | icon: p.icon ? <span className="text-base leading-none">{p.icon}</span> : <FileText size={16} />, | |
| 281 | + | onItemClick: () => editor.insertInlineContent([{ type: "mention", props: { kind: "page", id: p.id, name: p.title || "Untitled", href: p.path } }, " "] as never), | |
| 282 | + | })); | |
| 283 | + | }; | |
| 284 | + | ||
| 285 | + | // Suggestions: the blocks each one changes are struck through in place. | |
| 286 | + | const struck = suggestions | |
| 287 | + | .flatMap((s) => s.block_ids) | |
| 288 | + | .filter((id) => /^[0-9a-zA-Z-]+$/.test(id)) | |
| 289 | + | .map((id) => `.g1t-editor .bn-block-outer[data-id="${id}"] > .bn-block > .bn-block-content`) | |
| 290 | + | .join(",\n"); | |
| 291 | + | ||
| 292 | + | return ( | |
| 293 | + | <div className="g1t-editor relative"> | |
| 294 | + | {struck && ( | |
| 295 | + | <style>{`${struck} { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--g1t-danger) 70%, transparent); background: color-mix(in srgb, var(--g1t-danger) 8%, transparent); border-radius: 4px; }`}</style> | |
| 296 | + | )} | |
| 297 | + | <BlockNoteView editor={editor as never} editable={editable} theme="dark" renderEditor={false} slashMenu={false} comments={canDo(role, "view")} className="g1t-bn"> | |
| 298 | + | <div className={`grid gap-8 ${showComments ? "xl:grid-cols-[minmax(0,1fr)_18rem]" : ""}`}> | |
| 299 | + | <div className="relative min-w-0"> | |
| 300 | + | <BlockNoteViewEditor /> | |
| 301 | + | {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />} | |
| 302 | + | </div> | |
| 303 | + | {showComments && ( | |
| 304 | + | <aside aria-label="Comments" className="hidden xl:block"> | |
| 305 | + | <div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto [scrollbar-width:thin]"> | |
| 306 | + | <ThreadsSidebar filter="all" sort="position" /> | |
| 307 | + | </div> | |
| 308 | + | </aside> | |
| 309 | + | )} | |
| 310 | + | </div> | |
| 311 | + | <SuggestionMenuController triggerCharacter="/" getItems={async (query) => slashItems(query)} /> | |
| 312 | + | <SuggestionMenuController triggerCharacter="@" getItems={async (query) => mentionItems(query)} /> | |
| 313 | + | <SuggestionMenuController triggerCharacter="[[" getItems={pageItems} /> | |
| 314 | + | </BlockNoteView> | |
| 315 | + | </div> | |
| 316 | + | ); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /** | |
| 320 | + | * Each suggestion beside the blocks it changes, in the margin on a wide | |
| 321 | + | * screen (the page lists them above the editor on a narrow one). | |
| 322 | + | */ | |
| 323 | + | function SuggestionRail({ editor, suggestions, render, hidden }: { editor: DocEditorInstance; suggestions: DocSuggestion[]; render: (s: DocSuggestion) => ReactNode; hidden: boolean }) { | |
| 324 | + | const box = useRef<HTMLDivElement>(null); | |
| 325 | + | const [tops, setTops] = useState<Record<string, number>>({}); | |
| 326 | + | useLayoutEffect(() => { | |
| 327 | + | const place = () => { | |
| 328 | + | const root = box.current?.parentElement; | |
| 329 | + | if (!root) return; | |
| 330 | + | const base = root.getBoundingClientRect().top; | |
| 331 | + | const next: Record<string, number> = {}; | |
| 332 | + | let floor = 0; | |
| 333 | + | for (const s of suggestions) { | |
| 334 | + | const first = s.block_ids[0]; | |
| 335 | + | const el = first ? root.querySelector(`.bn-block-outer[data-id="${CSS.escape(first)}"]`) : null; | |
| 336 | + | const want = el ? el.getBoundingClientRect().top - base : root.getBoundingClientRect().height - 40; | |
| 337 | + | // Cards never overlap: each starts below the one before. | |
| 338 | + | const top = Math.max(want, floor); | |
| 339 | + | next[s.id] = top; | |
| 340 | + | floor = top + 190; | |
| 341 | + | } | |
| 342 | + | setTops(next); | |
| 343 | + | }; | |
| 344 | + | place(); | |
| 345 | + | const off = editor.onChange(() => requestAnimationFrame(place)); | |
| 346 | + | const observer = new ResizeObserver(place); | |
| 347 | + | if (box.current?.parentElement) observer.observe(box.current.parentElement); | |
| 348 | + | return () => { | |
| 349 | + | off?.(); | |
| 350 | + | observer.disconnect(); | |
| 351 | + | }; | |
| 352 | + | }, [editor, suggestions]); | |
| 353 | + | if (hidden || !suggestions.length) return null; | |
| 354 | + | return ( | |
| 355 | + | <div ref={box} className="pointer-events-none absolute inset-y-0 left-full ml-8 hidden w-72 2xl:block" aria-label="Suggestions"> | |
| 356 | + | {suggestions.map((s) => ( | |
| 357 | + | <div key={s.id} className="pointer-events-auto absolute inset-x-0" style={{ top: tops[s.id] ?? 0 }}> | |
| 358 | + | {render(s)} | |
| 359 | + | </div> | |
| 360 | + | ))} | |
| 361 | + | </div> | |
| 362 | + | ); | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /** A comment's text: its blocks' inline text, mentions as @name. */ | |
| 366 | + | function commentText(body: unknown): string { | |
| 367 | + | const out: string[] = []; | |
| 368 | + | const walk = (v: unknown) => { | |
| 369 | + | if (Array.isArray(v)) v.forEach(walk); | |
| 370 | + | else if (v && typeof v === "object") { | |
| 371 | + | const o = v as Record<string, unknown>; | |
| 372 | + | if (o.type === "text" && typeof o.text === "string") out.push(o.text); | |
| 373 | + | else if (o.type === "mention") out.push(`@${(o.props as { name?: string } | undefined)?.name ?? ""}`); | |
| 374 | + | else { | |
| 375 | + | walk(o.content); | |
| 376 | + | walk(o.children); | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | }; | |
| 380 | + | walk(body); | |
| 381 | + | return out.join(""); | |
| 382 | + | } |
| 1 | + | /** | |
| 2 | + | * A page's surroundings: agents' suggestions with Accept and Reject, the | |
| 3 | + | * history dialog, moving a page, saving it as a template, the emoji and | |
| 4 | + | * cover pickers, and comments on the whole page. | |
| 5 | + | */ | |
| 6 | + | import type { DocDiffLine, DocSuggestion, DocVersion, DocVersionDetail, DocsSidebarSpace, MemberProfile, Result } from "@g1t/contracts"; | |
| 7 | + | import { Check, History, MessageSquare, RotateCcw, Sparkles, X } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 9 | + | ||
| 10 | + | import { COVER_GRADIENTS, buildTree, canDo, flatten } from "../../lib/docs"; | |
| 11 | + | import { Button, ErrorText, TimeAgo } from "../ui"; | |
| 12 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 13 | + | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 14 | + | import { SelectField } from "../ui/select"; | |
| 15 | + | import { docsQuery } from "./actions"; | |
| 16 | + | import { Face } from "./parts"; | |
| 17 | + | ||
| 18 | + | // ── Suggestions ───────────────────────────────────────────────────────────── | |
| 19 | + | ||
| 20 | + | function DiffBlock({ text, tone }: { text: string; tone: "del" | "add" }) { | |
| 21 | + | if (!text.trim()) return <p className="text-xs text-faint italic">{tone === "del" ? "Nothing removed" : "Nothing added"}</p>; | |
| 22 | + | return ( | |
| 23 | + | <pre | |
| 24 | + | className={`max-h-56 overflow-auto rounded-md px-2.5 py-2 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap [scrollbar-width:thin] ${ | |
| 25 | + | tone === "del" ? "bg-danger/8 text-fg-soft line-through decoration-danger/60" : "bg-success/10 text-fg" | |
| 26 | + | }`} | |
| 27 | + | > | |
| 28 | + | {text} | |
| 29 | + | </pre> | |
| 30 | + | ); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | function targetLabel(s: DocSuggestion): string { | |
| 34 | + | switch (s.target.kind) { | |
| 35 | + | case "append": | |
| 36 | + | return "Adds to the end"; | |
| 37 | + | case "document": | |
| 38 | + | return "Rewrites the page"; | |
| 39 | + | case "section": | |
| 40 | + | return `Changes “${s.target.heading}”`; | |
| 41 | + | default: | |
| 42 | + | return s.block_ids.length === 1 ? "Changes one block" : `Changes ${s.block_ids.length} blocks`; | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | export function SuggestionCard({ suggestion, canDecide, onDecide, compact = false }: { suggestion: DocSuggestion; canDecide: boolean; onDecide: (id: string, decision: "accept" | "reject") => void; compact?: boolean }) { | |
| 47 | + | const [open, setOpen] = useState(!compact); | |
| 48 | + | const s = suggestion; | |
| 49 | + | return ( | |
| 50 | + | <article className="rounded-xl border border-accent/30 bg-surface shadow-lg shadow-black/20"> | |
| 51 | + | <header className="flex items-center gap-2 border-b border-line px-3 py-2"> | |
| 52 | + | <Face who={s.author} size={20} /> | |
| 53 | + | <span className="min-w-0 grow"> | |
| 54 | + | <span className="block truncate text-xs font-medium text-fg">{s.author.display_name} suggests</span> | |
| 55 | + | <span className="block truncate text-[0.6875rem] text-faint"> | |
| 56 | + | {targetLabel(s)} | |
| 57 | + | {s.asked_by ? ` · for ${s.asked_by.display_name}` : ""} · <TimeAgo at={s.created_at} /> | |
| 58 | + | </span> | |
| 59 | + | </span> | |
| 60 | + | <Sparkles size={14} className="shrink-0 text-accent" aria-hidden="true" /> | |
| 61 | + | </header> | |
| 62 | + | <div className="space-y-2 px-3 py-2.5"> | |
| 63 | + | {s.note && <p className="text-xs leading-relaxed text-muted">{s.note}</p>} | |
| 64 | + | {open ? ( | |
| 65 | + | <> | |
| 66 | + | {s.target.kind !== "append" && <DiffBlock text={s.before_markdown} tone="del" />} | |
| 67 | + | <DiffBlock text={s.after_markdown} tone="add" /> | |
| 68 | + | </> | |
| 69 | + | ) : ( | |
| 70 | + | <button type="button" onClick={() => setOpen(true)} className="text-xs text-accent hover:underline"> | |
| 71 | + | Show the change | |
| 72 | + | </button> | |
| 73 | + | )} | |
| 74 | + | </div> | |
| 75 | + | {canDecide && ( | |
| 76 | + | <footer className="flex gap-2 border-t border-line px-3 py-2"> | |
| 77 | + | <button type="button" onClick={() => onDecide(s.id, "accept")} className="inline-flex h-7 items-center gap-1 rounded-md bg-success/15 px-2.5 text-xs font-medium text-success hover:bg-success/25"> | |
| 78 | + | <Check size={13} /> Accept | |
| 79 | + | </button> | |
| 80 | + | <button type="button" onClick={() => onDecide(s.id, "reject")} className="inline-flex h-7 items-center gap-1 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg"> | |
| 81 | + | <X size={13} /> Reject | |
| 82 | + | </button> | |
| 83 | + | </footer> | |
| 84 | + | )} | |
| 85 | + | </article> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // ── History ───────────────────────────────────────────────────────────────── | |
| 90 | + | ||
| 91 | + | const KIND_LABELS: Record<DocVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", restore: "Restored" }; | |
| 92 | + | ||
| 93 | + | function DiffView({ lines }: { lines: DocDiffLine[] }) { | |
| 94 | + | return ( | |
| 95 | + | <div className="font-mono text-[0.75rem] leading-relaxed"> | |
| 96 | + | {lines.map((l, i) => ( | |
| 97 | + | <div key={i} className={`flex gap-2 px-2 whitespace-pre-wrap ${l.op === "add" ? "bg-success/10 text-fg" : l.op === "del" ? "bg-danger/10 text-fg-soft line-through decoration-danger/50" : "text-muted"}`}> | |
| 98 | + | <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span> | |
| 99 | + | <span className="min-w-0">{l.text || " "}</span> | |
| 100 | + | </div> | |
| 101 | + | ))} | |
| 102 | + | </div> | |
| 103 | + | ); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | export function HistoryDialog({ slug, pageId, open, onOpenChange, canRestore, onRestore }: { slug: string; pageId: string; open: boolean; onOpenChange: (o: boolean) => void; canRestore: boolean; onRestore: (versionId: string) => Promise<Result<unknown>> }) { | |
| 107 | + | const [versions, setVersions] = useState<DocVersion[] | null>(null); | |
| 108 | + | const [chosen, setChosen] = useState<string | null>(null); | |
| 109 | + | const [detail, setDetail] = useState<DocVersionDetail | null>(null); | |
| 110 | + | const [error, setError] = useState<string | null>(null); | |
| 111 | + | const [restoring, setRestoring] = useState(false); | |
| 112 | + | useEffect(() => { | |
| 113 | + | if (!open) return; | |
| 114 | + | setVersions(null); | |
| 115 | + | setError(null); | |
| 116 | + | void docsQuery<DocVersion[]>(slug, { versions: pageId }).then((r) => { | |
| 117 | + | if (r.ok) { | |
| 118 | + | setVersions(r.value); | |
| 119 | + | setChosen(r.value[0]?.id ?? null); | |
| 120 | + | } else setError(r.error.message); | |
| 121 | + | }); | |
| 122 | + | }, [open, slug, pageId]); | |
| 123 | + | useEffect(() => { | |
| 124 | + | if (!chosen) return setDetail(null); | |
| 125 | + | setDetail(null); | |
| 126 | + | void docsQuery<DocVersionDetail>(slug, { version: chosen, page: pageId }).then((r) => r.ok && setDetail(r.value)); | |
| 127 | + | }, [chosen, slug, pageId]); | |
| 128 | + | return ( | |
| 129 | + | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 130 | + | <DialogContent className="max-w-5xl"> | |
| 131 | + | <DialogHeader> | |
| 132 | + | <DialogTitle className="flex items-center gap-2"> | |
| 133 | + | <History size={16} /> History | |
| 134 | + | </DialogTitle> | |
| 135 | + | <DialogDescription>Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</DialogDescription> | |
| 136 | + | </DialogHeader> | |
| 137 | + | {error && <ErrorText>{error}</ErrorText>} | |
| 138 | + | <div className="grid min-h-[24rem] gap-4 md:grid-cols-[16rem_minmax(0,1fr)]"> | |
| 139 | + | <ul className="max-h-[60vh] space-y-px overflow-y-auto [scrollbar-width:thin]"> | |
| 140 | + | {versions === null && !error && <li className="px-2 py-1 text-xs text-faint">Loading…</li>} | |
| 141 | + | {versions?.map((v) => ( | |
| 142 | + | <li key={v.id}> | |
| 143 | + | <button type="button" onClick={() => setChosen(v.id)} className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${chosen === v.id ? "bg-raised" : "hover:bg-raised/60"}`}> | |
| 144 | + | <span className="block text-xs font-medium text-fg">{new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}</span> | |
| 145 | + | <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span> | |
| 146 | + | <span className="mt-1 flex items-center gap-1"> | |
| 147 | + | {v.authors.slice(0, 4).map((a) => ( | |
| 148 | + | <Face key={`${a.kind}:${a.id}`} who={a} size={14} /> | |
| 149 | + | ))} | |
| 150 | + | <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span> | |
| 151 | + | </span> | |
| 152 | + | </button> | |
| 153 | + | </li> | |
| 154 | + | ))} | |
| 155 | + | </ul> | |
| 156 | + | <div className="min-w-0 overflow-hidden rounded-lg border border-line"> | |
| 157 | + | {detail ? ( | |
| 158 | + | <div className="max-h-[60vh] overflow-auto py-2 [scrollbar-width:thin]"> | |
| 159 | + | <DiffView lines={detail.diff} /> | |
| 160 | + | </div> | |
| 161 | + | ) : ( | |
| 162 | + | <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p> | |
| 163 | + | )} | |
| 164 | + | </div> | |
| 165 | + | </div> | |
| 166 | + | <DialogFooter> | |
| 167 | + | {canRestore && chosen && versions?.[0]?.id !== chosen && ( | |
| 168 | + | <Button | |
| 169 | + | type="button" | |
| 170 | + | variant="accent" | |
| 171 | + | disabled={restoring} | |
| 172 | + | onClick={async () => { | |
| 173 | + | setRestoring(true); | |
| 174 | + | const done = await onRestore(chosen); | |
| 175 | + | setRestoring(false); | |
| 176 | + | if (done.ok) onOpenChange(false); | |
| 177 | + | else setError(done.error.message); | |
| 178 | + | }} | |
| 179 | + | > | |
| 180 | + | <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"} | |
| 181 | + | </Button> | |
| 182 | + | )} | |
| 183 | + | </DialogFooter> | |
| 184 | + | </DialogContent> | |
| 185 | + | </Dialog> | |
| 186 | + | ); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // ── Move ──────────────────────────────────────────────────────────────────── | |
| 190 | + | ||
| 191 | + | export function MoveDialog({ spaces, pageId, current, open, onOpenChange, onMove }: { spaces: DocsSidebarSpace[]; pageId: string; current: { space_id: string; parent_id: string | null }; open: boolean; onOpenChange: (o: boolean) => void; onMove: (spaceId: string, parentId: string | null) => void }) { | |
| 192 | + | const writable = spaces.filter((s) => canDo(s.viewer_role, "edit")); | |
| 193 | + | const [space, setSpace] = useState(current.space_id); | |
| 194 | + | const [parent, setParent] = useState(current.parent_id ?? ""); | |
| 195 | + | const pages = useMemo(() => { | |
| 196 | + | const s = writable.find((x) => x.id === space); | |
| 197 | + | if (!s) return []; | |
| 198 | + | const tree = flatten(buildTree(s.pages)); | |
| 199 | + | // Not under itself or anything under it. | |
| 200 | + | const hidden = new Set<string>([pageId]); | |
| 201 | + | for (const item of tree) if (item.parent_id && hidden.has(item.parent_id)) hidden.add(item.id); | |
| 202 | + | return tree.filter((i) => !hidden.has(i.id)); | |
| 203 | + | }, [writable, space, pageId]); | |
| 204 | + | return ( | |
| 205 | + | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 206 | + | <DialogContent> | |
| 207 | + | <DialogHeader> | |
| 208 | + | <DialogTitle>Move page</DialogTitle> | |
| 209 | + | <DialogDescription>Pages under it move with it.</DialogDescription> | |
| 210 | + | </DialogHeader> | |
| 211 | + | <div className="space-y-3"> | |
| 212 | + | <SelectField aria-label="Space" value={space} onValueChange={(v) => { setSpace(v); setParent(""); }} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 w-full" /> | |
| 213 | + | <SelectField | |
| 214 | + | aria-label="Inside" | |
| 215 | + | value={parent} | |
| 216 | + | onValueChange={setParent} | |
| 217 | + | options={[{ value: "", label: "At the top of the space" }, ...pages.map((p) => ({ value: p.id, label: `${" ".repeat(p.depth)}${p.icon ?? ""} ${p.title || "Untitled"}`.trim() }))]} | |
| 218 | + | className="h-9 w-full" | |
| 219 | + | /> | |
| 220 | + | </div> | |
| 221 | + | <DialogFooter> | |
| 222 | + | <Button type="button" variant="accent" onClick={() => onMove(space, parent || null)}> | |
| 223 | + | Move | |
| 224 | + | </Button> | |
| 225 | + | </DialogFooter> | |
| 226 | + | </DialogContent> | |
| 227 | + | </Dialog> | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // ── Save as template ─────────────────────────────────────────────────────── | |
| 232 | + | ||
| 233 | + | export function TemplateDialog({ open, onOpenChange, title, onSave }: { open: boolean; onOpenChange: (o: boolean) => void; title: string; onSave: (name: string, description: string) => Promise<Result<unknown>> }) { | |
| 234 | + | const [name, setName] = useState(title); | |
| 235 | + | const [description, setDescription] = useState(""); | |
| 236 | + | const [error, setError] = useState<string | null>(null); | |
| 237 | + | useEffect(() => setName(title), [title]); | |
| 238 | + | return ( | |
| 239 | + | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 240 | + | <DialogContent> | |
| 241 | + | <DialogHeader> | |
| 242 | + | <DialogTitle>Save as a template</DialogTitle> | |
| 243 | + | <DialogDescription>Everyone in the workspace can start a page from it.</DialogDescription> | |
| 244 | + | </DialogHeader> | |
| 245 | + | <div className="space-y-3"> | |
| 246 | + | <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" /> | |
| 247 | + | <input aria-label="What it's for" placeholder="What it's for" value={description} onChange={(e) => setDescription(e.target.value)} className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" /> | |
| 248 | + | {error && <ErrorText>{error}</ErrorText>} | |
| 249 | + | </div> | |
| 250 | + | <DialogFooter> | |
| 251 | + | <Button | |
| 252 | + | type="button" | |
| 253 | + | variant="accent" | |
| 254 | + | onClick={async () => { | |
| 255 | + | const done = await onSave(name, description); | |
| 256 | + | if (done.ok) onOpenChange(false); | |
| 257 | + | else setError(done.error.message); | |
| 258 | + | }} | |
| 259 | + | > | |
| 260 | + | Save template | |
| 261 | + | </Button> | |
| 262 | + | </DialogFooter> | |
| 263 | + | </DialogContent> | |
| 264 | + | </Dialog> | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // ── Icon and cover ───────────────────────────────────────────────────────── | |
| 269 | + | ||
| 270 | + | const EMOJI = ["📄", "📘", "📚", "📝", "📐", "🧭", "🗺️", "🚀", "🛠️", "🧰", "🧪", "🐛", "🔒", "🔑", "⚙️", "📦", "🧩", "🎯", "📈", "📊", "🗓️", "⏱️", "✅", "⚠️", "🔥", "🩹", "💡", "🧠", "🤖", "👋", "🤝", "💬", "📣", "🏁", "🌱", "⭐"]; | |
| 271 | + | ||
| 272 | + | export function IconPicker({ value, onChange, children }: { value: string | null; onChange: (icon: string | null) => void; children: ReactNode }) { | |
| 273 | + | const [open, setOpen] = useState(false); | |
| 274 | + | const [custom, setCustom] = useState(""); | |
| 275 | + | return ( | |
| 276 | + | <Popover open={open} onOpenChange={setOpen}> | |
| 277 | + | <PopoverTrigger asChild>{children}</PopoverTrigger> | |
| 278 | + | <PopoverContent align="start" className="w-72 p-3"> | |
| 279 | + | <div className="grid grid-cols-9 gap-0.5"> | |
| 280 | + | {EMOJI.map((e) => ( | |
| 281 | + | <button | |
| 282 | + | key={e} | |
| 283 | + | type="button" | |
| 284 | + | onClick={() => { | |
| 285 | + | onChange(e); | |
| 286 | + | setOpen(false); | |
| 287 | + | }} | |
| 288 | + | className={`flex size-7 items-center justify-center rounded text-base hover:bg-raised ${value === e ? "bg-raised" : ""}`} | |
| 289 | + | aria-label={`Use ${e}`} | |
| 290 | + | > | |
| 291 | + | {e} | |
| 292 | + | </button> | |
| 293 | + | ))} | |
| 294 | + | </div> | |
| 295 | + | <form | |
| 296 | + | className="mt-2 flex gap-1.5" | |
| 297 | + | onSubmit={(e) => { | |
| 298 | + | e.preventDefault(); | |
| 299 | + | if (custom.trim()) onChange(custom.trim()); | |
| 300 | + | setOpen(false); | |
| 301 | + | }} | |
| 302 | + | > | |
| 303 | + | <input value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="Any emoji" aria-label="Any emoji" className="h-8 min-w-0 grow rounded-md border border-line bg-bg px-2 text-sm outline-none focus:border-accent/60" /> | |
| 304 | + | <button type="submit" className="h-8 rounded-md bg-raised px-2 text-xs hover:bg-line"> | |
| 305 | + | Use | |
| 306 | + | </button> | |
| 307 | + | </form> | |
| 308 | + | {value && ( | |
| 309 | + | <button | |
| 310 | + | type="button" | |
| 311 | + | onClick={() => { | |
| 312 | + | onChange(null); | |
| 313 | + | setOpen(false); | |
| 314 | + | }} | |
| 315 | + | className="mt-2 text-xs text-faint hover:text-fg" | |
| 316 | + | > | |
| 317 | + | Remove icon | |
| 318 | + | </button> | |
| 319 | + | )} | |
| 320 | + | </PopoverContent> | |
| 321 | + | </Popover> | |
| 322 | + | ); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | export function CoverPicker({ onChange, children }: { onChange: (cover: string | null) => void; children: ReactNode }) { | |
| 326 | + | const [open, setOpen] = useState(false); | |
| 327 | + | return ( | |
| 328 | + | <Popover open={open} onOpenChange={setOpen}> | |
| 329 | + | <PopoverTrigger asChild>{children}</PopoverTrigger> | |
| 330 | + | <PopoverContent align="start" className="w-72 p-3"> | |
| 331 | + | <div className="grid grid-cols-3 gap-2"> | |
| 332 | + | {COVER_GRADIENTS.map((g, i) => ( | |
| 333 | + | <button | |
| 334 | + | key={i} | |
| 335 | + | type="button" | |
| 336 | + | aria-label={`Cover ${i + 1}`} | |
| 337 | + | onClick={() => { | |
| 338 | + | onChange(`gradient:${i}`); | |
| 339 | + | setOpen(false); | |
| 340 | + | }} | |
| 341 | + | className="h-12 rounded-md ring-1 ring-line hover:ring-accent" | |
| 342 | + | style={{ background: g }} | |
| 343 | + | /> | |
| 344 | + | ))} | |
| 345 | + | </div> | |
| 346 | + | <button | |
| 347 | + | type="button" | |
| 348 | + | onClick={() => { | |
| 349 | + | onChange(null); | |
| 350 | + | setOpen(false); | |
| 351 | + | }} | |
| 352 | + | className="mt-3 text-xs text-faint hover:text-fg" | |
| 353 | + | > | |
| 354 | + | Remove cover | |
| 355 | + | </button> | |
| 356 | + | </PopoverContent> | |
| 357 | + | </Popover> | |
| 358 | + | ); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // ── Comments on the whole page ───────────────────────────────────────────── | |
| 362 | + | ||
| 363 | + | export type PageThread = { id: string; resolved: boolean; comments: { id: string; author: string; text: string; created_at: number }[] }; | |
| 364 | + | ||
| 365 | + | export function Discussion({ | |
| 366 | + | threads, | |
| 367 | + | people, | |
| 368 | + | canComment, | |
| 369 | + | onPost, | |
| 370 | + | onReply, | |
| 371 | + | onResolve, | |
| 372 | + | }: { | |
| 373 | + | threads: PageThread[]; | |
| 374 | + | people: (key: string) => Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed">; | |
| 375 | + | canComment: boolean; | |
| 376 | + | onPost: (text: string) => Promise<boolean>; | |
| 377 | + | onReply: (thread: string, text: string) => Promise<boolean>; | |
| 378 | + | onResolve: (thread: string, resolved: boolean) => void; | |
| 379 | + | }) { | |
| 380 | + | const [draft, setDraft] = useState(""); | |
| 381 | + | const [replies, setReplies] = useState<Record<string, string>>({}); | |
| 382 | + | const open = threads.filter((t) => !t.resolved); | |
| 383 | + | const resolved = threads.filter((t) => t.resolved); | |
| 384 | + | const [showResolved, setShowResolved] = useState(false); | |
| 385 | + | const thread = (t: PageThread) => ( | |
| 386 | + | <li key={t.id} className={`rounded-xl border border-line bg-surface ${t.resolved ? "opacity-70" : ""}`}> | |
| 387 | + | <ul className="divide-y divide-line"> | |
| 388 | + | {t.comments.map((c) => { | |
| 389 | + | const who = people(c.author); | |
| 390 | + | return ( | |
| 391 | + | <li key={c.id} className="flex gap-2.5 px-3.5 py-3"> | |
| 392 | + | <Face who={who} size={22} /> | |
| 393 | + | <div className="min-w-0 grow"> | |
| 394 | + | <p className="text-xs"> | |
| 395 | + | <span className="font-medium text-fg">{who.display_name}</span> <span className="text-faint">{c.created_at ? <TimeAgo at={c.created_at} /> : null}</span> | |
| 396 | + | </p> | |
| 397 | + | <p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-fg-soft">{c.text}</p> | |
| 398 | + | </div> | |
| 399 | + | </li> | |
| 400 | + | ); | |
| 401 | + | })} | |
| 402 | + | </ul> | |
| 403 | + | {canComment && ( | |
| 404 | + | <form | |
| 405 | + | className="flex items-center gap-2 border-t border-line px-3 py-2" | |
| 406 | + | onSubmit={async (e) => { | |
| 407 | + | e.preventDefault(); | |
| 408 | + | const text = (replies[t.id] ?? "").trim(); | |
| 409 | + | if (text && (await onReply(t.id, text))) setReplies((r) => ({ ...r, [t.id]: "" })); | |
| 410 | + | }} | |
| 411 | + | > | |
| 412 | + | <input value={replies[t.id] ?? ""} onChange={(e) => setReplies((r) => ({ ...r, [t.id]: e.target.value }))} placeholder="Reply" aria-label="Reply" className="h-8 min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" /> | |
| 413 | + | <button type="button" onClick={() => onResolve(t.id, !t.resolved)} className="shrink-0 text-xs text-faint hover:text-fg"> | |
| 414 | + | {t.resolved ? "Reopen" : "Resolve"} | |
| 415 | + | </button> | |
| 416 | + | </form> | |
| 417 | + | )} | |
| 418 | + | </li> | |
| 419 | + | ); | |
| 420 | + | return ( | |
| 421 | + | <section aria-labelledby="discussion" className="mt-14 border-t border-line pt-8"> | |
| 422 | + | <h2 id="discussion" className="flex items-center gap-2 text-sm font-semibold"> | |
| 423 | + | <MessageSquare size={15} /> Discussion | |
| 424 | + | </h2> | |
| 425 | + | <p className="mt-1 text-xs text-faint">Comments on the whole page. To comment on a passage, select it and choose Comment.</p> | |
| 426 | + | <ul className="mt-4 space-y-3">{open.map(thread)}</ul> | |
| 427 | + | {resolved.length > 0 && ( | |
| 428 | + | <> | |
| 429 | + | <button type="button" onClick={() => setShowResolved(!showResolved)} className="mt-3 text-xs text-faint hover:text-fg"> | |
| 430 | + | {showResolved ? "Hide" : "Show"} {resolved.length} resolved | |
| 431 | + | </button> | |
| 432 | + | {showResolved && <ul className="mt-3 space-y-3">{resolved.map(thread)}</ul>} | |
| 433 | + | </> | |
| 434 | + | )} | |
| 435 | + | {canComment && ( | |
| 436 | + | <form | |
| 437 | + | className="mt-4 flex items-start gap-2" | |
| 438 | + | onSubmit={async (e) => { | |
| 439 | + | e.preventDefault(); | |
| 440 | + | if (draft.trim() && (await onPost(draft.trim()))) setDraft(""); | |
| 441 | + | }} | |
| 442 | + | > | |
| 443 | + | <textarea | |
| 444 | + | value={draft} | |
| 445 | + | onChange={(e) => setDraft(e.target.value)} | |
| 446 | + | rows={2} | |
| 447 | + | placeholder="Add a comment. @mention someone to tell them." | |
| 448 | + | aria-label="Add a comment" | |
| 449 | + | className="min-h-16 min-w-0 grow resize-y rounded-lg border border-line bg-surface px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent/60" | |
| 450 | + | /> | |
| 451 | + | <Button type="submit" variant="quiet" disabled={!draft.trim()}> | |
| 452 | + | Comment | |
| 453 | + | </Button> | |
| 454 | + | </form> | |
| 455 | + | )} | |
| 456 | + | </section> | |
| 457 | + | ); | |
| 458 | + | } |
| 1 | + | /** | |
| 2 | + | * Pieces Docs' pages share: a page card, a space card, a face for a | |
| 3 | + | * person or an agent, a page's breadcrumbs, the template gallery. | |
| 4 | + | */ | |
| 5 | + | import type { DocPage, DocSpace, DocTemplate, MemberProfile } from "@g1t/contracts"; | |
| 6 | + | import { ChevronRight, Lock, Users } from "lucide-react"; | |
| 7 | + | import type { ReactNode } from "react"; | |
| 8 | + | import { Link } from "react-router"; | |
| 9 | + | ||
| 10 | + | import { coverStyle } from "../../lib/docs"; | |
| 11 | + | import { AgentAvatar } from "../agent-avatar"; | |
| 12 | + | import { Avatar, TimeAgo } from "../ui"; | |
| 13 | + | import { Hint } from "../ui/hint"; | |
| 14 | + | import { PageIcon, SpaceIcon } from "./sidebar"; | |
| 15 | + | ||
| 16 | + | export function Face({ who, size = 20 }: { who: Pick<MemberProfile, "kind" | "id" | "name" | "avatar" | "avatar_seed">; size?: number }) { | |
| 17 | + | if (who.kind === "agent") return <AgentAvatar agent={{ id: who.id, handle: who.name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null }} size={size} />; | |
| 18 | + | return <Avatar name={who.name} image={who.avatar} size={size} />; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export function Faces({ people, size = 22, max = 4 }: { people: MemberProfile[]; size?: number; max?: number }) { | |
| 22 | + | const shown = people.slice(0, max); | |
| 23 | + | return ( | |
| 24 | + | <span className="flex items-center -space-x-1.5"> | |
| 25 | + | {shown.map((p) => ( | |
| 26 | + | <Hint key={`${p.kind}:${p.id}`} label={p.display_name}> | |
| 27 | + | <span className="rounded-full ring-2 ring-bg"> | |
| 28 | + | <Face who={p} size={size} /> | |
| 29 | + | </span> | |
| 30 | + | </Hint> | |
| 31 | + | ))} | |
| 32 | + | {people.length > max && <span className="ml-2 text-xs text-faint">+{people.length - max}</span>} | |
| 33 | + | </span> | |
| 34 | + | ); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function PageCard({ page, space }: { page: DocPage; space?: Pick<DocSpace, "name"> | null }) { | |
| 38 | + | const cover = coverStyle(page.cover); | |
| 39 | + | return ( | |
| 40 | + | <Link to={page.path} prefetch="intent" className="group flex flex-col overflow-hidden rounded-xl border border-line bg-surface transition-colors hover:border-line-strong"> | |
| 41 | + | <div className="h-14 shrink-0 border-b border-line" style={{ background: cover ?? "linear-gradient(120deg, color-mix(in srgb, var(--g1t-accent) 10%, var(--g1t-surface)), var(--g1t-surface))" }} /> | |
| 42 | + | <div className="-mt-5 flex min-h-0 grow flex-col px-4 pb-3.5"> | |
| 43 | + | <span className="flex size-9 items-center justify-center rounded-lg border border-line bg-bg text-lg"> | |
| 44 | + | <PageIcon icon={page.icon} size={18} /> | |
| 45 | + | </span> | |
| 46 | + | <span className="mt-2 line-clamp-1 text-sm font-medium text-fg group-hover:text-accent">{page.title || "Untitled"}</span> | |
| 47 | + | <span className="mt-1 line-clamp-2 min-h-[2.5em] text-xs leading-relaxed text-muted">{page.excerpt || "Nothing written yet."}</span> | |
| 48 | + | <span className="mt-3 flex items-center gap-1.5 text-[0.6875rem] text-faint"> | |
| 49 | + | {page.updated_by && <Face who={page.updated_by} size={14} />} | |
| 50 | + | <span className="truncate"> | |
| 51 | + | {space ? `${space.name} · ` : ""} | |
| 52 | + | <TimeAgo at={page.updated_at} /> | |
| 53 | + | </span> | |
| 54 | + | </span> | |
| 55 | + | </div> | |
| 56 | + | </Link> | |
| 57 | + | ); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | export function SpaceCard({ slug, space }: { slug: string; space: DocSpace }) { | |
| 61 | + | return ( | |
| 62 | + | <Link to={`/${slug}/-/docs/${space.slug}`} prefetch="intent" className="group flex gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 63 | + | <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-raised text-lg"> | |
| 64 | + | <SpaceIcon space={space} size={18} /> | |
| 65 | + | </span> | |
| 66 | + | <span className="min-w-0 grow"> | |
| 67 | + | <span className="flex items-center gap-1.5"> | |
| 68 | + | <span className="truncate text-sm font-medium text-fg group-hover:text-accent">{space.name}</span> | |
| 69 | + | {space.kind === "private" && <Lock size={12} className="shrink-0 text-faint" aria-label="Private" />} | |
| 70 | + | {space.kind === "team" && <Users size={12} className="shrink-0 text-faint" aria-label="Team space" />} | |
| 71 | + | </span> | |
| 72 | + | <span className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">{space.description || "No description."}</span> | |
| 73 | + | <span className="mt-2 block text-[0.6875rem] text-faint"> | |
| 74 | + | {space.page_count} {space.page_count === 1 ? "page" : "pages"} | |
| 75 | + | {space.projects.length > 0 && ` · ${space.projects.join(", ")}`} | |
| 76 | + | </span> | |
| 77 | + | </span> | |
| 78 | + | </Link> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | export function TemplateCard({ template, action }: { template: DocTemplate; action: ReactNode }) { | |
| 83 | + | return ( | |
| 84 | + | <div className="flex flex-col rounded-xl border border-line bg-surface p-4"> | |
| 85 | + | <span className="text-2xl leading-none" aria-hidden="true"> | |
| 86 | + | {template.icon} | |
| 87 | + | </span> | |
| 88 | + | <span className="mt-3 text-sm font-medium">{template.name}</span> | |
| 89 | + | <span className="mt-1 grow text-xs leading-relaxed text-muted">{template.description || (template.builtin ? "" : "Saved by your workspace.")}</span> | |
| 90 | + | <div className="mt-3 flex items-center gap-2">{action}</div> | |
| 91 | + | </div> | |
| 92 | + | ); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | export function Crumbs({ items }: { items: { label: ReactNode; to?: string }[] }) { | |
| 96 | + | return ( | |
| 97 | + | <nav aria-label="Breadcrumbs" className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-faint"> | |
| 98 | + | {items.map((item, i) => ( | |
| 99 | + | <span key={i} className="flex min-w-0 items-center gap-1"> | |
| 100 | + | {i > 0 && <ChevronRight size={12} className="shrink-0" aria-hidden="true" />} | |
| 101 | + | {item.to ? ( | |
| 102 | + | <Link to={item.to} prefetch="intent" className="truncate hover:text-fg"> | |
| 103 | + | {item.label} | |
| 104 | + | </Link> | |
| 105 | + | ) : ( | |
| 106 | + | <span className="truncate text-muted">{item.label}</span> | |
| 107 | + | )} | |
| 108 | + | </span> | |
| 109 | + | ))} | |
| 110 | + | </nav> | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /** A heading for a section of a Docs page. */ | |
| 115 | + | export function SectionTitle({ children, action }: { children: ReactNode; action?: ReactNode }) { | |
| 116 | + | return ( | |
| 117 | + | <div className="mb-3 flex items-center justify-between gap-3"> | |
| 118 | + | <h2 className="text-sm font-semibold text-fg">{children}</h2> | |
| 119 | + | {action} | |
| 120 | + | </div> | |
| 121 | + | ); | |
| 122 | + | } |
| 1 | + | /** | |
| 2 | + | * A page's live connection: the Yjs document and everyone's presence, | |
| 3 | + | * synced over `wss://<site>/<workspace>/-/docs/live?page=<id>` with the | |
| 4 | + | * page's room (services/docs src/room.ts). Binary frames speak the | |
| 5 | + | * y-protocols sync and awareness messages; text frames are the service's | |
| 6 | + | * own notices (`DocsLiveEvent`): a rename, a new suggestion, a version, | |
| 7 | + | * a change of access. | |
| 8 | + | * | |
| 9 | + | * It reconnects with backoff when the socket drops, and says where it is | |
| 10 | + | * (`status`) so the page can show "Offline, changes will sync". | |
| 11 | + | * Browser-only. | |
| 12 | + | */ | |
| 13 | + | import type { DocsLiveEvent } from "@g1t/contracts"; | |
| 14 | + | import * as decoding from "lib0/decoding"; | |
| 15 | + | import * as encoding from "lib0/encoding"; | |
| 16 | + | import * as awarenessProtocol from "y-protocols/awareness"; | |
| 17 | + | import * as syncProtocol from "y-protocols/sync"; | |
| 18 | + | import * as Y from "yjs"; | |
| 19 | + | ||
| 20 | + | const MESSAGE_SYNC = 0; | |
| 21 | + | const MESSAGE_AWARENESS = 1; | |
| 22 | + | const MESSAGE_QUERY_AWARENESS = 3; | |
| 23 | + | ||
| 24 | + | export type LiveStatus = "connecting" | "synced" | "offline" | "closed"; | |
| 25 | + | ||
| 26 | + | export class DocsProvider { | |
| 27 | + | readonly doc: Y.Doc; | |
| 28 | + | readonly awareness: awarenessProtocol.Awareness; | |
| 29 | + | status: LiveStatus = "connecting"; | |
| 30 | + | private socket: WebSocket | null = null; | |
| 31 | + | private attempts = 0; | |
| 32 | + | private timer: ReturnType<typeof setTimeout> | null = null; | |
| 33 | + | private keepalive: ReturnType<typeof setInterval> | null = null; | |
| 34 | + | private stopped = false; | |
| 35 | + | private readonly statusListeners = new Set<(status: LiveStatus) => void>(); | |
| 36 | + | private readonly eventListeners = new Set<(event: DocsLiveEvent) => void>(); | |
| 37 | + | ||
| 38 | + | constructor( | |
| 39 | + | private readonly url: string, | |
| 40 | + | doc?: Y.Doc, | |
| 41 | + | ) { | |
| 42 | + | this.doc = doc ?? new Y.Doc(); | |
| 43 | + | this.awareness = new awarenessProtocol.Awareness(this.doc); | |
| 44 | + | this.doc.on("update", this.onDocUpdate); | |
| 45 | + | this.awareness.on("update", this.onAwarenessUpdate); | |
| 46 | + | if (typeof window !== "undefined") { | |
| 47 | + | window.addEventListener("beforeunload", this.onUnload); | |
| 48 | + | window.addEventListener("online", this.onOnline); | |
| 49 | + | } | |
| 50 | + | this.connect(); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | onStatus(listener: (status: LiveStatus) => void): () => void { | |
| 54 | + | this.statusListeners.add(listener); | |
| 55 | + | listener(this.status); | |
| 56 | + | return () => this.statusListeners.delete(listener); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | onEvent(listener: (event: DocsLiveEvent) => void): () => void { | |
| 60 | + | this.eventListeners.add(listener); | |
| 61 | + | return () => this.eventListeners.delete(listener); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | private setStatus(status: LiveStatus) { | |
| 65 | + | if (this.status === status) return; | |
| 66 | + | this.status = status; | |
| 67 | + | for (const l of this.statusListeners) l(status); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | private connect() { | |
| 71 | + | if (this.stopped) return; | |
| 72 | + | this.setStatus(this.attempts ? "offline" : "connecting"); | |
| 73 | + | const socket = new WebSocket(this.url); | |
| 74 | + | socket.binaryType = "arraybuffer"; | |
| 75 | + | this.socket = socket; | |
| 76 | + | socket.onopen = () => { | |
| 77 | + | this.attempts = 0; | |
| 78 | + | // Our state vector: the room answers with what we lack, and asks for what it lacks. | |
| 79 | + | const encoder = encoding.createEncoder(); | |
| 80 | + | encoding.writeVarUint(encoder, MESSAGE_SYNC); | |
| 81 | + | syncProtocol.writeSyncStep1(encoder, this.doc); | |
| 82 | + | socket.send(encoding.toUint8Array(encoder)); | |
| 83 | + | if (this.awareness.getLocalState() !== null) { | |
| 84 | + | const a = encoding.createEncoder(); | |
| 85 | + | encoding.writeVarUint(a, MESSAGE_AWARENESS); | |
| 86 | + | encoding.writeVarUint8Array(a, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID])); | |
| 87 | + | socket.send(encoding.toUint8Array(a)); | |
| 88 | + | } | |
| 89 | + | const q = encoding.createEncoder(); | |
| 90 | + | encoding.writeVarUint(q, MESSAGE_QUERY_AWARENESS); | |
| 91 | + | socket.send(encoding.toUint8Array(q)); | |
| 92 | + | if (this.keepalive) clearInterval(this.keepalive); | |
| 93 | + | // Answered at the edge without waking the room. | |
| 94 | + | this.keepalive = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send("ping"), 25_000); | |
| 95 | + | }; | |
| 96 | + | socket.onmessage = (event) => { | |
| 97 | + | if (typeof event.data === "string") { | |
| 98 | + | if (event.data === "pong") return; | |
| 99 | + | try { | |
| 100 | + | const parsed = JSON.parse(event.data) as DocsLiveEvent; | |
| 101 | + | for (const l of this.eventListeners) l(parsed); | |
| 102 | + | } catch { | |
| 103 | + | // Not ours. | |
| 104 | + | } | |
| 105 | + | return; | |
| 106 | + | } | |
| 107 | + | this.receive(new Uint8Array(event.data as ArrayBuffer)); | |
| 108 | + | }; | |
| 109 | + | socket.onclose = (event) => { | |
| 110 | + | if (this.keepalive) clearInterval(this.keepalive); | |
| 111 | + | this.socket = null; | |
| 112 | + | // Others stop seeing our cursor; we stop seeing theirs. | |
| 113 | + | awarenessProtocol.removeAwarenessStates( | |
| 114 | + | this.awareness, | |
| 115 | + | [...this.awareness.getStates().keys()].filter((id) => id !== this.doc.clientID), | |
| 116 | + | this, | |
| 117 | + | ); | |
| 118 | + | // 4403: no longer allowed; 4410: in the trash. Neither comes back by retrying. | |
| 119 | + | if (event.code === 4403 || event.code === 4410 || this.stopped) { | |
| 120 | + | this.setStatus("closed"); | |
| 121 | + | return; | |
| 122 | + | } | |
| 123 | + | this.setStatus("offline"); | |
| 124 | + | const delay = Math.min(30_000, 500 * 2 ** this.attempts) + Math.random() * 500; | |
| 125 | + | this.attempts++; | |
| 126 | + | this.timer = setTimeout(() => this.connect(), delay); | |
| 127 | + | }; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | private receive(data: Uint8Array) { | |
| 131 | + | const decoder = decoding.createDecoder(data); | |
| 132 | + | const type = decoding.readVarUint(decoder); | |
| 133 | + | if (type === MESSAGE_SYNC) { | |
| 134 | + | const encoder = encoding.createEncoder(); | |
| 135 | + | encoding.writeVarUint(encoder, MESSAGE_SYNC); | |
| 136 | + | const step = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this); | |
| 137 | + | if (encoding.length(encoder) > 1) this.socket?.send(encoding.toUint8Array(encoder)); | |
| 138 | + | if (step === syncProtocol.messageYjsSyncStep2) this.setStatus("synced"); | |
| 139 | + | return; | |
| 140 | + | } | |
| 141 | + | if (type === MESSAGE_AWARENESS) { | |
| 142 | + | awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | private onDocUpdate = (update: Uint8Array, origin: unknown) => { | |
| 147 | + | if (origin === this) return; | |
| 148 | + | const encoder = encoding.createEncoder(); | |
| 149 | + | encoding.writeVarUint(encoder, MESSAGE_SYNC); | |
| 150 | + | syncProtocol.writeUpdate(encoder, update); | |
| 151 | + | if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder)); | |
| 152 | + | // While offline, the update stays in the document and goes in the next sync. | |
| 153 | + | }; | |
| 154 | + | ||
| 155 | + | private onAwarenessUpdate = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => { | |
| 156 | + | if (origin === this) return; | |
| 157 | + | const changed = [...added, ...updated, ...removed]; | |
| 158 | + | const encoder = encoding.createEncoder(); | |
| 159 | + | encoding.writeVarUint(encoder, MESSAGE_AWARENESS); | |
| 160 | + | encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changed)); | |
| 161 | + | if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(encoding.toUint8Array(encoder)); | |
| 162 | + | }; | |
| 163 | + | ||
| 164 | + | private onUnload = () => { | |
| 165 | + | awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "unload"); | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | private onOnline = () => { | |
| 169 | + | if (this.socket || this.stopped) return; | |
| 170 | + | if (this.timer) clearTimeout(this.timer); | |
| 171 | + | this.attempts = 0; | |
| 172 | + | this.connect(); | |
| 173 | + | }; | |
| 174 | + | ||
| 175 | + | destroy() { | |
| 176 | + | this.stopped = true; | |
| 177 | + | if (this.timer) clearTimeout(this.timer); | |
| 178 | + | if (this.keepalive) clearInterval(this.keepalive); | |
| 179 | + | awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], "destroy"); | |
| 180 | + | this.doc.off("update", this.onDocUpdate); | |
| 181 | + | this.awareness.off("update", this.onAwarenessUpdate); | |
| 182 | + | if (typeof window !== "undefined") { | |
| 183 | + | window.removeEventListener("beforeunload", this.onUnload); | |
| 184 | + | window.removeEventListener("online", this.onOnline); | |
| 185 | + | } | |
| 186 | + | this.socket?.close(); | |
| 187 | + | this.awareness.destroy(); | |
| 188 | + | } | |
| 189 | + | } |
| 1 | + | /** | |
| 2 | + | * Docs mode's sidebar (beside the rail, docs/WORKSPACE.md "Shell"): | |
| 3 | + | * search, Home, Templates and Trash; Favorites and Recent; then each | |
| 4 | + | * space with its page tree, which opens to the page being read. Pages are | |
| 5 | + | * dragged to reorder them or to put one inside another; the ⋯ menu moves | |
| 6 | + | * them too, for keyboards and phones. | |
| 7 | + | */ | |
| 8 | + | import type { DocsSidebarSpace } from "@g1t/contracts"; | |
| 9 | + | import { BookOpen, ChevronRight, Clock, FileText, Home, LayoutTemplate, Lock, Plus, Search, Settings, Star, Trash2, Users } from "lucide-react"; | |
| 10 | + | import { type DragEvent, type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 11 | + | import { NavLink, useLocation, useNavigate, useParams } from "react-router"; | |
| 12 | + | ||
| 13 | + | import { buildTree, canDo, pageIdOf, pagePath, pathTo, type TreeItem } from "../../lib/docs"; | |
| 14 | + | import { Hint } from "../ui/hint"; | |
| 15 | + | import { Skeleton } from "../ui/skeleton"; | |
| 16 | + | import { docsRequest, useDocsAction, useDocsData } from "./actions"; | |
| 17 | + | ||
| 18 | + | const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; | |
| 19 | + | ||
| 20 | + | function SideLink({ to, end, icon, children, trailing }: { to: string; end?: boolean; icon: ReactNode; children: ReactNode; trailing?: ReactNode }) { | |
| 21 | + | return ( | |
| 22 | + | <NavLink | |
| 23 | + | to={to} | |
| 24 | + | end={end} | |
| 25 | + | prefetch="intent" | |
| 26 | + | className={({ isActive }) => `${ROW} pl-2 ${isActive ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`} | |
| 27 | + | > | |
| 28 | + | <span className="flex w-5 shrink-0 justify-center text-faint">{icon}</span> | |
| 29 | + | <span className="min-w-0 grow truncate">{children}</span> | |
| 30 | + | {trailing} | |
| 31 | + | </NavLink> | |
| 32 | + | ); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function Section({ title, children, action, open: initially = true }: { title: string; children: ReactNode; action?: ReactNode; open?: boolean }) { | |
| 36 | + | const [open, setOpen] = useState(initially); | |
| 37 | + | return ( | |
| 38 | + | <section className="mt-4"> | |
| 39 | + | <div className="mb-0.5 flex items-center gap-1 pr-1"> | |
| 40 | + | <button type="button" onClick={() => setOpen(!open)} aria-expanded={open} className="flex grow items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"> | |
| 41 | + | <ChevronRight size={12} className={`transition-transform ${open ? "rotate-90" : ""}`} /> | |
| 42 | + | {title} | |
| 43 | + | </button> | |
| 44 | + | {action} | |
| 45 | + | </div> | |
| 46 | + | {open && children} | |
| 47 | + | </section> | |
| 48 | + | ); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /** A page's icon: its emoji, or the plain page. */ | |
| 52 | + | export function PageIcon({ icon, size = 15 }: { icon: string | null | undefined; size?: number }) { | |
| 53 | + | if (icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{icon}</span>; | |
| 54 | + | return <FileText size={size} className="text-faint" aria-hidden="true" />; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** A space's icon: its emoji, or the book. */ | |
| 58 | + | export function SpaceIcon({ space, size = 15 }: { space: Pick<DocsSidebarSpace, "icon" | "kind">; size?: number }) { | |
| 59 | + | if (space.icon) return <span className="leading-none" style={{ fontSize: size - 1 }} aria-hidden="true">{space.icon}</span>; | |
| 60 | + | if (space.kind === "private") return <Lock size={size} className="text-faint" aria-hidden="true" />; | |
| 61 | + | if (space.kind === "team") return <Users size={size} className="text-faint" aria-hidden="true" />; | |
| 62 | + | return <BookOpen size={size} className="text-faint" aria-hidden="true" />; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type Drag = { id: string; space: string } | null; | |
| 66 | + | ||
| 67 | + | function TreeRow({ | |
| 68 | + | slug, | |
| 69 | + | space, | |
| 70 | + | item, | |
| 71 | + | current, | |
| 72 | + | open, | |
| 73 | + | toggle, | |
| 74 | + | drag, | |
| 75 | + | setDrag, | |
| 76 | + | onDrop, | |
| 77 | + | onAdd, | |
| 78 | + | }: { | |
| 79 | + | slug: string; | |
| 80 | + | space: DocsSidebarSpace; | |
| 81 | + | item: TreeItem; | |
| 82 | + | current: string | null; | |
| 83 | + | open: Set<string>; | |
| 84 | + | toggle: (id: string) => void; | |
| 85 | + | drag: Drag; | |
| 86 | + | setDrag: (d: Drag) => void; | |
| 87 | + | onDrop: (target: TreeItem, where: "before" | "inside") => void; | |
| 88 | + | onAdd: (parent: string) => void; | |
| 89 | + | }) { | |
| 90 | + | const [over, setOver] = useState<"before" | "inside" | null>(null); | |
| 91 | + | const expanded = open.has(item.id); | |
| 92 | + | const active = current === item.id; | |
| 93 | + | const editable = canDo(space.viewer_role, "edit"); | |
| 94 | + | const zone = (e: DragEvent<HTMLElement>): "before" | "inside" => { | |
| 95 | + | const box = e.currentTarget.getBoundingClientRect(); | |
| 96 | + | return e.clientY - box.top < box.height * 0.35 ? "before" : "inside"; | |
| 97 | + | }; | |
| 98 | + | return ( | |
| 99 | + | <li> | |
| 100 | + | <div | |
| 101 | + | draggable={editable} | |
| 102 | + | onDragStart={(e) => { | |
| 103 | + | e.dataTransfer.effectAllowed = "move"; | |
| 104 | + | e.dataTransfer.setData("text/plain", item.id); | |
| 105 | + | setDrag({ id: item.id, space: space.id }); | |
| 106 | + | }} | |
| 107 | + | onDragEnd={() => setDrag(null)} | |
| 108 | + | onDragOver={(e) => { | |
| 109 | + | if (!drag || drag.id === item.id || !editable) return; | |
| 110 | + | e.preventDefault(); | |
| 111 | + | setOver(zone(e)); | |
| 112 | + | }} | |
| 113 | + | onDragLeave={() => setOver(null)} | |
| 114 | + | onDrop={(e) => { | |
| 115 | + | e.preventDefault(); | |
| 116 | + | const where = zone(e); | |
| 117 | + | setOver(null); | |
| 118 | + | if (drag && drag.id !== item.id) onDrop(item, where); | |
| 119 | + | }} | |
| 120 | + | className={`${ROW} relative ${active ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"} ${over === "inside" ? "ring-1 ring-accent/60 ring-inset" : ""}`} | |
| 121 | + | style={{ paddingLeft: 4 + item.depth * 14 }} | |
| 122 | + | > | |
| 123 | + | {over === "before" && <span className="pointer-events-none absolute inset-x-1 -top-px h-0.5 rounded bg-accent" aria-hidden="true" />} | |
| 124 | + | <button | |
| 125 | + | type="button" | |
| 126 | + | onClick={() => toggle(item.id)} | |
| 127 | + | aria-label={expanded ? "Collapse" : "Expand"} | |
| 128 | + | aria-expanded={expanded} | |
| 129 | + | className={`flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg ${item.children.length ? "" : "invisible"}`} | |
| 130 | + | > | |
| 131 | + | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| 132 | + | </button> | |
| 133 | + | <NavLink to={pagePath(slug, space.slug, item.title, item.id)} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5"> | |
| 134 | + | <span className="flex w-4 shrink-0 justify-center"> | |
| 135 | + | <PageIcon icon={item.icon} size={14} /> | |
| 136 | + | </span> | |
| 137 | + | <span className="min-w-0 truncate">{item.title || "Untitled"}</span> | |
| 138 | + | </NavLink> | |
| 139 | + | {editable && ( | |
| 140 | + | <Hint label="Add a page inside"> | |
| 141 | + | <button | |
| 142 | + | type="button" | |
| 143 | + | onClick={() => onAdd(item.id)} | |
| 144 | + | aria-label={`Add a page inside ${item.title || "Untitled"}`} | |
| 145 | + | className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100" | |
| 146 | + | > | |
| 147 | + | <Plus size={13} /> | |
| 148 | + | </button> | |
| 149 | + | </Hint> | |
| 150 | + | )} | |
| 151 | + | </div> | |
| 152 | + | {expanded && item.children.length > 0 && ( | |
| 153 | + | <ul> | |
| 154 | + | {item.children.map((child) => ( | |
| 155 | + | <TreeRow key={child.id} slug={slug} space={space} item={child} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={onDrop} onAdd={onAdd} /> | |
| 156 | + | ))} | |
| 157 | + | </ul> | |
| 158 | + | )} | |
| 159 | + | </li> | |
| 160 | + | ); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | function SpaceTree({ slug, space, current, drag, setDrag }: { slug: string; space: DocsSidebarSpace; current: string | null; drag: Drag; setDrag: (d: Drag) => void }) { | |
| 164 | + | const navigate = useNavigate(); | |
| 165 | + | const { send } = useDocsAction(slug); | |
| 166 | + | const tree = useMemo(() => buildTree(space.pages), [space.pages]); | |
| 167 | + | const [open, setOpen] = useState<Set<string>>(() => new Set(pathTo(space.pages, current))); | |
| 168 | + | const [collapsed, setCollapsed] = useState(false); | |
| 169 | + | // Opening a page opens the way to it. | |
| 170 | + | useEffect(() => { | |
| 171 | + | if (!current) return; | |
| 172 | + | const path = pathTo(space.pages, current); | |
| 173 | + | if (path.length) setOpen((was) => new Set([...was, ...path])); | |
| 174 | + | }, [current, space.pages]); | |
| 175 | + | const toggle = (id: string) => | |
| 176 | + | setOpen((was) => { | |
| 177 | + | const next = new Set(was); | |
| 178 | + | if (next.has(id)) next.delete(id); | |
| 179 | + | else next.add(id); | |
| 180 | + | return next; | |
| 181 | + | }); | |
| 182 | + | const add = async (parent: string | null) => { | |
| 183 | + | const made = await send<{ path: string }>("create_page", { page: { space_id: space.id, parent_id: parent } }); | |
| 184 | + | if (made.ok) { | |
| 185 | + | if (parent) setOpen((was) => new Set([...was, parent])); | |
| 186 | + | navigate(made.value.path); | |
| 187 | + | } | |
| 188 | + | }; | |
| 189 | + | const drop = async (target: TreeItem, where: "before" | "inside") => { | |
| 190 | + | if (!drag) return; | |
| 191 | + | const move = where === "inside" ? { space_id: space.id, parent_id: target.id, before_id: null } : { space_id: space.id, parent_id: target.parent_id, before_id: target.id }; | |
| 192 | + | setDrag(null); | |
| 193 | + | await send("move_page", { page_id: drag.id, move }); | |
| 194 | + | if (where === "inside") setOpen((was) => new Set([...was, target.id])); | |
| 195 | + | }; | |
| 196 | + | const editable = canDo(space.viewer_role, "edit"); | |
| 197 | + | return ( | |
| 198 | + | <li className="mt-1"> | |
| 199 | + | <div | |
| 200 | + | className={`${ROW} text-fg-soft`} | |
| 201 | + | onDragOver={(e) => { | |
| 202 | + | if (drag && editable) e.preventDefault(); | |
| 203 | + | }} | |
| 204 | + | onDrop={async (e) => { | |
| 205 | + | // Dropped on the space itself: to the top of it, at the end. | |
| 206 | + | e.preventDefault(); | |
| 207 | + | if (!drag) return; | |
| 208 | + | const id = drag.id; | |
| 209 | + | setDrag(null); | |
| 210 | + | await send("move_page", { page_id: id, move: { space_id: space.id, parent_id: null, before_id: null } }); | |
| 211 | + | }} | |
| 212 | + | > | |
| 213 | + | <button type="button" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed} aria-label={collapsed ? `Show ${space.name}` : `Hide ${space.name}`} className="flex size-5 shrink-0 items-center justify-center rounded text-faint hover:bg-line hover:text-fg"> | |
| 214 | + | <ChevronRight size={13} className={`transition-transform ${collapsed ? "" : "rotate-90"}`} /> | |
| 215 | + | </button> | |
| 216 | + | <NavLink to={`/${slug}/-/docs/${space.slug}`} end prefetch="intent" className={({ isActive }) => `flex min-w-0 grow items-center gap-1.5 font-medium ${isActive ? "text-fg" : ""}`}> | |
| 217 | + | <span className="flex w-4 shrink-0 justify-center"> | |
| 218 | + | <SpaceIcon space={space} size={14} /> | |
| 219 | + | </span> | |
| 220 | + | <span className="min-w-0 truncate">{space.name}</span> | |
| 221 | + | </NavLink> | |
| 222 | + | {canDo(space.viewer_role, "manage") && ( | |
| 223 | + | <Hint label="Space settings"> | |
| 224 | + | <NavLink to={`/${slug}/-/docs/${space.slug}/settings`} aria-label={`${space.name} settings`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100"> | |
| 225 | + | <Settings size={13} /> | |
| 226 | + | </NavLink> | |
| 227 | + | </Hint> | |
| 228 | + | )} | |
| 229 | + | {editable && ( | |
| 230 | + | <Hint label={`New page in ${space.name}`}> | |
| 231 | + | <button type="button" onClick={() => add(null)} aria-label={`New page in ${space.name}`} className="flex size-6 shrink-0 items-center justify-center rounded text-faint opacity-0 group-hover:opacity-100 hover:bg-line hover:text-fg focus-visible:opacity-100"> | |
| 232 | + | <Plus size={13} /> | |
| 233 | + | </button> | |
| 234 | + | </Hint> | |
| 235 | + | )} | |
| 236 | + | </div> | |
| 237 | + | {!collapsed && ( | |
| 238 | + | <ul> | |
| 239 | + | {tree.map((item) => ( | |
| 240 | + | <TreeRow key={item.id} slug={slug} space={space} item={item} current={current} open={open} toggle={toggle} drag={drag} setDrag={setDrag} onDrop={drop} onAdd={add} /> | |
| 241 | + | ))} | |
| 242 | + | {tree.length === 0 && <li className="py-1 pr-2 pl-9 text-xs text-faint">No pages yet.</li>} | |
| 243 | + | </ul> | |
| 244 | + | )} | |
| 245 | + | </li> | |
| 246 | + | ); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | export function DocsSidebar({ slug, onClose }: { slug: string; onClose?: () => void }) { | |
| 250 | + | const data = useDocsData(); | |
| 251 | + | const navigate = useNavigate(); | |
| 252 | + | const params = useParams(); | |
| 253 | + | const { pathname } = useLocation(); | |
| 254 | + | const current = pageIdOf(params.page); | |
| 255 | + | const [drag, setDrag] = useState<Drag>(null); | |
| 256 | + | const [query, setQuery] = useState(""); | |
| 257 | + | const sidebar = data?.sidebar; | |
| 258 | + | const general = sidebar?.spaces.find((s) => s.is_default) ?? sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit")); | |
| 259 | + | const newPage = async () => { | |
| 260 | + | if (!general) return; | |
| 261 | + | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id } }); | |
| 262 | + | if (made.ok) navigate(made.value.path); | |
| 263 | + | }; | |
| 264 | + | return ( | |
| 265 | + | <div className="flex h-full flex-col"> | |
| 266 | + | <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2.5 pl-4"> | |
| 267 | + | <h2 className="min-w-0 grow truncate text-[0.9375rem] font-semibold">Docs</h2> | |
| 268 | + | {general && ( | |
| 269 | + | <Hint label="New page"> | |
| 270 | + | <button type="button" onClick={newPage} aria-label="New page" className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"> | |
| 271 | + | <Plus size={16} /> | |
| 272 | + | </button> | |
| 273 | + | </Hint> | |
| 274 | + | )} | |
| 275 | + | {onClose && ( | |
| 276 | + | <button type="button" aria-label="Close menu" onClick={onClose} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"> | |
| 277 | + | <ChevronRight size={16} className="rotate-180" /> | |
| 278 | + | </button> | |
| 279 | + | )} | |
| 280 | + | </div> | |
| 281 | + | <nav aria-label="Docs" className="min-h-0 grow overflow-y-auto px-2.5 pt-3 pb-4 [scrollbar-width:thin]"> | |
| 282 | + | <form | |
| 283 | + | role="search" | |
| 284 | + | onSubmit={(e) => { | |
| 285 | + | e.preventDefault(); | |
| 286 | + | navigate(`/${slug}/-/docs/search?q=${encodeURIComponent(query.trim())}`); | |
| 287 | + | }} | |
| 288 | + | className="mb-2" | |
| 289 | + | > | |
| 290 | + | <label className="flex h-8 items-center gap-2 rounded-md border border-line bg-bg px-2 text-[0.8125rem] text-faint focus-within:border-accent/60"> | |
| 291 | + | <Search size={14} aria-hidden="true" /> | |
| 292 | + | <span className="sr-only">Search docs</span> | |
| 293 | + | <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search docs" className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint" /> | |
| 294 | + | </label> | |
| 295 | + | </form> | |
| 296 | + | <div className="space-y-px"> | |
| 297 | + | <SideLink to={`/${slug}/-/docs`} end icon={<Home size={15} />}> | |
| 298 | + | Home | |
| 299 | + | </SideLink> | |
| 300 | + | <SideLink to={`/${slug}/-/docs/templates`} icon={<LayoutTemplate size={15} />}> | |
| 301 | + | Templates | |
| 302 | + | </SideLink> | |
| 303 | + | <SideLink to={`/${slug}/-/docs/trash`} icon={<Trash2 size={15} />} trailing={sidebar?.trash_count ? <span className="text-[0.6875rem] text-faint tabular-nums">{sidebar.trash_count}</span> : undefined}> | |
| 304 | + | Trash | |
| 305 | + | </SideLink> | |
| 306 | + | </div> | |
| 307 | + | {sidebar === undefined || sidebar === null ? ( | |
| 308 | + | sidebar === null ? ( | |
| 309 | + | <p className="mt-4 px-2 text-xs leading-relaxed text-faint">Docs didn't answer. Your spaces will show here once it does.</p> | |
| 310 | + | ) : ( | |
| 311 | + | <div className="mt-5 space-y-3 px-2" aria-busy="true"> | |
| 312 | + | {[0, 1, 2, 3].map((i) => ( | |
| 313 | + | <Skeleton key={i} className="h-3 w-36" /> | |
| 314 | + | ))} | |
| 315 | + | </div> | |
| 316 | + | ) | |
| 317 | + | ) : ( | |
| 318 | + | <> | |
| 319 | + | {sidebar.favorites.length > 0 && ( | |
| 320 | + | <Section title="Favorites"> | |
| 321 | + | <ul className="space-y-px"> | |
| 322 | + | {sidebar.favorites.map((p) => ( | |
| 323 | + | <li key={p.id}> | |
| 324 | + | <SideLink to={p.path} icon={<PageIcon icon={p.icon} size={14} />} trailing={<Star size={12} className="shrink-0 fill-current text-warn/80" aria-hidden="true" />}> | |
| 325 | + | {p.title || "Untitled"} | |
| 326 | + | </SideLink> | |
| 327 | + | </li> | |
| 328 | + | ))} | |
| 329 | + | </ul> | |
| 330 | + | </Section> | |
| 331 | + | )} | |
| 332 | + | {sidebar.recent.length > 0 && ( | |
| 333 | + | <Section title="Recent" open={false}> | |
| 334 | + | <ul className="space-y-px"> | |
| 335 | + | {sidebar.recent.slice(0, 6).map((p) => ( | |
| 336 | + | <li key={p.id}> | |
| 337 | + | <SideLink to={p.path} icon={<Clock size={14} />}> | |
| 338 | + | {p.title || "Untitled"} | |
| 339 | + | </SideLink> | |
| 340 | + | </li> | |
| 341 | + | ))} | |
| 342 | + | </ul> | |
| 343 | + | </Section> | |
| 344 | + | )} | |
| 345 | + | <Section | |
| 346 | + | title="Spaces" | |
| 347 | + | action={ | |
| 348 | + | sidebar.can_create_space ? ( | |
| 349 | + | <Hint label="New space"> | |
| 350 | + | <NavLink to={`/${slug}/-/docs/new`} aria-label="New space" className="flex size-6 items-center justify-center rounded text-faint hover:bg-raised hover:text-fg"> | |
| 351 | + | <Plus size={13} /> | |
| 352 | + | </NavLink> | |
| 353 | + | </Hint> | |
| 354 | + | ) : undefined | |
| 355 | + | } | |
| 356 | + | > | |
| 357 | + | <ul> | |
| 358 | + | {sidebar.spaces.map((space) => ( | |
| 359 | + | <SpaceTree key={space.id} slug={slug} space={space} current={current} drag={drag} setDrag={setDrag} /> | |
| 360 | + | ))} | |
| 361 | + | </ul> | |
| 362 | + | </Section> | |
| 363 | + | {pathname === `/${slug}/-/docs` && sidebar.spaces.length === 0 && <p className="mt-3 px-2 text-xs text-faint">No spaces you can read yet.</p>} | |
| 364 | + | </> | |
| 365 | + | )} | |
| 366 | + | </nav> | |
| 367 | + | </div> | |
| 368 | + | ); | |
| 369 | + | } | |
| 370 | + |
| 1 | + | /** | |
| 2 | + | * A space's settings, as a new space asks for them and its settings page | |
| 3 | + | * changes them: name, icon, address, who it is for, what they may do, | |
| 4 | + | * how agents change its pages, and the projects it is about. | |
| 5 | + | */ | |
| 6 | + | import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocAgentMode, type DocRole, type DocSpace, type DocSpaceKind } from "@g1t/contracts"; | |
| 7 | + | import { type FormEvent, useState } from "react"; | |
| 8 | + | ||
| 9 | + | import { Button, ErrorText } from "../ui"; | |
| 10 | + | import { SelectField } from "../ui/select"; | |
| 11 | + | ||
| 12 | + | export type SpaceFormValue = { | |
| 13 | + | name: string; | |
| 14 | + | icon: string; | |
| 15 | + | slug: string; | |
| 16 | + | description: string; | |
| 17 | + | kind: DocSpaceKind; | |
| 18 | + | team: string; | |
| 19 | + | default_role: DocRole; | |
| 20 | + | agent_mode: DocAgentMode; | |
| 21 | + | projects: string; | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | export function initialSpace(space?: DocSpace | null): SpaceFormValue { | |
| 25 | + | return { | |
| 26 | + | name: space?.name ?? "", | |
| 27 | + | icon: space?.icon ?? "", | |
| 28 | + | slug: space?.slug ?? "", | |
| 29 | + | description: space?.description ?? "", | |
| 30 | + | kind: space?.kind ?? "workspace", | |
| 31 | + | team: space?.team ?? "", | |
| 32 | + | default_role: space?.default_role ?? "edit", | |
| 33 | + | agent_mode: space?.agent_mode ?? "suggest", | |
| 34 | + | projects: (space?.projects ?? []).join(", "), | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | const FIELD = "h-9 w-full rounded-md border border-line bg-bg px-3 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/60"; | |
| 39 | + | ||
| 40 | + | function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { | |
| 41 | + | return ( | |
| 42 | + | <div className="grid gap-1.5 sm:grid-cols-[12rem_minmax(0,1fr)] sm:gap-6"> | |
| 43 | + | <div> | |
| 44 | + | <div className="text-sm font-medium">{label}</div> | |
| 45 | + | {hint && <p className="mt-0.5 text-xs leading-relaxed text-faint">{hint}</p>} | |
| 46 | + | </div> | |
| 47 | + | <div className="min-w-0">{children}</div> | |
| 48 | + | </div> | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | export function SpaceForm({ | |
| 53 | + | initial, | |
| 54 | + | teams, | |
| 55 | + | isDefault = false, | |
| 56 | + | submitLabel, | |
| 57 | + | busy, | |
| 58 | + | error, | |
| 59 | + | onSubmit, | |
| 60 | + | }: { | |
| 61 | + | initial: SpaceFormValue; | |
| 62 | + | teams: { slug: string; name: string }[]; | |
| 63 | + | isDefault?: boolean; | |
| 64 | + | submitLabel: string; | |
| 65 | + | busy: boolean; | |
| 66 | + | error: string | null; | |
| 67 | + | onSubmit: (value: SpaceFormValue) => void; | |
| 68 | + | }) { | |
| 69 | + | const [v, setV] = useState(initial); | |
| 70 | + | const set = <K extends keyof SpaceFormValue>(key: K, value: SpaceFormValue[K]) => setV((was) => ({ ...was, [key]: value })); | |
| 71 | + | const submit = (e: FormEvent) => { | |
| 72 | + | e.preventDefault(); | |
| 73 | + | onSubmit(v); | |
| 74 | + | }; | |
| 75 | + | const roles = (["view", "comment", "edit"] as DocRole[]).map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] })); | |
| 76 | + | return ( | |
| 77 | + | <form onSubmit={submit} className="space-y-6"> | |
| 78 | + | <Row label="Name"> | |
| 79 | + | <div className="flex gap-2"> | |
| 80 | + | <input aria-label="Icon (an emoji)" value={v.icon} onChange={(e) => set("icon", e.target.value)} placeholder="📚" maxLength={8} className={`${FIELD} w-14 text-center`} /> | |
| 81 | + | <input aria-label="Name" required value={v.name} onChange={(e) => set("name", e.target.value)} placeholder="Engineering" maxLength={80} className={FIELD} /> | |
| 82 | + | </div> | |
| 83 | + | </Row> | |
| 84 | + | <Row label="Description" hint="One line on what belongs here."> | |
| 85 | + | <input aria-label="Description" value={v.description} onChange={(e) => set("description", e.target.value)} maxLength={300} placeholder="Specs, runbooks and decisions for the platform team" className={FIELD} /> | |
| 86 | + | </Row> | |
| 87 | + | <Row label="Address" hint="In the space's links. Left empty, it comes from the name."> | |
| 88 | + | <input aria-label="Address" value={v.slug} onChange={(e) => set("slug", e.target.value.toLowerCase())} placeholder="engineering" pattern="[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?" className={`${FIELD} font-mono`} /> | |
| 89 | + | </Row> | |
| 90 | + | <Row label="Who it's for" hint={isDefault ? "The General space is always the whole workspace's." : "Private spaces are only their members'. Not even owners see them unless added."}> | |
| 91 | + | <SelectField | |
| 92 | + | aria-label="Who it's for" | |
| 93 | + | value={v.kind} | |
| 94 | + | disabled={isDefault} | |
| 95 | + | onValueChange={(kind) => set("kind", kind as DocSpaceKind)} | |
| 96 | + | options={(["workspace", "team", "private"] as DocSpaceKind[]).filter((k) => k !== "team" || teams.length > 0).map((k) => ({ value: k, label: DOC_SPACE_KIND_LABELS[k] }))} | |
| 97 | + | className="h-9 w-full" | |
| 98 | + | /> | |
| 99 | + | </Row> | |
| 100 | + | {v.kind === "team" && ( | |
| 101 | + | <Row label="Team"> | |
| 102 | + | <SelectField aria-label="Team" value={v.team} onValueChange={(team) => set("team", team)} placeholder="Choose a team" options={teams.map((t) => ({ value: t.slug, label: t.name }))} className="h-9 w-full" /> | |
| 103 | + | </Row> | |
| 104 | + | )} | |
| 105 | + | {v.kind !== "private" && ( | |
| 106 | + | <Row label={v.kind === "team" ? "The team can" : "Everyone can"} hint="Members you add below can be given more."> | |
| 107 | + | <SelectField aria-label="What they can do" value={v.default_role} onValueChange={(r) => set("default_role", r as DocRole)} options={roles} className="h-9 w-full" /> | |
| 108 | + | </Row> | |
| 109 | + | )} | |
| 110 | + | <Row label="Agents in this space" hint="An agent never does more than the person it works for. With Suggest, its changes wait for a person to accept them."> | |
| 111 | + | <SelectField | |
| 112 | + | aria-label="Agents in this space" | |
| 113 | + | value={v.agent_mode} | |
| 114 | + | onValueChange={(m) => set("agent_mode", m as DocAgentMode)} | |
| 115 | + | options={(["suggest", "edit"] as DocAgentMode[]).map((m) => ({ | |
| 116 | + | value: m, | |
| 117 | + | label: DOC_AGENT_MODE_LABELS[m], | |
| 118 | + | description: m === "suggest" ? "Tracked changes you accept or reject (recommended)" : "Agents edit pages directly; every edit is in the history", | |
| 119 | + | }))} | |
| 120 | + | className="h-9 w-full" | |
| 121 | + | /> | |
| 122 | + | </Row> | |
| 123 | + | <Row label="Projects" hint="owner/name, comma separated. Docs can be filtered by project."> | |
| 124 | + | <input aria-label="Projects" value={v.projects} onChange={(e) => set("projects", e.target.value)} placeholder="acme/web, acme/api" className={`${FIELD} font-mono`} /> | |
| 125 | + | </Row> | |
| 126 | + | {error && <ErrorText>{error}</ErrorText>} | |
| 127 | + | <div className="flex justify-end"> | |
| 128 | + | <Button type="submit" variant="accent" disabled={busy}> | |
| 129 | + | {busy ? "Saving…" : submitLabel} | |
| 130 | + | </Button> | |
| 131 | + | </div> | |
| 132 | + | </form> | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /** The form's value as the service takes it. */ | |
| 137 | + | export function spaceInput(v: SpaceFormValue) { | |
| 138 | + | return { | |
| 139 | + | name: v.name.trim(), | |
| 140 | + | icon: v.icon.trim() || null, | |
| 141 | + | slug: v.slug.trim() || null, | |
| 142 | + | description: v.description.trim() || null, | |
| 143 | + | kind: v.kind, | |
| 144 | + | team: v.kind === "team" ? v.team : null, | |
| 145 | + | default_role: v.kind === "private" ? null : v.default_role, | |
| 146 | + | agent_mode: v.agent_mode, | |
| 147 | + | projects: v.projects | |
| 148 | + | .split(/[\s,]+/) | |
| 149 | + | .map((p) => p.trim()) | |
| 150 | + | .filter(Boolean), | |
| 151 | + | }; | |
| 152 | + | } |
| 1 | + | /** The built-in templates Home shows first (the rest are on the Templates page). */ | |
| 2 | + | export const BUILTIN_TEMPLATE_IDS = ["builtin:meeting-notes", "builtin:rfc", "builtin:runbook", "builtin:postmortem", "builtin:decision", "builtin:onboarding", "builtin:project-brief", "builtin:weekly-update"]; |
| 1 | + | /** | |
| 2 | + | * Names and faces for member keys (`user:<id>`, `agent:<id>`): comment | |
| 3 | + | * authors and history. Asked of the site once per key per tab. | |
| 4 | + | */ | |
| 5 | + | import type { Result } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | /** Someone by member key, as the site names them. */ | |
| 8 | + | export type Who = { key: string; kind: "user" | "agent"; id: string; name: string; display_name: string; avatar: string | null; avatar_seed: string | null }; | |
| 9 | + | ||
| 10 | + | const known = new Map<string, Who>(); | |
| 11 | + | ||
| 12 | + | /** Names and faces for member keys (`user:<id>`, `agent:<id>`), asked of the site once each. */ | |
| 13 | + | export async function whoAre(slug: string, keys: string[]): Promise<Map<string, Who>> { | |
| 14 | + | const wanted = [...new Set(keys)].filter((k) => !known.has(k) && /^(user|agent):/.test(k)); | |
| 15 | + | if (wanted.length) { | |
| 16 | + | try { | |
| 17 | + | const r = await fetch(`/${slug}/-/docs/api?who=${encodeURIComponent(wanted.join(","))}`, { headers: { accept: "application/json" } }); | |
| 18 | + | const result = (await r.json()) as Result<Who[]>; | |
| 19 | + | if (result.ok) for (const w of result.value) known.set(w.key, w); | |
| 20 | + | } catch { | |
| 21 | + | // Shown as "Someone" until asked again. | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | return new Map(keys.map((k) => [k, known.get(k)!]).filter(([, w]) => !!w) as [string, Who][]); | |
| 25 | + | } | |
| 26 | + |
| 190 | 190 | ||
| 191 | 191 | /** What the line under the frame says about the moment: working today, or a preview. */ | |
| 192 | 192 | function honesty(frame: Frame): { today: boolean; label: string } { | |
| 193 | − | if (frame.scene === "docs") return { today: false, label: "Docs: coming soon" }; | |
| 193 | + | if (frame.scene === "docs") return { today: false, label: "Docs works today; an agent updating a page after a merge on its own is coming soon" }; | |
| 194 | 194 | if (frame.scene === "agents") return { today: true, label: "Sessions work today" }; | |
| 195 | 195 | if (frame.scene === "code") return { today: true, label: "Works today" }; | |
| 196 | 196 | if (frame.handoff && !frame.shipped) return { today: false, label: "g1t splitting work between colleagues: coming soon" }; | |
| ⋯ | |||
| 1066 | 1066 | ||
| 1067 | 1067 | /* Docs ---------------------------------------------------------------- */ | |
| 1068 | 1068 | ||
| 1069 | − | /** Docs is coming: its sidebar is a preview of what it will hold, and says so. */ | |
| 1069 | + | /** Docs' sidebar: search, then each space's pages. */ | |
| 1070 | 1070 | function DocsSide() { | |
| 1071 | 1071 | return ( | |
| 1072 | 1072 | <> | |
| 1073 | − | <SideHead action={<Plus size={14} />} soon> | |
| 1073 | + | <SideHead action={<Plus size={14} />}> | |
| 1074 | 1074 | Docs | |
| 1075 | 1075 | </SideHead> | |
| 1076 | 1076 | <div className="px-2 pt-2.5"> | |
| ⋯ | |||
| 1094 | 1094 | ); | |
| 1095 | 1095 | } | |
| 1096 | 1096 | ||
| 1097 | − | function DocsMain({ frame, tag = true }: { frame: Frame; tag?: boolean }) { | |
| 1097 | + | function DocsMain({ frame }: { frame: Frame; tag?: boolean }) { | |
| 1098 | 1098 | return ( | |
| 1099 | 1099 | <div className="relative h-full px-10 py-7"> | |
| 1100 | − | {tag && ( | |
| 1101 | − | <span className="absolute top-4 right-5 rounded-full bg-raised px-2.5 py-1 font-mono text-[10px] tracking-wide text-muted uppercase ring-1 ring-line-strong"> | |
| 1102 | − | Coming soon | |
| 1103 | − | </span> | |
| 1104 | − | )} | |
| 1105 | 1100 | <p className="text-[11px] text-faint">Product / Exporting data</p> | |
| 1106 | 1101 | <h3 className="mt-2 text-[22px] font-semibold tracking-tight text-fg">Exporting data</h3> | |
| 1107 | 1102 | <p className="mt-1.5 flex items-center gap-2 text-[11px] text-faint"> | |
| ⋯ | |||
| 1300 | 1295 | <span className="text-faint">{MODE[frame.scene].icon}</span> | |
| 1301 | 1296 | <span className="truncate">{MODE[frame.scene].label}</span> | |
| 1302 | 1297 | </span> | |
| 1303 | − | {frame.scene === "docs" && <Soon />} | |
| 1304 | 1298 | </div> | |
| 1305 | 1299 | <div className="relative min-h-0 flex-1"> | |
| 1306 | 1300 | {scenes.map((scene) => { | |
| ⋯ | |||
| 1608 | 1602 | </div> | |
| 1609 | 1603 | <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance"> | |
| 1610 | 1604 | Chat, agents and their sessions (colleagues brought in included), pull requests, checks and the merge queue work | |
| 1611 | − | today. g1t splitting one request between colleagues on its own, and Docs, are coming soon. Otto, Margo and the others are | |
| 1605 | + | today, and so does Docs. g1t splitting one request between colleagues on its own, and an agent updating a page after a merge by itself, are coming soon. Otto, Margo and the others are | |
| 1612 | 1606 | agents this workspace hired from role templates; only @g1t comes built in. | |
| 1613 | 1607 | </p> | |
| 1614 | 1608 | </div> | |
| 28 | 28 | import { type ModeKey, SETTINGS_PAGES, modeOf, sidebarCurrent } from "../lib/workspace-nav"; | |
| 29 | 29 | import { AgentsSidebar } from "./agents-mode"; | |
| 30 | 30 | import { ChatSidebar } from "./chat/sidebar"; | |
| 31 | + | import { DocsSidebar } from "./docs/sidebar"; | |
| 31 | 32 | import { HelpMenu, Rail } from "./rail"; | |
| 32 | 33 | import { HomeSidebar } from "./home-sidebar"; | |
| 33 | 34 | import { G1tMark } from "./orchestrator"; | |
| ⋯ | |||
| 2056 | 2057 | const SIDEBAR_BOX = "h-full border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]"; | |
| 2057 | 2058 | ||
| 2058 | 2059 | /** | |
| 2059 | − | * Which sidebar sits beside the rail: each mode's own, or none (Docs, the | |
| 2060 | − | * Inbox, which is a page of its own, and g1t's public pages, such as a | |
| 2061 | − | * profile, which are no workspace's and carry their own left column). | |
| 2062 | − | * Without a workspace (a visitor), the one sidebar there always was. | |
| 2060 | + | * Which sidebar sits beside the rail: each mode's own, or none (the Inbox, | |
| 2061 | + | * which is a page of its own, and g1t's public pages, such as a profile, | |
| 2062 | + | * which are no workspace's and carry their own left column). Without a | |
| 2063 | + | * workspace (a visitor), the one sidebar there always was. | |
| 2063 | 2064 | */ | |
| 2064 | 2065 | function sidebarFor(mode: ModeKey | null): Panel | null { | |
| 2065 | 2066 | if (mode == null) return "code"; | |
| 2066 | − | if (mode === "docs" || mode === "inbox" || mode === "site") return null; | |
| 2067 | + | if (mode === "inbox" || mode === "site") return null; | |
| 2067 | 2068 | return mode; | |
| 2068 | 2069 | } | |
| 2069 | 2070 | ||
| 2070 | 2071 | /** The sidebars that sit beside the rail, one per mode that has one. */ | |
| 2071 | − | type Panel = "home" | "chat" | "agents" | "code" | "workspace" | "account"; | |
| 2072 | + | type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account"; | |
| 2072 | 2073 | ||
| 2073 | 2074 | /** | |
| 2074 | 2075 | * The app: a sidebar with the workspace, its repositories and the sections | |
| ⋯ | |||
| 2144 | 2145 | return <HomeSidebar slug={ws.slug} shell={shell} code={code} onFind={find} header={<ModeHeader title="Home" onClose={close} />} />; | |
| 2145 | 2146 | case "chat": | |
| 2146 | 2147 | return <ChatSidebar slug={ws.slug} />; | |
| 2148 | + | case "docs": | |
| 2149 | + | return <DocsSidebar slug={ws.slug} onClose={close} />; | |
| 2147 | 2150 | case "agents": | |
| 2148 | 2151 | return <AgentsSidebar slug={ws.slug} shellAgents={shell.agents ?? null} code={code} owner={ws.role === "owner"} />; | |
| 2149 | 2152 | case "workspace": | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { buildTree, canDo, coverStyle, cursorColour, flatten, pageIdOf, pagePath, pathTo, readingTime, snippetParts } from "./docs.ts"; | |
| 5 | + | ||
| 6 | + | const id = "pag_01jb2k7x9hfq0b3zj0f5s2m8ra"; | |
| 7 | + | ||
| 8 | + | const nodes = [ | |
| 9 | + | { id: "b", parent_id: null, position: 2, title: "B", icon: null, slug: "b" }, | |
| 10 | + | { id: "a", parent_id: null, position: 1, title: "A", icon: null, slug: "a" }, | |
| 11 | + | { id: "c", parent_id: "a", position: 1, title: "C", icon: null, slug: "c" }, | |
| 12 | + | { id: "orphan", parent_id: "gone", position: 3, title: "O", icon: null, slug: "o" }, | |
| 13 | + | ]; | |
| 14 | + | ||
| 15 | + | test("the tree orders each level and lifts pages whose parent is gone", () => { | |
| 16 | + | const tree = buildTree(nodes); | |
| 17 | + | assert.deepEqual( | |
| 18 | + | tree.map((n) => n.id), | |
| 19 | + | ["a", "b", "orphan"], | |
| 20 | + | ); | |
| 21 | + | assert.equal(tree[0]!.children[0]!.id, "c"); | |
| 22 | + | assert.equal(tree[0]!.children[0]!.depth, 1); | |
| 23 | + | assert.deepEqual( | |
| 24 | + | flatten(tree).map((n) => n.id), | |
| 25 | + | ["a", "c", "b", "orphan"], | |
| 26 | + | ); | |
| 27 | + | assert.deepEqual(pathTo(nodes, "c"), ["a"]); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("page addresses carry the id, read back from the end", () => { | |
| 31 | + | assert.equal(pagePath("acme", "eng", "Release plan!", id), `/acme/-/docs/eng/release-plan-${id}`); | |
| 32 | + | assert.equal(pagePath("acme", "eng", "", id), `/acme/-/docs/eng/${id}`); | |
| 33 | + | assert.equal(pageIdOf(`old-title-${id}`), id); | |
| 34 | + | assert.equal(pageIdOf("settings"), null); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("snippets mark their matches", () => { | |
| 38 | + | assert.deepEqual(snippetParts("a [[b]] c"), [ | |
| 39 | + | { text: "a ", match: false }, | |
| 40 | + | { text: "b", match: true }, | |
| 41 | + | { text: " c", match: false }, | |
| 42 | + | ]); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("covers, colours, roles, reading time", () => { | |
| 46 | + | assert.match(coverStyle("gradient:1")!, /^linear-gradient/); | |
| 47 | + | assert.match(coverStyle("https://g1tusercontent.com/docs-files/x")!, /url\("https:/); | |
| 48 | + | assert.equal(coverStyle("javascript:alert(1)"), null); | |
| 49 | + | assert.equal(cursorColour("ana"), cursorColour("ana")); | |
| 50 | + | assert.equal(canDo("edit", "comment"), true); | |
| 51 | + | assert.equal(canDo("comment", "edit"), false); | |
| 52 | + | assert.deepEqual(readingTime("one two three"), { words: 3, minutes: 1 }); | |
| 53 | + | }); |
| 1 | + | /** | |
| 2 | + | * Docs mode's pure helpers: page trees, addresses, cursor colours, covers | |
| 3 | + | * and how search snippets mark their matches. No Workers or DOM imports, | |
| 4 | + | * so it is tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { DocRole, DocTreeNode } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | export type TreeItem = DocTreeNode & { children: TreeItem[]; depth: number }; | |
| 9 | + | ||
| 10 | + | /** A space's flat page list as a tree, each level in position order. */ | |
| 11 | + | export function buildTree(nodes: DocTreeNode[]): TreeItem[] { | |
| 12 | + | const byParent = new Map<string | null, DocTreeNode[]>(); | |
| 13 | + | const ids = new Set(nodes.map((n) => n.id)); | |
| 14 | + | for (const node of nodes) { | |
| 15 | + | // A page whose parent is gone (in the trash) shows at the top. | |
| 16 | + | const parent = node.parent_id && ids.has(node.parent_id) ? node.parent_id : null; | |
| 17 | + | const list = byParent.get(parent) ?? []; | |
| 18 | + | list.push(node); | |
| 19 | + | byParent.set(parent, list); | |
| 20 | + | } | |
| 21 | + | const seen = new Set<string>(); | |
| 22 | + | const build = (parent: string | null, depth: number): TreeItem[] => | |
| 23 | + | (byParent.get(parent) ?? []) | |
| 24 | + | .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)) | |
| 25 | + | .filter((n) => !seen.has(n.id) && seen.add(n.id)) | |
| 26 | + | .map((n) => ({ ...n, depth, children: build(n.id, depth + 1) })); | |
| 27 | + | return build(null, 0); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** Every page in a tree, depth first, as a move dialog lists them. */ | |
| 31 | + | export function flatten(tree: TreeItem[]): TreeItem[] { | |
| 32 | + | const out: TreeItem[] = []; | |
| 33 | + | const walk = (items: TreeItem[]) => { | |
| 34 | + | for (const item of items) { | |
| 35 | + | out.push(item); | |
| 36 | + | walk(item.children); | |
| 37 | + | } | |
| 38 | + | }; | |
| 39 | + | walk(tree); | |
| 40 | + | return out; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /** The ids on the way to `id` (its ancestors), so the sidebar opens them. */ | |
| 44 | + | export function pathTo(nodes: DocTreeNode[], id: string | null): string[] { | |
| 45 | + | if (!id) return []; | |
| 46 | + | const byId = new Map(nodes.map((n) => [n.id, n])); | |
| 47 | + | const out: string[] = []; | |
| 48 | + | let at = byId.get(id)?.parent_id ?? null; | |
| 49 | + | while (at && !out.includes(at)) { | |
| 50 | + | out.unshift(at); | |
| 51 | + | at = byId.get(at)?.parent_id ?? null; | |
| 52 | + | } | |
| 53 | + | return out; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** The words of a title as an address: `release-plan`. */ | |
| 57 | + | export function titleSlug(title: string): string { | |
| 58 | + | return title | |
| 59 | + | .normalize("NFKD") | |
| 60 | + | .replace(/[\u0300-\u036f]/g, "") | |
| 61 | + | .toLowerCase() | |
| 62 | + | .replace(/[^a-z0-9]+/g, "-") | |
| 63 | + | .replace(/^-+|-+$/g, "") | |
| 64 | + | .slice(0, 50) | |
| 65 | + | .replace(/-+$/g, ""); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /** A page's address, from its space and current title; the id at the end is what is read. */ | |
| 69 | + | export function pagePath(workspace: string, space: string, title: string, id: string): string { | |
| 70 | + | const words = titleSlug(title); | |
| 71 | + | return `/${workspace}/-/docs/${space}/${words ? `${words}-${id}` : id}`; | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** The page id at the end of a page's address segment. */ | |
| 75 | + | export function pageIdOf(segment: string | undefined): string | null { | |
| 76 | + | return /(pag_[0-9a-hjkmnp-tv-z]{26})$/.exec(segment ?? "")?.[1] ?? null; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** A search snippet's parts: `[[word]]` marks a match. */ | |
| 80 | + | export function snippetParts(snippet: string): { text: string; match: boolean }[] { | |
| 81 | + | const out: { text: string; match: boolean }[] = []; | |
| 82 | + | const re = /\[\[([\s\S]*?)\]\]/g; | |
| 83 | + | let at = 0; | |
| 84 | + | for (let m = re.exec(snippet); m; m = re.exec(snippet)) { | |
| 85 | + | if (m.index > at) out.push({ text: snippet.slice(at, m.index), match: false }); | |
| 86 | + | out.push({ text: m[1]!, match: true }); | |
| 87 | + | at = m.index + m[0].length; | |
| 88 | + | } | |
| 89 | + | if (at < snippet.length) out.push({ text: snippet.slice(at), match: false }); | |
| 90 | + | return out; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** Cursor colours: distinct, readable on the dark page. */ | |
| 94 | + | export const CURSOR_COLOURS = ["#b8a6ff", "#7dd3fc", "#86efac", "#fcd34d", "#fca5a5", "#f9a8d4", "#a5b4fc", "#5eead4", "#fdba74", "#c4b5fd"]; | |
| 95 | + | ||
| 96 | + | /** A person's cursor colour, stable for their name. */ | |
| 97 | + | export function cursorColour(name: string): string { | |
| 98 | + | let hash = 0; | |
| 99 | + | for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 100 | + | return CURSOR_COLOURS[Math.abs(hash) % CURSOR_COLOURS.length]!; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /** The covers a page can take without an upload. */ | |
| 104 | + | export const COVER_GRADIENTS = [ | |
| 105 | + | "linear-gradient(120deg, #2a2340 0%, #4b3d7a 50%, #8f7ee0 100%)", | |
| 106 | + | "linear-gradient(120deg, #11222c 0%, #1f4b5c 55%, #4fb3c8 100%)", | |
| 107 | + | "linear-gradient(120deg, #1d1f17 0%, #3d4a24 55%, #a3c45a 100%)", | |
| 108 | + | "linear-gradient(120deg, #2b1a1a 0%, #5c2e2e 55%, #d9776a 100%)", | |
| 109 | + | "linear-gradient(120deg, #241a2b 0%, #5a2d5f 55%, #d27bd8 100%)", | |
| 110 | + | "linear-gradient(120deg, #1a1d2b 0%, #2c3466 55%, #7a8cf0 100%)", | |
| 111 | + | ]; | |
| 112 | + | ||
| 113 | + | /** A cover's CSS background: one of the gradients, or an image. */ | |
| 114 | + | export function coverStyle(cover: string | null | undefined): string | null { | |
| 115 | + | if (!cover) return null; | |
| 116 | + | const gradient = /^gradient:(\d{1,2})$/.exec(cover); | |
| 117 | + | if (gradient) return COVER_GRADIENTS[Number(gradient[1]) % COVER_GRADIENTS.length]!; | |
| 118 | + | if (/^https:\/\//.test(cover)) return `center / cover no-repeat url("${cover.replace(/["\\]/g, "")}")`; | |
| 119 | + | return null; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; | |
| 123 | + | ||
| 124 | + | export function canDo(role: DocRole | null | undefined, need: DocRole): boolean { | |
| 125 | + | return !!role && RANK[role] >= RANK[need]; | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /** How many words a page has, and the minutes it takes to read. */ | |
| 129 | + | export function readingTime(markdown: string): { words: number; minutes: number } { | |
| 130 | + | const words = markdown.replace(/```[\s\S]*?```/g, " ").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; | |
| 131 | + | return { words, minutes: Math.max(1, Math.round(words / 230)) }; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /** A Markdown file's name for a page. */ | |
| 135 | + | export function markdownFileName(title: string): string { | |
| 136 | + | return `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "Untitled"}.md`; | |
| 137 | + | } |
| 8 | 8 | chatClient, | |
| 9 | 9 | contextClient, | |
| 10 | 10 | deploymentsClient, | |
| 11 | + | docsClient, | |
| 11 | 12 | memoryReviewClient, | |
| 12 | 13 | packagesClient, | |
| 13 | 14 | projectsClient, | |
| ⋯ | |||
| 47 | 48 | const PACKAGES = instrumented("packages", env.PACKAGES); | |
| 48 | 49 | const CHAT = instrumented("chat", env.CHAT); | |
| 49 | 50 | const AGENTS = instrumented("agents", env.AGENTS); | |
| 51 | + | const DOCS = instrumented("docs", env.DOCS); | |
| 50 | 52 | ||
| 51 | 53 | export const identity = identityClient(IDENTITY); | |
| 52 | 54 | /** A person's email addresses and account security: methods of identity. */ | |
| ⋯ | |||
| 81 | 83 | export const chat = chatClient(CHAT); | |
| 82 | 84 | /** The workspace's own agents: who they are, their limits and their desks. */ | |
| 83 | 85 | export const workspaceAgents = workspaceAgentsClient(AGENTS); | |
| 86 | + | /** Docs: spaces, pages, history, suggestions and comments. */ | |
| 87 | + | export const docs = docsClient(DOCS); | |
| 141 | 141 | // Sessions, colleagues brought in, spend against a cap: built. | |
| 142 | 142 | { scene: "agents", label: "Agents", start: BEAT.agents, soon: false }, | |
| 143 | 143 | { scene: "code", label: "Code", start: BEAT.code, soon: false }, | |
| 144 | − | { scene: "docs", label: "Docs", start: BEAT.docs, soon: true }, | |
| 144 | + | // Docs is built; an agent keeping a page current after a merge on its | |
| 145 | + | // own is not yet, and the line under the frame says so. | |
| 146 | + | { scene: "docs", label: "Docs", start: BEAT.docs, soon: false }, | |
| 145 | 147 | ]; | |
| 146 | 148 | ||
| 147 | 149 | /** Which pill a time falls under. Back in chat after the merge is still Code's story. */ |
| 156 | 156 | route("dm/:id", "routes/workspace/chat/channel.tsx", { id: "routes/workspace/chat/dm" }), | |
| 157 | 157 | route(":channel", "routes/workspace/chat/channel.tsx"), | |
| 158 | 158 | ]), | |
| 159 | − | // Docs mode, coming; Home for a member without Code; and what Code's | |
| 160 | − | // pages say to them (docs/WORKSPACE.md, "Members without Code"). | |
| 161 | − | route("-/docs", "routes/workspace/docs.tsx"), | |
| 159 | + | // Docs mode (docs/WORKSPACE.md, "Docs"): what its pages call as they | |
| 160 | + | // run (a page's live socket, JSON, comments, uploads, export), then | |
| 161 | + | // Home, search, templates, the trash, a new space, and each space and | |
| 162 | + | // page by its address. A page's address ends in its id, so renaming it | |
| 163 | + | // keeps links working. | |
| 164 | + | route("-/docs/live", "routes/workspace/docs/live.ts"), | |
| 165 | + | route("-/docs/api", "routes/workspace/docs/api.ts"), | |
| 166 | + | route("-/docs/threads/:page/*", "routes/workspace/docs/threads.ts"), | |
| 167 | + | route("-/docs/upload", "routes/workspace/docs/upload.ts"), | |
| 168 | + | route("-/docs/export", "routes/workspace/docs/export.ts"), | |
| 169 | + | route("-/docs", "routes/workspace/docs/layout.tsx", [ | |
| 170 | + | index("routes/workspace/docs/home.tsx"), | |
| 171 | + | route("search", "routes/workspace/docs/search.tsx"), | |
| 172 | + | route("templates", "routes/workspace/docs/templates.tsx"), | |
| 173 | + | route("trash", "routes/workspace/docs/trash.tsx"), | |
| 174 | + | route("new", "routes/workspace/docs/new-space.tsx"), | |
| 175 | + | route(":space", "routes/workspace/docs/space.tsx"), | |
| 176 | + | route(":space/settings", "routes/workspace/docs/space-settings.tsx"), | |
| 177 | + | route(":space/:page", "routes/workspace/docs/page.tsx"), | |
| 178 | + | ]), | |
| 179 | + | // Home for a member without Code, and what Code's pages say to them | |
| 180 | + | // (docs/WORKSPACE.md, "Members without Code"). | |
| 162 | 181 | route("-/home", "routes/workspace/home.tsx"), | |
| 163 | 182 | route("-/code-access", "routes/workspace/code-access.tsx"), | |
| 164 | 183 | route("-/memory", "routes/workspace/memory.tsx"), |
| 1 | − | import { BookOpen, FileText, GitBranch, MessagesSquare, Sparkles } from "lucide-react"; | |
| 2 | − | import type { ReactNode } from "react"; | |
| 3 | − | ||
| 4 | − | import type { Route } from "./+types/docs"; | |
| 5 | − | import { page } from "../../lib/meta"; | |
| 6 | − | ||
| 7 | − | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 8 | − | return page(args, { title: `Docs · ${params.owner} · g1t` }); | |
| 9 | − | } | |
| 10 | − | ||
| 11 | − | function Point({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| 12 | − | return ( | |
| 13 | − | <li className="flex gap-3.5 rounded-xl border border-line bg-surface p-4"> | |
| 14 | − | <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">{icon}</span> | |
| 15 | − | <span> | |
| 16 | − | <span className="block text-sm font-medium">{title}</span> | |
| 17 | − | <span className="mt-0.5 block text-[0.8125rem] leading-relaxed text-muted">{children}</span> | |
| 18 | − | </span> | |
| 19 | − | </li> | |
| 20 | − | ); | |
| 21 | − | } | |
| 22 | − | ||
| 23 | − | /** Docs mode, coming: what it will be (docs/WORKSPACE.md, "Docs"). */ | |
| 24 | − | export default function Docs() { | |
| 25 | − | return ( | |
| 26 | − | <div className="mx-auto max-w-3xl"> | |
| 27 | − | <div className="flex items-center gap-2"> | |
| 28 | − | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/15 text-accent"> | |
| 29 | − | <BookOpen size={20} /> | |
| 30 | − | </span> | |
| 31 | − | <span className="rounded-full px-2 py-0.5 text-[0.6875rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Coming soon</span> | |
| 32 | − | </div> | |
| 33 | − | <h1 className="mt-5 text-3xl font-semibold tracking-tight">Docs that keep themselves true</h1> | |
| 34 | − | <p className="mt-3 max-w-2xl text-[0.9375rem] leading-relaxed text-muted"> | |
| 35 | − | The workspace's written knowledge: specs, runbooks, decisions and onboarding, edited together in real time. Agents read it before they | |
| 36 | − | work and keep it current after every merge. | |
| 37 | − | </p> | |
| 38 | − | <ul className="mt-8 grid gap-3 sm:grid-cols-2"> | |
| 39 | − | <Point icon={<FileText size={16} />} title="Spaces and pages"> | |
| 40 | − | One space per team or project, each a tree of pages with history, comments and backlinks. | |
| 41 | − | </Point> | |
| 42 | − | <Point icon={<Sparkles size={16} />} title="Agents read and write"> | |
| 43 | − | Every reply and task draws on it. Without write access, an agent's edit is a suggestion you accept or reject. | |
| 44 | − | </Point> | |
| 45 | − | <Point icon={<GitBranch size={16} />} title="Pages know what they describe"> | |
| 46 | − | A page that cites code is marked stale when a merge changes it, and its owner drafts the update. | |
| 47 | − | </Point> | |
| 48 | − | <Point icon={<MessagesSquare size={16} />} title="Conversations become pages"> | |
| 49 | − | “Write this up” in a thread makes a page from it, linked both ways, so decisions get a home. | |
| 50 | − | </Point> | |
| 51 | − | </ul> | |
| 52 | − | </div> | |
| 53 | − | ); | |
| 54 | − | } |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/api"; | |
| 6 | + | import { workspacePeople } from "../../../lib/chat.server"; | |
| 7 | + | import { docs, identity, repos, work } from "../../../lib/services.server"; | |
| 8 | + | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 9 | + | import { pageIdOf } from "../../../lib/docs"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * What Docs pages ask for as they run, as JSON: page search for links and | |
| 13 | + | * the sidebar's search box, a page's history and one version, cards for | |
| 14 | + | * embedded g1t things; and everything they change: pages, spaces, members, | |
| 15 | + | * suggestions, templates. Every answer is `{ ok, value }` or | |
| 16 | + | * `{ ok: false, error }`, the services' own shape. | |
| 17 | + | */ | |
| 18 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 19 | + | const viewer = requireUser(context, request); | |
| 20 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 21 | + | const slug = params.owner.toLowerCase(); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | const q = url.searchParams; | |
| 24 | + | const answer = await safely<unknown>(async () => { | |
| 25 | + | if (q.has("search")) return docs.search(slug, viewer, { query: q.get("search") ?? "", space_id: q.get("space"), project: q.get("project"), limit: Number(q.get("limit")) || 10 }); | |
| 26 | + | if (q.get("versions")) return docs.versions(slug, q.get("versions")!, viewer); | |
| 27 | + | if (q.get("version") && q.get("page")) return docs.version(slug, q.get("page")!, q.get("version")!, viewer); | |
| 28 | + | if (q.get("suggestions")) return docs.suggestions(slug, q.get("suggestions")!, viewer); | |
| 29 | + | if (q.get("threads")) return docs.threads(slug, q.get("threads")!, viewer); | |
| 30 | + | if (q.get("space")) return docs.space(slug, q.get("space")!, viewer); | |
| 31 | + | if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin); | |
| 32 | + | if (q.has("templates")) return docs.templates(slug, viewer); | |
| 33 | + | // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page. | |
| 34 | + | if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100)); | |
| 35 | + | // A member's key (`user:<id>`) by username, for adding them to a space. | |
| 36 | + | if (q.get("person")) { | |
| 37 | + | const user = await identity.userByUsername(q.get("person")!.toLowerCase()); | |
| 38 | + | return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } }; | |
| 39 | + | } | |
| 40 | + | return docs.sidebar(slug, viewer); | |
| 41 | + | }); | |
| 42 | + | return Response.json(answer, { headers: { "cache-control": "no-store" } }); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type Sent = { | |
| 46 | + | intent?: string; | |
| 47 | + | page_id?: string; | |
| 48 | + | space_id?: string; | |
| 49 | + | suggestion_id?: string; | |
| 50 | + | version_id?: string; | |
| 51 | + | template_id?: string; | |
| 52 | + | member?: string; | |
| 53 | + | role?: DocRole | null; | |
| 54 | + | decision?: "accept" | "reject"; | |
| 55 | + | on?: boolean; | |
| 56 | + | page?: NewDocPage; | |
| 57 | + | change?: DocPageChange; | |
| 58 | + | move?: DocMove; | |
| 59 | + | space?: NewDocSpace; | |
| 60 | + | space_change?: DocSpaceChange; | |
| 61 | + | name?: string; | |
| 62 | + | description?: string | null; | |
| 63 | + | target?: DocEditTarget; | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 67 | + | assertSameOrigin(request); | |
| 68 | + | const viewer = requireUser(context, request); | |
| 69 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 70 | + | const slug = params.owner.toLowerCase(); | |
| 71 | + | const sent = (await request.json().catch(() => ({}))) as Sent; | |
| 72 | + | const page = sent.page_id ?? ""; | |
| 73 | + | const answer = await safely(async (): Promise<Result<unknown>> => { | |
| 74 | + | switch (sent.intent) { | |
| 75 | + | case "create_page": | |
| 76 | + | return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage)); | |
| 77 | + | case "update_page": | |
| 78 | + | return docs.updatePage(slug, page, viewer, sent.change ?? {}); | |
| 79 | + | case "move_page": | |
| 80 | + | return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null }); | |
| 81 | + | case "duplicate_page": | |
| 82 | + | return docs.duplicatePage(slug, page, viewer); | |
| 83 | + | case "archive_page": | |
| 84 | + | return docs.archivePage(slug, page, viewer); | |
| 85 | + | case "restore_page": | |
| 86 | + | return docs.restorePage(slug, page, viewer); | |
| 87 | + | case "delete_page": | |
| 88 | + | return docs.deletePage(slug, page, viewer); | |
| 89 | + | case "favorite": | |
| 90 | + | return docs.favorite(slug, page, viewer, !!sent.on); | |
| 91 | + | case "restore_version": | |
| 92 | + | return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer); | |
| 93 | + | case "decide": | |
| 94 | + | return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject"); | |
| 95 | + | case "accept_all": | |
| 96 | + | return docs.acceptAll(slug, page, viewer); | |
| 97 | + | case "save_template": | |
| 98 | + | return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null }); | |
| 99 | + | case "delete_template": | |
| 100 | + | return docs.deleteTemplate(slug, sent.template_id ?? "", viewer); | |
| 101 | + | case "create_space": | |
| 102 | + | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); | |
| 103 | + | case "update_space": | |
| 104 | + | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); | |
| 105 | + | case "set_member": | |
| 106 | + | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); | |
| 107 | + | default: | |
| 108 | + | return { ok: false, error: { code: "invalid", message: "Unknown request." } }; | |
| 109 | + | } | |
| 110 | + | }); | |
| 111 | + | return Response.json(answer, { headers: { "cache-control": "no-store" } }); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> { | |
| 115 | + | try { | |
| 116 | + | return await run(); | |
| 117 | + | } catch (error) { | |
| 118 | + | console.error("docs api:", error); | |
| 119 | + | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } }; | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * The card for a g1t address embedded in a page, as this reader may see | |
| 125 | + | * it: an issue or pull request with its state, a channel, a project, a | |
| 126 | + | * page. Anything they can't read is not found, exactly as one that does | |
| 127 | + | * not exist. | |
| 128 | + | */ | |
| 129 | + | async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> { | |
| 130 | + | let path: string; | |
| 131 | + | try { | |
| 132 | + | const url = new URL(address, origin); | |
| 133 | + | if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } }; | |
| 134 | + | path = url.pathname; | |
| 135 | + | } catch { | |
| 136 | + | return { ok: false, error: { code: "invalid", message: "That isn't an address." } }; | |
| 137 | + | } | |
| 138 | + | const parts = path.split("/").filter(Boolean).map(decodeURIComponent); | |
| 139 | + | const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } }; | |
| 140 | + | // /<owner>/-/docs/<space>/<page> | |
| 141 | + | if (parts[1] === "-" && parts[2] === "docs" && parts[4]) { | |
| 142 | + | if (parts[0]!.toLowerCase() !== slug) return missing; | |
| 143 | + | const id = pageIdOf(parts[4]); | |
| 144 | + | if (!id) return missing; | |
| 145 | + | const found = await docs.page(slug, id, viewer); | |
| 146 | + | if (!found.ok) return missing; | |
| 147 | + | return { ok: true, value: { kind: "page", title: `${found.value.page.icon ? `${found.value.page.icon} ` : ""}${found.value.page.title || "Untitled"}`, subtitle: found.value.space.name, state: null, href: found.value.page.path } }; | |
| 148 | + | } | |
| 149 | + | // /<owner>/-/chat/<channel> | |
| 150 | + | if (parts[1] === "-" && parts[2] === "chat" && parts[3]) { | |
| 151 | + | return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } }; | |
| 152 | + | } | |
| 153 | + | if (parts.length < 2 || parts[1] === "-") return missing; | |
| 154 | + | const repo = { namespace: parts[0]!, name: parts[1]! }; | |
| 155 | + | // /<owner>/<repo>/issues/<n> and /pull/<n> | |
| 156 | + | const number = Number(parts[3]); | |
| 157 | + | if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) { | |
| 158 | + | if (parts[2] === "issues") { | |
| 159 | + | const found = await work.getIssue(repo, number, viewer); | |
| 160 | + | if (!found.ok) return missing; | |
| 161 | + | return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } }; | |
| 162 | + | } | |
| 163 | + | const found = await work.getPull(repo, number, viewer); | |
| 164 | + | if (!found.ok) return missing; | |
| 165 | + | const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null }; | |
| 166 | + | const state = pull.mergedAt ? "merged" : String(pull.status ?? "open"); | |
| 167 | + | return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } }; | |
| 168 | + | } | |
| 169 | + | const found = await repos.get(repo, viewer); | |
| 170 | + | if (!found.ok) return missing; | |
| 171 | + | return { ok: true, value: { kind: "project", title: `${repo.namespace}/${repo.name}`, subtitle: (found.value as { description?: string | null }).description ?? "Project", state: null, href: `/${repo.namespace}/${repo.name}` } }; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /** Names and faces for member keys, as the workspace knows them. */ | |
| 175 | + | async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> { | |
| 176 | + | const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5)); | |
| 177 | + | const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6))); | |
| 178 | + | const [names, { people, agents }] = await Promise.all([userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), workspacePeople(slug, viewer)]); | |
| 179 | + | const out = keys.map((key) => { | |
| 180 | + | if (key.startsWith("agent:")) { | |
| 181 | + | const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined; | |
| 182 | + | return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null }; | |
| 183 | + | } | |
| 184 | + | const username = names[key.slice(5)]?.toLowerCase(); | |
| 185 | + | const person = username ? people.find((p) => p.name === username) : undefined; | |
| 186 | + | return { key, kind: "user", id: key.slice(5), name: username ?? "ghost", display_name: person?.display_name ?? username ?? "Former member", avatar: person?.avatar ?? null, avatar_seed: null }; | |
| 187 | + | }); | |
| 188 | + | return { ok: true, value: out }; | |
| 189 | + | } | |
| 190 | + |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/export"; | |
| 4 | + | import { docs } from "../../../lib/services.server"; | |
| 5 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 6 | + | import { zip } from "../../../lib/zip"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * Docs as Markdown files: `?page=<id>` downloads a page as `<title>.md`; | |
| 10 | + | * `?space=<id>` downloads a space as a zip of its pages, in folders that | |
| 11 | + | * follow its tree. Only what the viewer can read. | |
| 12 | + | */ | |
| 13 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 14 | + | const viewer = requireUser(context, request); | |
| 15 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 16 | + | const slug = params.owner.toLowerCase(); | |
| 17 | + | const url = new URL(request.url); | |
| 18 | + | const page = url.searchParams.get("page"); | |
| 19 | + | const space = url.searchParams.get("space"); | |
| 20 | + | if (page) { | |
| 21 | + | const found = await docs.exportPage(slug, page, viewer); | |
| 22 | + | if (!found.ok) throw data(found.error.message, { status: found.error.code === "not_found" ? 404 : 403 }); | |
| 23 | + | return new Response(found.value.markdown, { | |
| 24 | + | headers: { | |
| 25 | + | "content-type": "text/markdown; charset=utf-8", | |
| 26 | + | "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.value.filename)}`, | |
| 27 | + | "cache-control": "no-store", | |
| 28 | + | }, | |
| 29 | + | }); | |
| 30 | + | } | |
| 31 | + | if (space) { | |
| 32 | + | const found = await docs.exportSpace(slug, space, viewer); | |
| 33 | + | if (!found.ok) throw data(found.error.message, { status: found.error.code === "not_found" ? 404 : 403 }); | |
| 34 | + | const encoder = new TextEncoder(); | |
| 35 | + | const bytes = await zip(found.value.files.map((f) => ({ path: `${found.value.name}/${f.path}`, data: encoder.encode(f.markdown) }))); | |
| 36 | + | return new Response(bytes, { | |
| 37 | + | headers: { | |
| 38 | + | "content-type": "application/zip", | |
| 39 | + | "content-disposition": `attachment; filename="${found.value.name}.zip"`, | |
| 40 | + | "cache-control": "no-store", | |
| 41 | + | }, | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | throw data("Which page or space?", { status: 400 }); | |
| 45 | + | } |
| 1 | + | import type { DocTemplate, DocsHome } from "@g1t/contracts"; | |
| 2 | + | import { BookOpen, FilePlus2, Plus, Search } from "lucide-react"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { Form, Link, data, useNavigate, useSubmit } from "react-router"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/home"; | |
| 7 | + | import { docsRequest, useDocsData } from "../../../components/docs/actions"; | |
| 8 | + | import { PageCard, SectionTitle, SpaceCard, TemplateCard } from "../../../components/docs/parts"; | |
| 9 | + | import { DocsSidebar } from "../../../components/docs/sidebar"; | |
| 10 | + | import { ButtonLink, EmptyState, ErrorText } from "../../../components/ui"; | |
| 11 | + | import { SelectField } from "../../../components/ui/select"; | |
| 12 | + | import { canDo } from "../../../lib/docs"; | |
| 13 | + | import { page } from "../../../lib/meta"; | |
| 14 | + | import { docs } from "../../../lib/services.server"; | |
| 15 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 16 | + | import { BUILTIN_TEMPLATE_IDS } from "../../../components/docs/templates"; | |
| 17 | + | ||
| 18 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 19 | + | return page(args, { title: `Docs · ${params.owner} · g1t` }); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ home: DocsHome | null; templates: DocTemplate[] }> { | |
| 23 | + | const viewer = requireUser(context, request); | |
| 24 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 25 | + | const slug = params.owner.toLowerCase(); | |
| 26 | + | const project = new URL(request.url).searchParams.get("project"); | |
| 27 | + | const [home, templates] = await Promise.all([ | |
| 28 | + | docs | |
| 29 | + | .home(slug, viewer, { project }) | |
| 30 | + | .then((r) => (r.ok ? r.value : null)) | |
| 31 | + | .catch(() => null), | |
| 32 | + | docs | |
| 33 | + | .templates(slug, viewer) | |
| 34 | + | .then((r) => (r.ok ? r.value : [])) | |
| 35 | + | .catch(() => []), | |
| 36 | + | ]); | |
| 37 | + | return { home, templates }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** Docs' home: what changed lately, your pages, the spaces, templates to start from; filtered by project. */ | |
| 41 | + | export default function DocsHomePage({ loaderData, params }: Route.ComponentProps) { | |
| 42 | + | const slug = params.owner.toLowerCase(); | |
| 43 | + | const { home, templates } = loaderData; | |
| 44 | + | const layout = useDocsData(); | |
| 45 | + | const navigate = useNavigate(); | |
| 46 | + | const submit = useSubmit(); | |
| 47 | + | const [error, setError] = useState<string | null>(null); | |
| 48 | + | const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit")); | |
| 49 | + | const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s])); | |
| 50 | + | const create = async (template?: string) => { | |
| 51 | + | if (!general) return; | |
| 52 | + | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } }); | |
| 53 | + | if (made.ok) navigate(made.value.path); | |
| 54 | + | else setError(made.error.message); | |
| 55 | + | }; | |
| 56 | + | if (!home) { | |
| 57 | + | return ( | |
| 58 | + | <div className="mx-auto max-w-3xl"> | |
| 59 | + | <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState> | |
| 60 | + | </div> | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | const featured = templates.filter((t) => BUILTIN_TEMPLATE_IDS.includes(t.id)).slice(0, 4); | |
| 64 | + | return ( | |
| 65 | + | <div className="mx-auto max-w-5xl"> | |
| 66 | + | {/* On a phone, the spaces and pages first: the sidebar as the page. */} | |
| 67 | + | <div className="-mx-4 -mt-6 mb-6 border-b border-line md:hidden"> | |
| 68 | + | <div className="h-[60dvh]"> | |
| 69 | + | <DocsSidebar slug={slug} /> | |
| 70 | + | </div> | |
| 71 | + | </div> | |
| 72 | + | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 73 | + | <div> | |
| 74 | + | <div className="flex items-center gap-2 text-accent"> | |
| 75 | + | <BookOpen size={18} /> | |
| 76 | + | <span className="text-xs font-medium tracking-wide uppercase">Docs</span> | |
| 77 | + | </div> | |
| 78 | + | <h1 className="mt-2 text-2xl font-semibold tracking-tight">What your workspace knows</h1> | |
| 79 | + | <p className="mt-1 max-w-xl text-sm text-muted">Specs, runbooks, decisions and onboarding, written together with your agents. Everything here is searchable, and agents read it before they work.</p> | |
| 80 | + | </div> | |
| 81 | + | <div className="flex items-center gap-2"> | |
| 82 | + | <ButtonLink to={`/${slug}/-/docs/new`} variant="quiet"> | |
| 83 | + | <Plus size={15} /> New space | |
| 84 | + | </ButtonLink> | |
| 85 | + | {general && ( | |
| 86 | + | <button type="button" onClick={() => create()} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"> | |
| 87 | + | <FilePlus2 size={15} /> New page | |
| 88 | + | </button> | |
| 89 | + | )} | |
| 90 | + | </div> | |
| 91 | + | </div> | |
| 92 | + | {error && ( | |
| 93 | + | <div className="mt-3"> | |
| 94 | + | <ErrorText>{error}</ErrorText> | |
| 95 | + | </div> | |
| 96 | + | )} | |
| 97 | + | ||
| 98 | + | <div className="mt-6 flex flex-wrap items-center gap-2"> | |
| 99 | + | <Form method="get" action={`/${slug}/-/docs/search`} className="flex h-9 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60 sm:max-w-md"> | |
| 100 | + | <Search size={15} className="text-faint" aria-hidden="true" /> | |
| 101 | + | <input name="q" placeholder="Search every page you can read" aria-label="Search docs" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" /> | |
| 102 | + | {home.project && <input type="hidden" name="project" value={home.project} />} | |
| 103 | + | </Form> | |
| 104 | + | {home.projects.length > 0 && ( | |
| 105 | + | <Form method="get" id="docs-project"> | |
| 106 | + | <SelectField | |
| 107 | + | name="project" | |
| 108 | + | aria-label="Filter by project" | |
| 109 | + | value={home.project ?? ""} | |
| 110 | + | afterChange={() => submit(document.getElementById("docs-project") as HTMLFormElement)} | |
| 111 | + | options={[{ value: "", label: "All projects" }, ...home.projects.map((p) => ({ value: p, label: p }))]} | |
| 112 | + | className="h-9 min-w-48" | |
| 113 | + | /> | |
| 114 | + | </Form> | |
| 115 | + | )} | |
| 116 | + | </div> | |
| 117 | + | ||
| 118 | + | <section className="mt-8"> | |
| 119 | + | <SectionTitle>{home.project ? `Recently edited · ${home.project}` : "Recently edited"}</SectionTitle> | |
| 120 | + | {home.recent.length ? ( | |
| 121 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> | |
| 122 | + | {home.recent.map((p) => ( | |
| 123 | + | <PageCard key={p.id} page={p} space={spacesById.get(p.space_id)} /> | |
| 124 | + | ))} | |
| 125 | + | </div> | |
| 126 | + | ) : ( | |
| 127 | + | <EmptyState title={home.project ? "No pages about this project yet" : "No pages yet"}> | |
| 128 | + | {home.project ? "Link a space or a page to this project from its settings." : "Start one from a template below, or with New page."} | |
| 129 | + | </EmptyState> | |
| 130 | + | )} | |
| 131 | + | </section> | |
| 132 | + | ||
| 133 | + | {home.mine.length > 0 && ( | |
| 134 | + | <section className="mt-10"> | |
| 135 | + | <SectionTitle>Your pages</SectionTitle> | |
| 136 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 137 | + | {home.mine.map((p) => ( | |
| 138 | + | <li key={p.id}> | |
| 139 | + | <Link to={p.path} prefetch="intent" className="flex items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-raised"> | |
| 140 | + | <span className="w-5 text-center">{p.icon ?? "📄"}</span> | |
| 141 | + | <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span> | |
| 142 | + | <span className="hidden text-xs text-faint sm:inline">{spacesById.get(p.space_id)?.name}</span> | |
| 143 | + | </Link> | |
| 144 | + | </li> | |
| 145 | + | ))} | |
| 146 | + | </ul> | |
| 147 | + | </section> | |
| 148 | + | )} | |
| 149 | + | ||
| 150 | + | <section className="mt-10"> | |
| 151 | + | <SectionTitle | |
| 152 | + | action={ | |
| 153 | + | <Link to={`/${slug}/-/docs/new`} className="text-xs text-muted hover:text-fg"> | |
| 154 | + | New space | |
| 155 | + | </Link> | |
| 156 | + | } | |
| 157 | + | > | |
| 158 | + | Spaces | |
| 159 | + | </SectionTitle> | |
| 160 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 161 | + | {home.spaces.map((s) => ( | |
| 162 | + | <SpaceCard key={s.id} slug={slug} space={s} /> | |
| 163 | + | ))} | |
| 164 | + | </div> | |
| 165 | + | </section> | |
| 166 | + | ||
| 167 | + | {general && featured.length > 0 && ( | |
| 168 | + | <section className="mt-10 mb-6"> | |
| 169 | + | <SectionTitle | |
| 170 | + | action={ | |
| 171 | + | <Link to={`/${slug}/-/docs/templates`} className="text-xs text-muted hover:text-fg"> | |
| 172 | + | All templates | |
| 173 | + | </Link> | |
| 174 | + | } | |
| 175 | + | > | |
| 176 | + | Start from a template | |
| 177 | + | </SectionTitle> | |
| 178 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 179 | + | {featured.map((t) => ( | |
| 180 | + | <TemplateCard | |
| 181 | + | key={t.id} | |
| 182 | + | template={t} | |
| 183 | + | action={ | |
| 184 | + | <button type="button" onClick={() => create(t.id)} className="text-xs font-medium text-accent hover:underline"> | |
| 185 | + | Use in {general.name} | |
| 186 | + | </button> | |
| 187 | + | } | |
| 188 | + | /> | |
| 189 | + | ))} | |
| 190 | + | </div> | |
| 191 | + | </section> | |
| 192 | + | )} | |
| 193 | + | </div> | |
| 194 | + | ); | |
| 195 | + | } |
| 1 | + | import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { type DocsSidebar as Sidebar, shownUsername } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/layout"; | |
| 6 | + | import type { DocMentionable } from "../../../components/docs/editor"; | |
| 7 | + | import { workspacePeople } from "../../../lib/chat.server"; | |
| 8 | + | import { docs } from "../../../lib/services.server"; | |
| 9 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Docs mode: what its sidebar shows (the shell draws it from this data: | |
| 13 | + | * spaces and their page trees, favorites, recent pages) and who can be | |
| 14 | + | * mentioned in a page. Each part is empty rather than an error when its | |
| 15 | + | * service does not answer. | |
| 16 | + | */ | |
| 17 | + | export type DocsLayoutData = { | |
| 18 | + | slug: string; | |
| 19 | + | role: string; | |
| 20 | + | me: { key: string; name: string; display_name: string; avatar: string | null }; | |
| 21 | + | sidebar: Sidebar | null; | |
| 22 | + | mentionables: DocMentionable[]; | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<DocsLayoutData> { | |
| 26 | + | const viewer = requireUser(context, request); | |
| 27 | + | const role = roleIn(viewer, params.owner); | |
| 28 | + | if (!role) throw data(null, { status: 404 }); | |
| 29 | + | const slug = params.owner.toLowerCase(); | |
| 30 | + | const [sidebar, { people, agents }] = await Promise.all([ | |
| 31 | + | docs | |
| 32 | + | .sidebar(slug, viewer) | |
| 33 | + | .then((r) => (r.ok ? r.value : null)) | |
| 34 | + | .catch(() => null), | |
| 35 | + | workspacePeople(slug, viewer).catch(() => ({ people: [], agents: [] })), | |
| 36 | + | ]); | |
| 37 | + | const self = people.find((p) => p.name === viewer.username.toLowerCase()); | |
| 38 | + | return { | |
| 39 | + | slug, | |
| 40 | + | role, | |
| 41 | + | me: { | |
| 42 | + | key: `user:${viewer.id}`, | |
| 43 | + | name: viewer.username.toLowerCase(), | |
| 44 | + | display_name: self?.display_name ?? shownUsername({ username: viewer.username, display_username: viewer.display_username ?? null }), | |
| 45 | + | avatar: viewer.avatar ?? null, | |
| 46 | + | }, | |
| 47 | + | sidebar, | |
| 48 | + | mentionables: [ | |
| 49 | + | ...people.map((p): DocMentionable => ({ kind: "user", id: p.name, name: p.name, display_name: p.display_name, avatar: p.avatar })), | |
| 50 | + | ...agents.map((a): DocMentionable => ({ kind: "agent", id: a.id, name: a.handle, display_name: a.display_name, avatar: a.avatar, avatar_seed: a.avatar_seed ?? null })), | |
| 51 | + | ], | |
| 52 | + | }; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The tree changes when something is made, renamed, moved or trashed (a | |
| 57 | + | * submission, or the page asking again with `useRevalidator`); not when | |
| 58 | + | * moving between pages. | |
| 59 | + | */ | |
| 60 | + | export function shouldRevalidate({ currentParams, nextParams, currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 61 | + | if (formMethod && formMethod !== "GET") return defaultShouldRevalidate; | |
| 62 | + | if (currentUrl.href === nextUrl.href) return defaultShouldRevalidate; | |
| 63 | + | return currentParams.owner !== nextParams.owner; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | export default function DocsLayout() { | |
| 67 | + | return <Outlet />; | |
| 68 | + | } |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | import { DOCS_VIEWER_HEADER } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/live"; | |
| 6 | + | import { getViewer, roleIn } from "../../../lib/session.server"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * A page's live socket: `wss://<site>/<workspace>/-/docs/live?page=<id>`. | |
| 10 | + | * The site checks the session and that the page asking is the site's own, | |
| 11 | + | * then hands the upgrade to the docs service with the viewer, which checks | |
| 12 | + | * their role in the page's space and gives the socket to the page's room | |
| 13 | + | * (one Durable Object per page), which enforces that role. | |
| 14 | + | */ | |
| 15 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 16 | + | const viewer = getViewer(context); | |
| 17 | + | if (!viewer) return new Response("Sign in to use Docs.", { status: 401 }); | |
| 18 | + | if (!roleIn(viewer, params.owner)) return new Response("Not found", { status: 404 }); | |
| 19 | + | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { | |
| 20 | + | return new Response("This address takes a WebSocket.", { status: 426, headers: { upgrade: "websocket" } }); | |
| 21 | + | } | |
| 22 | + | const origin = request.headers.get("origin"); | |
| 23 | + | if (origin && origin !== new URL(request.url).origin) return new Response("Cross-origin socket refused", { status: 403 }); | |
| 24 | + | const page = new URL(request.url).searchParams.get("page"); | |
| 25 | + | if (!page) return new Response("Which page?", { status: 400 }); | |
| 26 | + | const headers = new Headers(request.headers); | |
| 27 | + | headers.delete("cookie"); | |
| 28 | + | headers.set(DOCS_VIEWER_HEADER, JSON.stringify(viewer)); | |
| 29 | + | const target = `https://docs/live?page=${encodeURIComponent(page)}&workspace=${encodeURIComponent(params.owner.toLowerCase())}`; | |
| 30 | + | try { | |
| 31 | + | return await env.DOCS.fetch(new Request(target, { method: "GET", headers })); | |
| 32 | + | } catch (error) { | |
| 33 | + | console.error("docs: the live socket could not be handed over", error); | |
| 34 | + | return new Response("Docs didn't answer.", { status: 503 }); | |
| 35 | + | } | |
| 36 | + | } |
| 1 | + | import { data, useNavigate } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/new-space"; | |
| 4 | + | import { useDocsAction } from "../../../components/docs/actions"; | |
| 5 | + | import { SpaceForm, initialSpace, spaceInput } from "../../../components/docs/space-form"; | |
| 6 | + | import { page } from "../../../lib/meta"; | |
| 7 | + | import { identity } from "../../../lib/services.server"; | |
| 8 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 9 | + | ||
| 10 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 11 | + | return page(args, { title: `New space · Docs · ${params.owner} · g1t` }); | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 15 | + | const viewer = requireUser(context, request); | |
| 16 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 17 | + | const teams = await identity.listTeams(viewer, params.owner.toLowerCase()).catch(() => null); | |
| 18 | + | return { teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** A new space: for everyone, a team, or only its members. Whoever makes it has full access. */ | |
| 22 | + | export default function NewSpace({ loaderData, params }: Route.ComponentProps) { | |
| 23 | + | const slug = params.owner.toLowerCase(); | |
| 24 | + | const navigate = useNavigate(); | |
| 25 | + | const { send, busy, error } = useDocsAction(slug); | |
| 26 | + | return ( | |
| 27 | + | <div className="mx-auto max-w-2xl"> | |
| 28 | + | <h1 className="text-xl font-semibold tracking-tight">New space</h1> | |
| 29 | + | <p className="mt-1 mb-8 text-sm text-muted">A space holds a tree of pages: one per team, project or topic.</p> | |
| 30 | + | <SpaceForm | |
| 31 | + | initial={initialSpace()} | |
| 32 | + | teams={loaderData.teams} | |
| 33 | + | submitLabel="Create space" | |
| 34 | + | busy={busy} | |
| 35 | + | error={error} | |
| 36 | + | onSubmit={async (value) => { | |
| 37 | + | const made = await send<{ slug: string }>("create_space", { space: spaceInput(value) }); | |
| 38 | + | if (made.ok) navigate(`/${slug}/-/docs/${made.value.slug}`); | |
| 39 | + | }} | |
| 40 | + | /> | |
| 41 | + | </div> | |
| 42 | + | ); | |
| 43 | + | } |
| 1 | + | import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, type DocPage, type DocPageDetail, type DocSuggestion, type DocsLiveEvent, type MemberProfile } from "@g1t/contracts"; | |
| 2 | + | import { | |
| 3 | + | ArrowRightLeft, | |
| 4 | + | Check, | |
| 5 | + | ClipboardCopy, | |
| 6 | + | Copy, | |
| 7 | + | Download, | |
| 8 | + | Ellipsis, | |
| 9 | + | History, | |
| 10 | + | ImagePlus, | |
| 11 | + | LayoutTemplate, | |
| 12 | + | Link2, | |
| 13 | + | MessageSquare, | |
| 14 | + | Share2, | |
| 15 | + | SmilePlus, | |
| 16 | + | Sparkles, | |
| 17 | + | Star, | |
| 18 | + | Trash2, | |
| 19 | + | WifiOff, | |
| 20 | + | } from "lucide-react"; | |
| 21 | + | import { type ComponentType, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 22 | + | import { Link, data, redirect, useNavigate, useRevalidator, useRouteLoaderData } from "react-router"; | |
| 23 | + | ||
| 24 | + | import type { Route } from "./+types/page"; | |
| 25 | + | import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions"; | |
| 26 | + | import type { DocEditorProps, Presence } from "../../../components/docs/editor"; | |
| 27 | + | import { EditorSkeleton } from "../../../components/docs/editor-skeleton"; | |
| 28 | + | import { CoverPicker, Discussion, HistoryDialog, IconPicker, MoveDialog, SuggestionCard, TemplateDialog, type PageThread } from "../../../components/docs/page-parts"; | |
| 29 | + | import { Crumbs, Face, Faces } from "../../../components/docs/parts"; | |
| 30 | + | import type { LiveStatus } from "../../../components/docs/provider"; | |
| 31 | + | import { PageIcon } from "../../../components/docs/sidebar"; | |
| 32 | + | import { type Who, whoAre } from "../../../components/docs/who"; | |
| 33 | + | import { Markdown } from "../../../components/markdown"; | |
| 34 | + | import { ErrorText, TimeAgo } from "../../../components/ui"; | |
| 35 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../../components/ui/dropdown-menu"; | |
| 36 | + | import { Hint } from "../../../components/ui/hint"; | |
| 37 | + | import { Popover, PopoverContent, PopoverTrigger } from "../../../components/ui/popover"; | |
| 38 | + | import { usercontentFrom } from "../../../lib/addresses"; | |
| 39 | + | import { canDo, coverStyle, pageIdOf, pagePath, readingTime } from "../../../lib/docs"; | |
| 40 | + | import { page as pageMeta } from "../../../lib/meta"; | |
| 41 | + | import { docs } from "../../../lib/services.server"; | |
| 42 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 43 | + | ||
| 44 | + | /** | |
| 45 | + | * The editor and everything it brings, loaded only in the browser, after | |
| 46 | + | * the page shows. The server's bundle never includes it: the server | |
| 47 | + | * renders the page's Markdown instead. | |
| 48 | + | */ | |
| 49 | + | const DocEditor = lazy( | |
| 50 | + | (): Promise<{ default: ComponentType<DocEditorProps> }> => (import.meta.env.SSR ? Promise.resolve({ default: () => null }) : import("../../../components/docs/editor")), | |
| 51 | + | ); | |
| 52 | + | ||
| 53 | + | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 54 | + | const p = loaded?.detail.page; | |
| 55 | + | return pageMeta(args, { title: `${p ? `${p.icon ? `${p.icon} ` : ""}${p.title || "Untitled"}` : "Page"} · ${params.owner} · g1t`, description: p?.excerpt, type: "article" }); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ detail: DocPageDetail }> { | |
| 59 | + | const viewer = requireUser(context, request); | |
| 60 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 61 | + | const id = pageIdOf(params.page); | |
| 62 | + | if (!id) throw data(null, { status: 404 }); | |
| 63 | + | const found = await docs.page(params.owner.toLowerCase(), id, viewer); | |
| 64 | + | if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 }); | |
| 65 | + | const detail = found.value; | |
| 66 | + | // An old address (renamed page, moved space) goes to the current one. | |
| 67 | + | const url = new URL(request.url); | |
| 68 | + | if (url.pathname !== detail.page.path) throw redirect(`${detail.page.path}${url.search}`); | |
| 69 | + | return { detail }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | function useHydrated() { | |
| 73 | + | const [hydrated, setHydrated] = useState(false); | |
| 74 | + | useEffect(() => setHydrated(true), []); | |
| 75 | + | return hydrated; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** One page: its header, the live editor (or its Markdown until the editor loads), suggestions, comments, links. */ | |
| 79 | + | export default function DocPageView({ loaderData, params }: Route.ComponentProps) { | |
| 80 | + | const slug = params.owner.toLowerCase(); | |
| 81 | + | const { detail } = loaderData; | |
| 82 | + | const { page, space } = detail; | |
| 83 | + | const layout = useDocsData(); | |
| 84 | + | const root = useRouteLoaderData("root"); | |
| 85 | + | const usercontent = usercontentFrom(root); | |
| 86 | + | const navigate = useNavigate(); | |
| 87 | + | const revalidator = useRevalidator(); | |
| 88 | + | const hydrated = useHydrated(); | |
| 89 | + | const { send, error, setError } = useDocsAction(slug); | |
| 90 | + | ||
| 91 | + | const [role, setRole] = useState(detail.role); | |
| 92 | + | useEffect(() => setRole(detail.role), [detail.role, page.id]); | |
| 93 | + | const editable = canDo(role, "edit"); | |
| 94 | + | ||
| 95 | + | // The header follows the page, and anyone renaming it live. | |
| 96 | + | const [live, setLive] = useState<DocPage>(page); | |
| 97 | + | useEffect(() => setLive(page), [page]); | |
| 98 | + | const [title, setTitle] = useState(page.title); | |
| 99 | + | const titleFocused = useRef(false); | |
| 100 | + | useEffect(() => { | |
| 101 | + | if (!titleFocused.current) setTitle(live.title); | |
| 102 | + | }, [live.title]); | |
| 103 | + | const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 104 | + | const saveTitle = (next: string) => { | |
| 105 | + | setTitle(next); | |
| 106 | + | if (saveTimer.current) clearTimeout(saveTimer.current); | |
| 107 | + | saveTimer.current = setTimeout(() => void send("update_page", { page_id: page.id, change: { title: next } }), 700); | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | const [suggestions, setSuggestions] = useState<DocSuggestion[]>(detail.suggestions); | |
| 111 | + | useEffect(() => setSuggestions(detail.suggestions), [detail.suggestions]); | |
| 112 | + | const [presence, setPresence] = useState<Presence[]>([]); | |
| 113 | + | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 114 | + | const [archived, setArchived] = useState(!!page.archived_at); | |
| 115 | + | useEffect(() => setArchived(!!page.archived_at), [page.archived_at]); | |
| 116 | + | const [showComments, setShowComments] = useState(false); | |
| 117 | + | const [history, setHistory] = useState(false); | |
| 118 | + | const [moving, setMoving] = useState(false); | |
| 119 | + | const [templating, setTemplating] = useState(false); | |
| 120 | + | const [favorite, setFavorite] = useState(detail.favorite); | |
| 121 | + | useEffect(() => setFavorite(detail.favorite), [detail.favorite]); | |
| 122 | + | const [copied, setCopied] = useState<string | null>(null); | |
| 123 | + | const [threads, setThreads] = useState<PageThread[]>([]); | |
| 124 | + | const [names, setNames] = useState<Map<string, Who>>(new Map()); | |
| 125 | + | useEffect(() => { | |
| 126 | + | const keys = threads.flatMap((t) => t.comments.map((c) => c.author)); | |
| 127 | + | if (keys.some((k) => !names.has(k))) void whoAre(slug, keys).then((found) => setNames((was) => new Map([...was, ...found]))); | |
| 128 | + | }, [threads, slug, names]); | |
| 129 | + | ||
| 130 | + | const onEvent = useCallback( | |
| 131 | + | (event: DocsLiveEvent) => { | |
| 132 | + | if (event.type === "page.updated") setLive(event.page); | |
| 133 | + | else if (event.type === "page.archived") setArchived(true); | |
| 134 | + | else if (event.type === "access") { | |
| 135 | + | if (event.role) setRole(event.role); | |
| 136 | + | else navigate(`/${slug}/-/docs`); | |
| 137 | + | } else if (event.type === "suggestion.created") setSuggestions((was) => [...was.filter((s) => s.id !== event.suggestion.id), event.suggestion]); | |
| 138 | + | else if (event.type === "suggestion.updated") setSuggestions((was) => (event.suggestion.status === "open" ? was.map((s) => (s.id === event.suggestion.id ? event.suggestion : s)) : was.filter((s) => s.id !== event.suggestion.id))); | |
| 139 | + | else if (event.type === "version.created") void revalidator.revalidate(); | |
| 140 | + | }, | |
| 141 | + | [navigate, slug, revalidator], | |
| 142 | + | ); | |
| 143 | + | ||
| 144 | + | const decide = async (id: string, decision: "accept" | "reject") => { | |
| 145 | + | setSuggestions((was) => was.filter((s) => s.id !== id)); | |
| 146 | + | const done = await send("decide", { suggestion_id: id, decision }, { reload: false }); | |
| 147 | + | if (!done.ok) void revalidator.revalidate(); | |
| 148 | + | }; | |
| 149 | + | ||
| 150 | + | const people = useCallback( | |
| 151 | + | (key: string): Pick<MemberProfile, "kind" | "id" | "name" | "display_name" | "avatar" | "avatar_seed"> => { | |
| 152 | + | const at = key.indexOf(":"); | |
| 153 | + | const kind = key.slice(0, at) === "agent" ? "agent" : "user"; | |
| 154 | + | const id = key.slice(at + 1); | |
| 155 | + | if (layout?.me.key === key) return { kind: "user", id, name: layout.me.name, display_name: layout.me.display_name, avatar: layout.me.avatar, avatar_seed: null }; | |
| 156 | + | const found = names.get(key); | |
| 157 | + | return { kind, id, name: found?.name ?? "someone", display_name: found?.display_name ?? (kind === "agent" ? "An agent" : "Someone"), avatar: found?.avatar ?? null, avatar_seed: found?.avatar_seed ?? null }; | |
| 158 | + | }, | |
| 159 | + | [layout, names], | |
| 160 | + | ); | |
| 161 | + | ||
| 162 | + | const threadRequest = async (path: string, method: string, body: unknown) => { | |
| 163 | + | try { | |
| 164 | + | const r = await fetch(`/${slug}/-/docs/threads/${page.id}${path}`, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 165 | + | if (!r.ok) { | |
| 166 | + | const result = (await r.json().catch(() => null)) as { error?: { message?: string } } | null; | |
| 167 | + | setError(result?.error?.message ?? "That comment didn't go through."); | |
| 168 | + | return false; | |
| 169 | + | } | |
| 170 | + | return true; | |
| 171 | + | } catch { | |
| 172 | + | setError("That comment didn't go through."); | |
| 173 | + | return false; | |
| 174 | + | } | |
| 175 | + | }; | |
| 176 | + | /** A comment's text as the editor's comments hold it: blocks, with @mentions as mentions. */ | |
| 177 | + | const commentBody = (text: string) => { | |
| 178 | + | const parts: unknown[] = []; | |
| 179 | + | for (const piece of text.split(/(@[a-z0-9][a-z0-9-]*)/i)) { | |
| 180 | + | const who = piece.startsWith("@") ? layout?.mentionables.find((m) => m.name.toLowerCase() === piece.slice(1).toLowerCase()) : null; | |
| 181 | + | if (who) parts.push({ type: "mention", props: { kind: who.kind, id: who.id, name: who.name, href: "" } }); | |
| 182 | + | else if (piece) parts.push({ type: "text", text: piece, styles: {} }); | |
| 183 | + | } | |
| 184 | + | return [{ type: "paragraph", content: parts, children: [] }]; | |
| 185 | + | }; | |
| 186 | + | ||
| 187 | + | const copy = async (what: "link" | "markdown") => { | |
| 188 | + | try { | |
| 189 | + | if (what === "link") await navigator.clipboard.writeText(`${window.location.origin}${pagePath(slug, space.slug, live.title, page.id)}`); | |
| 190 | + | else { | |
| 191 | + | const r = await fetch(`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`); | |
| 192 | + | await navigator.clipboard.writeText(await r.text()); | |
| 193 | + | } | |
| 194 | + | setCopied(what); | |
| 195 | + | setTimeout(() => setCopied(null), 1600); | |
| 196 | + | } catch { | |
| 197 | + | setError("Your browser didn't allow copying."); | |
| 198 | + | } | |
| 199 | + | }; | |
| 200 | + | ||
| 201 | + | const cover = coverStyle(live.cover); | |
| 202 | + | const reading = useMemo(() => readingTime(detail.markdown), [detail.markdown]); | |
| 203 | + | const others = presence.filter((p) => !p.me); | |
| 204 | + | const crumbs = [ | |
| 205 | + | { label: "Docs", to: `/${slug}/-/docs` }, | |
| 206 | + | { label: space.name, to: `/${slug}/-/docs/${space.slug}` }, | |
| 207 | + | ...detail.breadcrumbs.map((b) => ({ label: `${b.icon ? `${b.icon} ` : ""}${b.title || "Untitled"}`, to: b.path })), | |
| 208 | + | ]; | |
| 209 | + | ||
| 210 | + | return ( | |
| 211 | + | <div className="-mt-6"> | |
| 212 | + | {cover && ( | |
| 213 | + | <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}> | |
| 214 | + | {editable && ( | |
| 215 | + | <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}> | |
| 216 | + | <button type="button" className="absolute right-4 bottom-3 rounded-md bg-bg/70 px-2.5 py-1 text-xs text-fg opacity-0 backdrop-blur group-hover:opacity-100 focus-visible:opacity-100"> | |
| 217 | + | Change cover | |
| 218 | + | </button> | |
| 219 | + | </CoverPicker> | |
| 220 | + | )} | |
| 221 | + | </div> | |
| 222 | + | )} | |
| 223 | + | {/* The bar above the page: where it is, who is here, and what can be done. */} | |
| 224 | + | <div className="sticky top-14 z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> | |
| 225 | + | <div className="min-w-0 grow"> | |
| 226 | + | <Crumbs items={crumbs} /> | |
| 227 | + | </div> | |
| 228 | + | {status === "offline" && ( | |
| 229 | + | <Hint label="Your changes are kept and sync when the connection is back."> | |
| 230 | + | <span className="flex items-center gap-1 text-xs text-warn"> | |
| 231 | + | <WifiOff size={13} /> Offline | |
| 232 | + | </span> | |
| 233 | + | </Hint> | |
| 234 | + | )} | |
| 235 | + | {others.length > 0 && ( | |
| 236 | + | <span className="flex items-center -space-x-1.5" aria-label={`${others.length} others here`}> | |
| 237 | + | {others.slice(0, 5).map((p) => ( | |
| 238 | + | <Hint key={p.key} label={`${p.name}${p.kind === "agent" ? " (agent)" : ""}`}> | |
| 239 | + | <span className="rounded-full ring-2" style={{ ["--tw-ring-color" as string]: p.color }}> | |
| 240 | + | <Face who={{ kind: p.kind, id: p.key.slice(p.key.indexOf(":") + 1), name: p.name, avatar: p.avatar, avatar_seed: null }} size={24} /> | |
| 241 | + | </span> | |
| 242 | + | </Hint> | |
| 243 | + | ))} | |
| 244 | + | {others.length > 5 && <span className="pl-3 text-xs text-faint">+{others.length - 5}</span>} | |
| 245 | + | </span> | |
| 246 | + | )} | |
| 247 | + | <Hint label={showComments ? "Hide comments" : "Comments"}> | |
| 248 | + | <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`hidden size-8 items-center justify-center rounded-md xl:flex ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}> | |
| 249 | + | <MessageSquare size={16} /> | |
| 250 | + | </button> | |
| 251 | + | </Hint> | |
| 252 | + | <Hint label={favorite ? "Remove from favorites" : "Add to favorites"}> | |
| 253 | + | <button | |
| 254 | + | type="button" | |
| 255 | + | aria-label={favorite ? "Remove from favorites" : "Add to favorites"} | |
| 256 | + | aria-pressed={favorite} | |
| 257 | + | onClick={async () => { | |
| 258 | + | setFavorite(!favorite); | |
| 259 | + | await send("favorite", { page_id: page.id, on: !favorite }); | |
| 260 | + | }} | |
| 261 | + | className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg" | |
| 262 | + | > | |
| 263 | + | <Star size={16} className={favorite ? "fill-current text-warn" : ""} /> | |
| 264 | + | </button> | |
| 265 | + | </Hint> | |
| 266 | + | <Popover> | |
| 267 | + | <PopoverTrigger asChild> | |
| 268 | + | <button type="button" className="hidden h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg/85 hover:bg-raised sm:inline-flex"> | |
| 269 | + | <Share2 size={13} /> Share | |
| 270 | + | </button> | |
| 271 | + | </PopoverTrigger> | |
| 272 | + | <PopoverContent align="end" className="w-80 p-4 text-sm"> | |
| 273 | + | <p className="font-medium">Who can see this page</p> | |
| 274 | + | <p className="mt-1 text-xs leading-relaxed text-muted"> | |
| 275 | + | Everyone with access to <span className="text-fg">{space.name}</span>:{" "} | |
| 276 | + | {space.kind === "workspace" ? `the whole workspace (${space.default_role ? DOC_ROLE_LABELS[space.default_role].toLowerCase() : "listed members only"})` : space.kind === "team" ? `the ${space.team} team, and members added to the space` : "only the space's members"}. Agents here: {DOC_AGENT_MODE_LABELS[space.agent_mode].toLowerCase()}. | |
| 277 | + | </p> | |
| 278 | + | <p className="mt-2 text-xs text-muted">Your access: {DOC_ROLE_LABELS[role].toLowerCase()}.</p> | |
| 279 | + | <div className="mt-3 flex gap-2"> | |
| 280 | + | <button type="button" onClick={() => copy("link")} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-raised px-2.5 text-xs hover:bg-line"> | |
| 281 | + | {copied === "link" ? <Check size={13} /> : <Link2 size={13} />} {copied === "link" ? "Copied" : "Copy link"} | |
| 282 | + | </button> | |
| 283 | + | {canDo(space.viewer_role, "manage") && ( | |
| 284 | + | <Link to={`/${slug}/-/docs/${space.slug}/settings`} className="inline-flex h-8 items-center rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg"> | |
| 285 | + | Space access | |
| 286 | + | </Link> | |
| 287 | + | )} | |
| 288 | + | </div> | |
| 289 | + | </PopoverContent> | |
| 290 | + | </Popover> | |
| 291 | + | <DropdownMenu> | |
| 292 | + | <DropdownMenuTrigger asChild> | |
| 293 | + | <button type="button" aria-label="More" className="flex size-8 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"> | |
| 294 | + | <Ellipsis size={16} /> | |
| 295 | + | </button> | |
| 296 | + | </DropdownMenuTrigger> | |
| 297 | + | <DropdownMenuContent align="end" className="w-56"> | |
| 298 | + | <DropdownMenuItem onSelect={() => setHistory(true)}> | |
| 299 | + | <History size={14} /> History | |
| 300 | + | </DropdownMenuItem> | |
| 301 | + | <DropdownMenuItem onSelect={() => copy("markdown")}> | |
| 302 | + | <ClipboardCopy size={14} /> Copy as Markdown | |
| 303 | + | </DropdownMenuItem> | |
| 304 | + | <DropdownMenuItem asChild> | |
| 305 | + | <a href={`/${slug}/-/docs/export?page=${encodeURIComponent(page.id)}`}> | |
| 306 | + | <Download size={14} /> Export Markdown | |
| 307 | + | </a> | |
| 308 | + | </DropdownMenuItem> | |
| 309 | + | <DropdownMenuItem onSelect={() => copy("link")}> | |
| 310 | + | <Link2 size={14} /> Copy link | |
| 311 | + | </DropdownMenuItem> | |
| 312 | + | {editable && ( | |
| 313 | + | <> | |
| 314 | + | <DropdownMenuSeparator /> | |
| 315 | + | <DropdownMenuItem onSelect={() => setMoving(true)}> | |
| 316 | + | <ArrowRightLeft size={14} /> Move | |
| 317 | + | </DropdownMenuItem> | |
| 318 | + | <DropdownMenuItem | |
| 319 | + | onSelect={async () => { | |
| 320 | + | const made = await docsRequest<DocPage>(slug, "duplicate_page", { page_id: page.id }); | |
| 321 | + | if (made.ok) navigate(made.value.path); | |
| 322 | + | else setError(made.error.message); | |
| 323 | + | }} | |
| 324 | + | > | |
| 325 | + | <Copy size={14} /> Duplicate | |
| 326 | + | </DropdownMenuItem> | |
| 327 | + | </> | |
| 328 | + | )} | |
| 329 | + | <DropdownMenuItem onSelect={() => setTemplating(true)}> | |
| 330 | + | <LayoutTemplate size={14} /> Save as template | |
| 331 | + | </DropdownMenuItem> | |
| 332 | + | {editable && ( | |
| 333 | + | <> | |
| 334 | + | <DropdownMenuSeparator /> | |
| 335 | + | <DropdownMenuItem | |
| 336 | + | onSelect={async () => { | |
| 337 | + | const done = await send("archive_page", { page_id: page.id }); | |
| 338 | + | if (done.ok) navigate(`/${slug}/-/docs/${space.slug}`); | |
| 339 | + | }} | |
| 340 | + | className="text-danger focus:text-danger" | |
| 341 | + | > | |
| 342 | + | <Trash2 size={14} /> Move to trash | |
| 343 | + | </DropdownMenuItem> | |
| 344 | + | </> | |
| 345 | + | )} | |
| 346 | + | </DropdownMenuContent> | |
| 347 | + | </DropdownMenu> | |
| 348 | + | </div> | |
| 349 | + | ||
| 350 | + | <article className={`mx-auto pt-10 ${showComments ? "max-w-6xl" : "max-w-3xl"}`}> | |
| 351 | + | <div className={showComments ? "xl:max-w-3xl" : ""}> | |
| 352 | + | {archived && ( | |
| 353 | + | <p className="mb-6 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-sm text-warn"> | |
| 354 | + | This page was moved to the trash.{" "} | |
| 355 | + | <Link to={`/${slug}/-/docs/trash`} className="underline"> | |
| 356 | + | Open the trash | |
| 357 | + | </Link> | |
| 358 | + | </p> | |
| 359 | + | )} | |
| 360 | + | {/* Icon, cover, title. */} | |
| 361 | + | <div className="group"> | |
| 362 | + | {live.icon && ( | |
| 363 | + | <IconPicker value={live.icon} onChange={(icon) => editable && send("update_page", { page_id: page.id, change: { icon } })}> | |
| 364 | + | <button type="button" disabled={!editable} aria-label="Change the icon" className="-ml-1 mb-2 rounded-lg p-1 text-5xl leading-none hover:bg-raised disabled:hover:bg-transparent"> | |
| 365 | + | {live.icon} | |
| 366 | + | </button> | |
| 367 | + | </IconPicker> | |
| 368 | + | )} | |
| 369 | + | {editable && ( | |
| 370 | + | <div className="mb-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> | |
| 371 | + | {!live.icon && ( | |
| 372 | + | <IconPicker value={null} onChange={(icon) => send("update_page", { page_id: page.id, change: { icon } })}> | |
| 373 | + | <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg"> | |
| 374 | + | <SmilePlus size={13} /> Add icon | |
| 375 | + | </button> | |
| 376 | + | </IconPicker> | |
| 377 | + | )} | |
| 378 | + | {!cover && ( | |
| 379 | + | <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}> | |
| 380 | + | <button type="button" className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-faint hover:bg-raised hover:text-fg"> | |
| 381 | + | <ImagePlus size={13} /> Add cover | |
| 382 | + | </button> | |
| 383 | + | </CoverPicker> | |
| 384 | + | )} | |
| 385 | + | </div> | |
| 386 | + | )} | |
| 387 | + | {editable ? ( | |
| 388 | + | <textarea | |
| 389 | + | value={title} | |
| 390 | + | rows={1} | |
| 391 | + | placeholder="Untitled" | |
| 392 | + | aria-label="Title" | |
| 393 | + | onFocus={() => (titleFocused.current = true)} | |
| 394 | + | onBlur={() => (titleFocused.current = false)} | |
| 395 | + | onChange={(e) => saveTitle(e.target.value.replace(/\n/g, " "))} | |
| 396 | + | onKeyDown={(e) => { | |
| 397 | + | if (e.key === "Enter") { | |
| 398 | + | e.preventDefault(); | |
| 399 | + | (document.querySelector(".g1t-editor .bn-editor") as HTMLElement | null)?.focus(); | |
| 400 | + | } | |
| 401 | + | }} | |
| 402 | + | className="field-sizing-content w-full resize-none bg-transparent font-display text-4xl leading-tight font-semibold tracking-tight text-fg outline-none placeholder:text-faint/60" | |
| 403 | + | /> | |
| 404 | + | ) : ( | |
| 405 | + | <h1 className="font-display text-4xl leading-tight font-semibold tracking-tight">{live.title || "Untitled"}</h1> | |
| 406 | + | )} | |
| 407 | + | </div> | |
| 408 | + | {/* Who wrote it, who owns it, what it's about. */} | |
| 409 | + | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-faint"> | |
| 410 | + | {live.updated_by && ( | |
| 411 | + | <span className="flex items-center gap-1.5"> | |
| 412 | + | <Face who={live.updated_by} size={16} /> | |
| 413 | + | Last edited by <span className="text-muted">{live.updated_by.display_name}</span> · <TimeAgo at={live.updated_at} /> | |
| 414 | + | </span> | |
| 415 | + | )} | |
| 416 | + | {live.owners.length > 0 && ( | |
| 417 | + | <span className="flex items-center gap-1.5"> | |
| 418 | + | Owners <Faces people={live.owners} size={18} /> | |
| 419 | + | </span> | |
| 420 | + | )} | |
| 421 | + | {[...new Set([...live.projects, ...space.projects])].map((p) => ( | |
| 422 | + | <Link key={p} to={`/${p}`} className="rounded-full border border-line px-2 py-0.5 font-mono text-[0.6875rem] text-muted hover:border-line-strong hover:text-fg"> | |
| 423 | + | {p} | |
| 424 | + | </Link> | |
| 425 | + | ))} | |
| 426 | + | <span> | |
| 427 | + | {reading.words.toLocaleString()} words · {reading.minutes} min read | |
| 428 | + | </span> | |
| 429 | + | {!editable && <span className="rounded-full bg-raised px-2 py-0.5">{DOC_ROLE_LABELS[role]}</span>} | |
| 430 | + | </div> | |
| 431 | + | {error && ( | |
| 432 | + | <div className="mt-3"> | |
| 433 | + | <ErrorText>{error}</ErrorText> | |
| 434 | + | </div> | |
| 435 | + | )} | |
| 436 | + | ||
| 437 | + | {/* Agents' suggestions, all together on a narrower screen (beside their blocks on a wide one). */} | |
| 438 | + | {suggestions.length > 0 && ( | |
| 439 | + | <div className="mt-6 rounded-xl border border-accent/30 bg-accent/5 p-3 2xl:hidden"> | |
| 440 | + | <div className="mb-2 flex items-center gap-2"> | |
| 441 | + | <Sparkles size={14} className="text-accent" /> | |
| 442 | + | <span className="grow text-sm font-medium"> | |
| 443 | + | {suggestions.length} {suggestions.length === 1 ? "suggestion" : "suggestions"} waiting | |
| 444 | + | </span> | |
| 445 | + | {editable && suggestions.length > 1 && ( | |
| 446 | + | <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="text-xs font-medium text-accent hover:underline"> | |
| 447 | + | Accept all | |
| 448 | + | </button> | |
| 449 | + | )} | |
| 450 | + | </div> | |
| 451 | + | <div className="space-y-2"> | |
| 452 | + | {suggestions.map((s) => ( | |
| 453 | + | <SuggestionCard key={s.id} suggestion={s} canDecide={editable} onDecide={decide} compact /> | |
| 454 | + | ))} | |
| 455 | + | </div> | |
| 456 | + | </div> | |
| 457 | + | )} | |
| 458 | + | {suggestions.length > 1 && editable && ( | |
| 459 | + | <div className="mt-4 hidden justify-end 2xl:flex"> | |
| 460 | + | <button type="button" onClick={async () => { setSuggestions([]); await send("accept_all", { page_id: page.id }); }} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent/15 px-3 text-xs font-medium text-accent hover:bg-accent/25"> | |
| 461 | + | <Check size={13} /> Accept all {suggestions.length} suggestions | |
| 462 | + | </button> | |
| 463 | + | </div> | |
| 464 | + | )} | |
| 465 | + | </div> | |
| 466 | + | ||
| 467 | + | {/* The page itself: live once the editor loads; its saved Markdown until then. */} | |
| 468 | + | <div className="mt-6"> | |
| 469 | + | {hydrated && !archived ? ( | |
| 470 | + | <Suspense fallback={<ReadView markdown={detail.markdown} />}> | |
| 471 | + | <DocEditor | |
| 472 | + | key={page.id} | |
| 473 | + | slug={slug} | |
| 474 | + | pageId={page.id} | |
| 475 | + | role={role} | |
| 476 | + | me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }} | |
| 477 | + | mentionables={layout?.mentionables ?? []} | |
| 478 | + | usercontent={usercontent} | |
| 479 | + | suggestions={suggestions} | |
| 480 | + | showComments={showComments} | |
| 481 | + | onPresence={setPresence} | |
| 482 | + | onStatus={setStatus} | |
| 483 | + | onEvent={onEvent} | |
| 484 | + | onPageThreads={setThreads} | |
| 485 | + | renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />} | |
| 486 | + | /> | |
| 487 | + | </Suspense> | |
| 488 | + | ) : ( | |
| 489 | + | <ReadView markdown={detail.markdown} /> | |
| 490 | + | )} | |
| 491 | + | </div> | |
| 492 | + | ||
| 493 | + | <div className={showComments ? "xl:max-w-3xl" : ""}> | |
| 494 | + | {(detail.children.length > 0 || detail.backlinks.length > 0) && ( | |
| 495 | + | <div className="mt-14 grid gap-6 border-t border-line pt-8 sm:grid-cols-2"> | |
| 496 | + | {detail.children.length > 0 && ( | |
| 497 | + | <section> | |
| 498 | + | <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Pages inside</h2> | |
| 499 | + | <ul className="space-y-1"> | |
| 500 | + | {detail.children.map((c) => ( | |
| 501 | + | <li key={c.id}> | |
| 502 | + | <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised"> | |
| 503 | + | <PageIcon icon={c.icon} /> {c.title || "Untitled"} | |
| 504 | + | </Link> | |
| 505 | + | </li> | |
| 506 | + | ))} | |
| 507 | + | </ul> | |
| 508 | + | </section> | |
| 509 | + | )} | |
| 510 | + | {detail.backlinks.length > 0 && ( | |
| 511 | + | <section> | |
| 512 | + | <h2 className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">Linked from</h2> | |
| 513 | + | <ul className="space-y-1"> | |
| 514 | + | {detail.backlinks.map((c) => ( | |
| 515 | + | <li key={c.id}> | |
| 516 | + | <Link to={c.path} className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised"> | |
| 517 | + | <PageIcon icon={c.icon} /> {c.title || "Untitled"} | |
| 518 | + | </Link> | |
| 519 | + | </li> | |
| 520 | + | ))} | |
| 521 | + | </ul> | |
| 522 | + | </section> | |
| 523 | + | )} | |
| 524 | + | </div> | |
| 525 | + | )} | |
| 526 | + | {hydrated && ( | |
| 527 | + | <Discussion | |
| 528 | + | threads={threads} | |
| 529 | + | people={people} | |
| 530 | + | canComment={canDo(role, "comment")} | |
| 531 | + | onPost={(text) => threadRequest("", "POST", { initialComment: { body: commentBody(text) }, page_level: true })} | |
| 532 | + | onReply={(thread, text) => threadRequest(`/${encodeURIComponent(thread)}/comments`, "POST", { comment: { body: commentBody(text) } })} | |
| 533 | + | onResolve={(thread, resolved) => void threadRequest(`/${encodeURIComponent(thread)}/${resolved ? "resolve" : "unresolve"}`, "POST", {})} | |
| 534 | + | /> | |
| 535 | + | )} | |
| 536 | + | </div> | |
| 537 | + | </article> | |
| 538 | + | ||
| 539 | + | <HistoryDialog slug={slug} pageId={page.id} open={history} onOpenChange={setHistory} canRestore={editable} onRestore={(versionId) => send("restore_version", { page_id: page.id, version_id: versionId })} /> | |
| 540 | + | {layout?.sidebar && ( | |
| 541 | + | <MoveDialog | |
| 542 | + | spaces={layout.sidebar.spaces} | |
| 543 | + | pageId={page.id} | |
| 544 | + | current={{ space_id: page.space_id, parent_id: page.parent_id }} | |
| 545 | + | open={moving} | |
| 546 | + | onOpenChange={setMoving} | |
| 547 | + | onMove={async (spaceId, parentId) => { | |
| 548 | + | const moved = await send<DocPage>("move_page", { page_id: page.id, move: { space_id: spaceId, parent_id: parentId, before_id: null } }); | |
| 549 | + | setMoving(false); | |
| 550 | + | if (moved.ok) navigate(moved.value.path); | |
| 551 | + | }} | |
| 552 | + | /> | |
| 553 | + | )} | |
| 554 | + | <TemplateDialog open={templating} onOpenChange={setTemplating} title={live.title || "Untitled"} onSave={(name, description) => send("save_template", { page_id: page.id, name, description })} /> | |
| 555 | + | </div> | |
| 556 | + | ); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | /** The page as Markdown: what shows before the editor loads, and what a reader without script sees. */ | |
| 560 | + | function ReadView({ markdown }: { markdown: string }) { | |
| 561 | + | if (!markdown.trim()) return <EditorSkeleton />; | |
| 562 | + | return ( | |
| 563 | + | <div className="docs-read"> | |
| 564 | + | <Markdown source={markdown} /> | |
| 565 | + | </div> | |
| 566 | + | ); | |
| 567 | + | } |
| 1 | + | import type { DocSearchHit } from "@g1t/contracts"; | |
| 2 | + | import { Search } from "lucide-react"; | |
| 3 | + | import { Form, Link, data, useSubmit } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/search"; | |
| 6 | + | import { useDocsData } from "../../../components/docs/actions"; | |
| 7 | + | import { PageIcon } from "../../../components/docs/sidebar"; | |
| 8 | + | import { EmptyState, TimeAgo } from "../../../components/ui"; | |
| 9 | + | import { SelectField } from "../../../components/ui/select"; | |
| 10 | + | import { snippetParts } from "../../../lib/docs"; | |
| 11 | + | import { page } from "../../../lib/meta"; | |
| 12 | + | import { docs } from "../../../lib/services.server"; | |
| 13 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Search docs · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | + | const viewer = requireUser(context, request); | |
| 21 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | const q = url.searchParams.get("q") ?? ""; | |
| 24 | + | const space = url.searchParams.get("space") || null; | |
| 25 | + | const project = url.searchParams.get("project") || null; | |
| 26 | + | const found = q.trim() | |
| 27 | + | ? await docs.search(params.owner.toLowerCase(), viewer, { query: q, space_id: space, project, limit: 50 }).catch(() => null) | |
| 28 | + | : null; | |
| 29 | + | return { q, space, project, hits: found?.ok ? found.value : q.trim() ? null : ([] as DocSearchHit[]) }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Full text over every page the viewer can read; by space and by project. */ | |
| 33 | + | export default function DocsSearch({ loaderData, params }: Route.ComponentProps) { | |
| 34 | + | const slug = params.owner.toLowerCase(); | |
| 35 | + | const { q, space, project, hits } = loaderData; | |
| 36 | + | const layout = useDocsData(); | |
| 37 | + | const submit = useSubmit(); | |
| 38 | + | const spaces = layout?.sidebar?.spaces ?? []; | |
| 39 | + | const projects = [...new Set(spaces.flatMap((s) => s.projects))].sort(); | |
| 40 | + | const refresh = () => submit(document.getElementById("docs-search") as HTMLFormElement); | |
| 41 | + | return ( | |
| 42 | + | <div className="mx-auto max-w-3xl"> | |
| 43 | + | <h1 className="text-xl font-semibold tracking-tight">Search docs</h1> | |
| 44 | + | <Form method="get" id="docs-search" className="mt-4 flex flex-wrap gap-2"> | |
| 45 | + | <label className="flex h-10 min-w-0 grow items-center gap-2 rounded-md border border-line bg-surface px-3 focus-within:border-accent/60"> | |
| 46 | + | <Search size={16} className="text-faint" aria-hidden="true" /> | |
| 47 | + | <input name="q" defaultValue={q} autoFocus placeholder="Words in a title or a page" aria-label="Search" className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" /> | |
| 48 | + | </label> | |
| 49 | + | <SelectField | |
| 50 | + | name="space" | |
| 51 | + | aria-label="Space" | |
| 52 | + | value={space ?? ""} | |
| 53 | + | afterChange={refresh} | |
| 54 | + | options={[{ value: "", label: "Every space" }, ...spaces.map((s) => ({ value: s.id, label: s.name }))]} | |
| 55 | + | className="h-10 min-w-40" | |
| 56 | + | /> | |
| 57 | + | {projects.length > 0 && ( | |
| 58 | + | <SelectField name="project" aria-label="Project" value={project ?? ""} afterChange={refresh} options={[{ value: "", label: "Any project" }, ...projects.map((p) => ({ value: p, label: p }))]} className="h-10 min-w-40" /> | |
| 59 | + | )} | |
| 60 | + | </Form> | |
| 61 | + | <div className="mt-6"> | |
| 62 | + | {hits === null ? ( | |
| 63 | + | <EmptyState title="Search didn't answer">Try again in a moment.</EmptyState> | |
| 64 | + | ) : !q.trim() ? ( | |
| 65 | + | <p className="text-sm text-muted">Search titles and text across every space you can read. Agents search the same way, and only see what the people they answer can see.</p> | |
| 66 | + | ) : hits.length === 0 ? ( | |
| 67 | + | <EmptyState title="Nothing found">No page you can read matches “{q}”.</EmptyState> | |
| 68 | + | ) : ( | |
| 69 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 70 | + | {hits.map((hit) => ( | |
| 71 | + | <li key={hit.id}> | |
| 72 | + | <Link to={hit.path} className="block px-4 py-3 transition-colors hover:bg-raised"> | |
| 73 | + | <span className="flex items-center gap-2"> | |
| 74 | + | <PageIcon icon={hit.icon} /> | |
| 75 | + | <span className="truncate text-sm font-medium">{hit.title || "Untitled"}</span> | |
| 76 | + | <span className="ml-auto shrink-0 text-xs text-faint"> | |
| 77 | + | {hit.space_name} · <TimeAgo at={hit.updated_at} /> | |
| 78 | + | </span> | |
| 79 | + | </span> | |
| 80 | + | <span className="mt-1 line-clamp-2 block text-xs leading-relaxed text-muted"> | |
| 81 | + | {snippetParts(hit.snippet).map((part, i) => | |
| 82 | + | part.match ? ( | |
| 83 | + | <mark key={i} className="rounded bg-accent/20 px-0.5 text-fg"> | |
| 84 | + | {part.text} | |
| 85 | + | </mark> | |
| 86 | + | ) : ( | |
| 87 | + | <span key={i}>{part.text}</span> | |
| 88 | + | ), | |
| 89 | + | )} | |
| 90 | + | </span> | |
| 91 | + | {hit.projects.length > 0 && <span className="mt-1 block text-[0.6875rem] text-faint">{hit.projects.join(" · ")}</span>} | |
| 92 | + | </Link> | |
| 93 | + | </li> | |
| 94 | + | ))} | |
| 95 | + | </ul> | |
| 96 | + | )} | |
| 97 | + | </div> | |
| 98 | + | <p className="mt-4 text-xs text-faint"> | |
| 99 | + | <Link to={`/${slug}/-/docs`} className="hover:text-fg"> | |
| 100 | + | Back to Docs | |
| 101 | + | </Link> | |
| 102 | + | </p> | |
| 103 | + | </div> | |
| 104 | + | ); | |
| 105 | + | } |
| 1 | + | import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts"; | |
| 2 | + | import { Archive } from "lucide-react"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { Link, data, useNavigate } from "react-router"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/space-settings"; | |
| 7 | + | import { useDocsAction, useDocsData } from "../../../components/docs/actions"; | |
| 8 | + | import { Face, SectionTitle } from "../../../components/docs/parts"; | |
| 9 | + | import { SpaceForm, initialSpace, spaceInput } from "../../../components/docs/space-form"; | |
| 10 | + | import { Button, ErrorText } from "../../../components/ui"; | |
| 11 | + | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog"; | |
| 12 | + | import { SelectField } from "../../../components/ui/select"; | |
| 13 | + | import { page } from "../../../lib/meta"; | |
| 14 | + | import { docs, identity } from "../../../lib/services.server"; | |
| 15 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 16 | + | ||
| 17 | + | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 18 | + | return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Docs · ${params.owner} · g1t` }); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> { | |
| 22 | + | const viewer = requireUser(context, request); | |
| 23 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 24 | + | const slug = params.owner.toLowerCase(); | |
| 25 | + | const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]); | |
| 26 | + | if (!found.ok) throw data(null, { status: 404 }); | |
| 27 | + | if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 }); | |
| 28 | + | return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] })); | |
| 32 | + | ||
| 33 | + | /** A space's settings: what it is, who it's for, its members and their roles, and how agents work in it. */ | |
| 34 | + | export default function SpaceSettings({ loaderData, params }: Route.ComponentProps) { | |
| 35 | + | const slug = params.owner.toLowerCase(); | |
| 36 | + | const { space, members, teams } = loaderData; | |
| 37 | + | const layout = useDocsData(); | |
| 38 | + | const navigate = useNavigate(); | |
| 39 | + | const settings = useDocsAction(slug); | |
| 40 | + | const people = useDocsAction(slug); | |
| 41 | + | const [adding, setAdding] = useState(""); | |
| 42 | + | const [addRole, setAddRole] = useState<DocRole>("edit"); | |
| 43 | + | const [archiving, setArchiving] = useState(false); | |
| 44 | + | const listed = new Set(members.map((m) => m.key)); | |
| 45 | + | const candidates = [ | |
| 46 | + | ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })), | |
| 47 | + | ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })), | |
| 48 | + | ].filter((c) => !listed.has(c.value)); | |
| 49 | + | return ( | |
| 50 | + | <div className="mx-auto max-w-3xl"> | |
| 51 | + | <p className="text-xs text-faint"> | |
| 52 | + | <Link to={`/${slug}/-/docs/${space.slug}`} className="hover:text-fg"> | |
| 53 | + | {space.name} | |
| 54 | + | </Link>{" "} | |
| 55 | + | / Settings | |
| 56 | + | </p> | |
| 57 | + | <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1> | |
| 58 | + | <div className="mt-8"> | |
| 59 | + | <SpaceForm | |
| 60 | + | initial={initialSpace(space)} | |
| 61 | + | teams={teams} | |
| 62 | + | isDefault={space.is_default} | |
| 63 | + | submitLabel="Save" | |
| 64 | + | busy={settings.busy} | |
| 65 | + | error={settings.error} | |
| 66 | + | onSubmit={async (value) => { | |
| 67 | + | const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) }); | |
| 68 | + | if (saved.ok && saved.value.slug !== space.slug) navigate(`/${slug}/-/docs/${saved.value.slug}/settings`, { replace: true }); | |
| 69 | + | }} | |
| 70 | + | /> | |
| 71 | + | </div> | |
| 72 | + | ||
| 73 | + | <section className="mt-12"> | |
| 74 | + | <SectionTitle>Members</SectionTitle> | |
| 75 | + | <p className="mb-4 text-xs leading-relaxed text-faint"> | |
| 76 | + | {space.kind === "private" | |
| 77 | + | ? "Only the people, agents and teams here can open this space." | |
| 78 | + | : "Everyone the space is for already has its base role; add people here to give them more. Workspace owners always have full access."}{" "} | |
| 79 | + | An agent added here still never does more than the person it works for. | |
| 80 | + | </p> | |
| 81 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 82 | + | {members.map((m) => ( | |
| 83 | + | <li key={m.key} className="flex items-center gap-3 px-4 py-2.5"> | |
| 84 | + | {m.kind === "team" ? ( | |
| 85 | + | <span className="flex size-6 items-center justify-center rounded-full bg-raised text-xs">@</span> | |
| 86 | + | ) : ( | |
| 87 | + | <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={24} /> | |
| 88 | + | )} | |
| 89 | + | <span className="min-w-0 grow"> | |
| 90 | + | <span className="block truncate text-sm">{m.display_name}</span> | |
| 91 | + | <span className="block text-xs text-faint">{m.kind === "team" ? "Team" : m.kind === "agent" ? "Agent" : `@${m.name}`}</span> | |
| 92 | + | </span> | |
| 93 | + | <SelectField aria-label={`${m.display_name}'s role`} value={m.role} onValueChange={(role) => people.send("set_member", { space_id: space.id, member: m.key, role })} options={ROLE_OPTIONS} className="h-8 w-40" /> | |
| 94 | + | <button type="button" onClick={() => people.send("set_member", { space_id: space.id, member: m.key, role: null })} className="text-xs text-faint hover:text-danger"> | |
| 95 | + | Remove | |
| 96 | + | </button> | |
| 97 | + | </li> | |
| 98 | + | ))} | |
| 99 | + | {members.length === 0 && <li className="px-4 py-3 text-sm text-muted">Nobody added yet.</li>} | |
| 100 | + | </ul> | |
| 101 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 102 | + | <SelectField aria-label="Person, agent or team" value={adding} onValueChange={setAdding} placeholder="Add a person, agent or team" options={candidates} className="h-9 min-w-64 grow" /> | |
| 103 | + | <SelectField aria-label="Their role" value={addRole} onValueChange={(r) => setAddRole(r as DocRole)} options={ROLE_OPTIONS} className="h-9 w-40" /> | |
| 104 | + | <Button | |
| 105 | + | type="button" | |
| 106 | + | variant="quiet" | |
| 107 | + | disabled={!adding || people.busy} | |
| 108 | + | onClick={async () => { | |
| 109 | + | // People are named by username here; the service keys them by id. | |
| 110 | + | const member = adding.startsWith("username:") ? await memberKeyFor(slug, adding.slice(9)) : adding; | |
| 111 | + | if (!member) return people.setError("That person isn't in the workspace."); | |
| 112 | + | const done = await people.send("set_member", { space_id: space.id, member, role: addRole }); | |
| 113 | + | if (done.ok) setAdding(""); | |
| 114 | + | }} | |
| 115 | + | > | |
| 116 | + | Add | |
| 117 | + | </Button> | |
| 118 | + | </div> | |
| 119 | + | {people.error && ( | |
| 120 | + | <div className="mt-2"> | |
| 121 | + | <ErrorText>{people.error}</ErrorText> | |
| 122 | + | </div> | |
| 123 | + | )} | |
| 124 | + | </section> | |
| 125 | + | ||
| 126 | + | {!space.is_default && ( | |
| 127 | + | <section className="mt-12 rounded-xl border border-danger/30 p-5"> | |
| 128 | + | <h2 className="text-sm font-semibold">Archive this space</h2> | |
| 129 | + | <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. Its pages, history and comments are kept.</p> | |
| 130 | + | <Button type="button" variant="danger" onClick={() => setArchiving(true)} className="mt-3"> | |
| 131 | + | <Archive size={14} /> Archive space | |
| 132 | + | </Button> | |
| 133 | + | </section> | |
| 134 | + | )} | |
| 135 | + | <AlertDialog open={archiving} onOpenChange={setArchiving}> | |
| 136 | + | <AlertDialogContent> | |
| 137 | + | <AlertDialogHeader> | |
| 138 | + | <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle> | |
| 139 | + | <AlertDialogDescription>Nobody will see it in Docs until it is brought back.</AlertDialogDescription> | |
| 140 | + | </AlertDialogHeader> | |
| 141 | + | <AlertDialogFooter> | |
| 142 | + | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 143 | + | <AlertDialogAction | |
| 144 | + | onClick={async () => { | |
| 145 | + | const done = await settings.send("update_space", { space_id: space.id, space_change: { archived: true } }); | |
| 146 | + | if (done.ok) navigate(`/${slug}/-/docs`); | |
| 147 | + | }} | |
| 148 | + | > | |
| 149 | + | Archive | |
| 150 | + | </AlertDialogAction> | |
| 151 | + | </AlertDialogFooter> | |
| 152 | + | </AlertDialogContent> | |
| 153 | + | </AlertDialog> | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** A person's member key (`user:<id>`), from their username, as the site resolves it. */ | |
| 159 | + | async function memberKeyFor(slug: string, username: string): Promise<string | null> { | |
| 160 | + | try { | |
| 161 | + | const r = await fetch(`/${slug}/-/docs/api?person=${encodeURIComponent(username)}`, { headers: { accept: "application/json" } }); | |
| 162 | + | const result = (await r.json()) as { ok: boolean; value?: string }; | |
| 163 | + | return result.ok && result.value ? result.value : null; | |
| 164 | + | } catch { | |
| 165 | + | return null; | |
| 166 | + | } | |
| 167 | + | } |
| 1 | + | import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, DOC_SPACE_KIND_LABELS, type DocPage, type DocSpace, type DocSpaceMember } from "@g1t/contracts"; | |
| 2 | + | import { Download, FilePlus2, Settings } from "lucide-react"; | |
| 3 | + | import { Link, data, useNavigate } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/space"; | |
| 6 | + | import { useDocsAction } from "../../../components/docs/actions"; | |
| 7 | + | import { Face, SectionTitle } from "../../../components/docs/parts"; | |
| 8 | + | import { PageIcon, SpaceIcon } from "../../../components/docs/sidebar"; | |
| 9 | + | import { ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../../components/ui"; | |
| 10 | + | import { buildTree, canDo, flatten } from "../../../lib/docs"; | |
| 11 | + | import { page } from "../../../lib/meta"; | |
| 12 | + | import { docs } from "../../../lib/services.server"; | |
| 13 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `${loaded?.space.name ?? "Space"} · Docs · ${params.owner} · g1t`, description: loaded?.space.description }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }> { | |
| 20 | + | const viewer = requireUser(context, request); | |
| 21 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | + | const found = await docs.space(params.owner.toLowerCase(), params.space, viewer); | |
| 23 | + | if (!found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 403 }); | |
| 24 | + | return found.value; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** A space: what it is, who it's for, and its pages as a tree. */ | |
| 28 | + | export default function SpacePage({ loaderData, params }: Route.ComponentProps) { | |
| 29 | + | const slug = params.owner.toLowerCase(); | |
| 30 | + | const { space, members, pages } = loaderData; | |
| 31 | + | const navigate = useNavigate(); | |
| 32 | + | const { send, busy, error } = useDocsAction(slug); | |
| 33 | + | const tree = flatten(buildTree(pages.map((p) => ({ id: p.id, parent_id: p.parent_id, position: p.position, title: p.title, icon: p.icon, slug: p.slug })))); | |
| 34 | + | const byId = new Map(pages.map((p) => [p.id, p])); | |
| 35 | + | const create = async () => { | |
| 36 | + | const made = await send<{ path: string }>("create_page", { page: { space_id: space.id } }); | |
| 37 | + | if (made.ok) navigate(made.value.path); | |
| 38 | + | }; | |
| 39 | + | return ( | |
| 40 | + | <div className="mx-auto max-w-4xl"> | |
| 41 | + | <div className="flex flex-wrap items-start gap-4"> | |
| 42 | + | <span className="flex size-14 items-center justify-center rounded-2xl bg-raised text-2xl"> | |
| 43 | + | <SpaceIcon space={space} size={26} /> | |
| 44 | + | </span> | |
| 45 | + | <div className="min-w-0 grow"> | |
| 46 | + | <h1 className="text-2xl font-semibold tracking-tight">{space.name}</h1> | |
| 47 | + | {space.description && <p className="mt-1 text-sm text-muted">{space.description}</p>} | |
| 48 | + | <p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint"> | |
| 49 | + | <span>{space.kind === "team" ? `Team @${space.team}` : DOC_SPACE_KIND_LABELS[space.kind]}</span> | |
| 50 | + | {space.default_role && <span>{DOC_ROLE_LABELS[space.default_role]}</span>} | |
| 51 | + | <span>Agents: {DOC_AGENT_MODE_LABELS[space.agent_mode].toLowerCase()}</span> | |
| 52 | + | <span>Your access: {DOC_ROLE_LABELS[space.viewer_role].toLowerCase()}</span> | |
| 53 | + | {space.projects.map((p) => ( | |
| 54 | + | <Link key={p} to={`/${p}`} className="font-mono hover:text-fg"> | |
| 55 | + | {p} | |
| 56 | + | </Link> | |
| 57 | + | ))} | |
| 58 | + | </p> | |
| 59 | + | </div> | |
| 60 | + | <div className="flex items-center gap-2"> | |
| 61 | + | <a href={`/${slug}/-/docs/export?space=${encodeURIComponent(space.id)}`} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 hover:bg-surface hover:text-fg"> | |
| 62 | + | <Download size={15} /> Export | |
| 63 | + | </a> | |
| 64 | + | {canDo(space.viewer_role, "manage") && ( | |
| 65 | + | <ButtonLink to={`/${slug}/-/docs/${space.slug}/settings`} variant="quiet"> | |
| 66 | + | <Settings size={15} /> Settings | |
| 67 | + | </ButtonLink> | |
| 68 | + | )} | |
| 69 | + | {canDo(space.viewer_role, "edit") && ( | |
| 70 | + | <button type="button" disabled={busy} onClick={create} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg hover:bg-accent-hover"> | |
| 71 | + | <FilePlus2 size={15} /> New page | |
| 72 | + | </button> | |
| 73 | + | )} | |
| 74 | + | </div> | |
| 75 | + | </div> | |
| 76 | + | {error && ( | |
| 77 | + | <div className="mt-3"> | |
| 78 | + | <ErrorText>{error}</ErrorText> | |
| 79 | + | </div> | |
| 80 | + | )} | |
| 81 | + | <section className="mt-8"> | |
| 82 | + | <SectionTitle>Pages</SectionTitle> | |
| 83 | + | {tree.length === 0 ? ( | |
| 84 | + | <EmptyState title="No pages yet">{canDo(space.viewer_role, "edit") ? "Start with New page, or from a template." : "Nothing has been written here yet."}</EmptyState> | |
| 85 | + | ) : ( | |
| 86 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 87 | + | {tree.map((item) => { | |
| 88 | + | const p = byId.get(item.id)!; | |
| 89 | + | return ( | |
| 90 | + | <li key={item.id}> | |
| 91 | + | <Link to={p.path} prefetch="intent" className="flex items-center gap-3 py-2.5 pr-4 text-sm transition-colors hover:bg-raised" style={{ paddingLeft: 16 + item.depth * 20 }}> | |
| 92 | + | <PageIcon icon={p.icon} /> | |
| 93 | + | <span className="min-w-0 grow truncate">{p.title || "Untitled"}</span> | |
| 94 | + | <span className="hidden shrink-0 items-center gap-1.5 text-xs text-faint sm:flex"> | |
| 95 | + | {p.updated_by && <Face who={p.updated_by} size={14} />} | |
| 96 | + | <TimeAgo at={p.updated_at} /> | |
| 97 | + | </span> | |
| 98 | + | </Link> | |
| 99 | + | </li> | |
| 100 | + | ); | |
| 101 | + | })} | |
| 102 | + | </ul> | |
| 103 | + | )} | |
| 104 | + | </section> | |
| 105 | + | {members.length > 0 && ( | |
| 106 | + | <section className="mt-8"> | |
| 107 | + | <SectionTitle>Members</SectionTitle> | |
| 108 | + | <ul className="flex flex-wrap gap-2"> | |
| 109 | + | {members.map((m) => ( | |
| 110 | + | <li key={m.key} className="flex items-center gap-2 rounded-full border border-line bg-surface py-1 pr-3 pl-1 text-xs"> | |
| 111 | + | {m.kind === "team" ? <span className="flex size-5 items-center justify-center rounded-full bg-raised text-[0.625rem]">@</span> : <Face who={{ kind: m.kind, id: m.key.slice(m.key.indexOf(":") + 1), name: m.name, avatar: m.avatar, avatar_seed: m.avatar_seed ?? null }} size={20} />} | |
| 112 | + | <span>{m.display_name}</span> | |
| 113 | + | <span className="text-faint">{DOC_ROLE_LABELS[m.role]}</span> | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | </section> | |
| 118 | + | )} | |
| 119 | + | </div> | |
| 120 | + | ); | |
| 121 | + | } |
| 1 | + | import type { DocTemplate } from "@g1t/contracts"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { data, useNavigate } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/templates"; | |
| 6 | + | import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions"; | |
| 7 | + | import { SectionTitle, TemplateCard } from "../../../components/docs/parts"; | |
| 8 | + | import { ErrorText } from "../../../components/ui"; | |
| 9 | + | import { SelectField } from "../../../components/ui/select"; | |
| 10 | + | import { canDo } from "../../../lib/docs"; | |
| 11 | + | import { page } from "../../../lib/meta"; | |
| 12 | + | import { docs } from "../../../lib/services.server"; | |
| 13 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Templates · Docs · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ templates: DocTemplate[] }> { | |
| 20 | + | const viewer = requireUser(context, request); | |
| 21 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | + | const found = await docs.templates(params.owner.toLowerCase(), viewer).catch(() => null); | |
| 23 | + | return { templates: found?.ok ? found.value : [] }; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /** g1t's templates and the workspace's own; start a page from one in any space you can write in. */ | |
| 27 | + | export default function DocsTemplates({ loaderData, params }: Route.ComponentProps) { | |
| 28 | + | const slug = params.owner.toLowerCase(); | |
| 29 | + | const layout = useDocsData(); | |
| 30 | + | const navigate = useNavigate(); | |
| 31 | + | const { send, error: actionError } = useDocsAction(slug); | |
| 32 | + | const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit")); | |
| 33 | + | const [space, setSpace] = useState(writable.find((s) => s.is_default)?.id ?? writable[0]?.id ?? ""); | |
| 34 | + | const [error, setError] = useState<string | null>(null); | |
| 35 | + | const use = async (template: string) => { | |
| 36 | + | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: space, template_id: template } }); | |
| 37 | + | if (made.ok) navigate(made.value.path); | |
| 38 | + | else setError(made.error.message); | |
| 39 | + | }; | |
| 40 | + | const builtin = loaderData.templates.filter((t) => t.builtin); | |
| 41 | + | const own = loaderData.templates.filter((t) => !t.builtin); | |
| 42 | + | return ( | |
| 43 | + | <div className="mx-auto max-w-5xl"> | |
| 44 | + | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 45 | + | <div> | |
| 46 | + | <h1 className="text-xl font-semibold tracking-tight">Templates</h1> | |
| 47 | + | <p className="mt-1 text-sm text-muted">Start a page with its structure already there. Save any page as a template from its ⋯ menu.</p> | |
| 48 | + | </div> | |
| 49 | + | {writable.length > 0 && ( | |
| 50 | + | <label className="flex items-center gap-2 text-sm text-muted"> | |
| 51 | + | New pages go in | |
| 52 | + | <SelectField aria-label="Space" value={space} onValueChange={setSpace} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 min-w-40" /> | |
| 53 | + | </label> | |
| 54 | + | )} | |
| 55 | + | </div> | |
| 56 | + | {(error || actionError) && ( | |
| 57 | + | <div className="mt-3"> | |
| 58 | + | <ErrorText>{error ?? actionError}</ErrorText> | |
| 59 | + | </div> | |
| 60 | + | )} | |
| 61 | + | <section className="mt-8"> | |
| 62 | + | <SectionTitle>From g1t</SectionTitle> | |
| 63 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 64 | + | {builtin.map((t) => ( | |
| 65 | + | <TemplateCard | |
| 66 | + | key={t.id} | |
| 67 | + | template={t} | |
| 68 | + | action={ | |
| 69 | + | <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint"> | |
| 70 | + | Use template | |
| 71 | + | </button> | |
| 72 | + | } | |
| 73 | + | /> | |
| 74 | + | ))} | |
| 75 | + | </div> | |
| 76 | + | </section> | |
| 77 | + | <section className="mt-10"> | |
| 78 | + | <SectionTitle>Your workspace's</SectionTitle> | |
| 79 | + | {own.length === 0 ? ( | |
| 80 | + | <p className="text-sm text-muted">None yet. Open a page, then choose Save as template from its ⋯ menu.</p> | |
| 81 | + | ) : ( | |
| 82 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 83 | + | {own.map((t) => ( | |
| 84 | + | <TemplateCard | |
| 85 | + | key={t.id} | |
| 86 | + | template={t} | |
| 87 | + | action={ | |
| 88 | + | <> | |
| 89 | + | <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint"> | |
| 90 | + | Use template | |
| 91 | + | </button> | |
| 92 | + | <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger"> | |
| 93 | + | Delete | |
| 94 | + | </button> | |
| 95 | + | </> | |
| 96 | + | } | |
| 97 | + | /> | |
| 98 | + | ))} | |
| 99 | + | </div> | |
| 100 | + | )} | |
| 101 | + | </section> | |
| 102 | + | </div> | |
| 103 | + | ); | |
| 104 | + | } |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { DocThreadAction } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/threads"; | |
| 6 | + | import { docs } from "../../../lib/services.server"; | |
| 7 | + | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * The editor's comment store (BlockNote's `RESTYjsThreadStore`) writes | |
| 11 | + | * here; it reads threads straight from the page's live document, where | |
| 12 | + | * the page's room puts what these requests change: | |
| 13 | + | * | |
| 14 | + | * POST "" start a thread | |
| 15 | + | * POST /<thread>/addToDocument anchor it to the selected text | |
| 16 | + | * POST /<thread>/comments reply | |
| 17 | + | * PUT /<thread>/comments/<comment> edit a comment | |
| 18 | + | * DELETE /<thread>/comments/<comment>?soft= delete a comment | |
| 19 | + | * DELETE /<thread> delete a thread | |
| 20 | + | * POST /<thread>/resolve | /unresolve | |
| 21 | + | * POST /<thread>/comments/<comment>/reactions { emoji } | |
| 22 | + | * DELETE /<thread>/comments/<comment>/reactions/<emoji> | |
| 23 | + | * | |
| 24 | + | * A comment on the whole page is a thread started with `page_level: true`. | |
| 25 | + | */ | |
| 26 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 27 | + | assertSameOrigin(request); | |
| 28 | + | const viewer = requireUser(context, request); | |
| 29 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 30 | + | const slug = params.owner.toLowerCase(); | |
| 31 | + | const url = new URL(request.url); | |
| 32 | + | const parts = (params["*"] ?? "").split("/").filter(Boolean).map(decodeURIComponent); | |
| 33 | + | const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; | |
| 34 | + | const method = request.method.toUpperCase(); | |
| 35 | + | const [thread, what, comment, sub, emoji] = parts; | |
| 36 | + | let action: DocThreadAction | null = null; | |
| 37 | + | if (!thread && method === "POST") { | |
| 38 | + | const initial = (body.initialComment ?? {}) as { body?: unknown; metadata?: unknown }; | |
| 39 | + | action = { op: "create", body: initial.body ?? body.body, metadata: body.metadata ?? initial.metadata, page_level: body.page_level === true }; | |
| 40 | + | } else if (thread && !what && method === "DELETE") action = { op: "delete_thread", thread_id: thread }; | |
| 41 | + | else if (thread && what === "addToDocument" && method === "POST") { | |
| 42 | + | const selection = (body.selection ?? {}) as { yjs?: { anchor?: unknown; head?: unknown } }; | |
| 43 | + | if (!selection.yjs) return Response.json({ ok: false, error: { code: "invalid", message: "Select some text to comment on." } }, { status: 422 }); | |
| 44 | + | action = { op: "anchor", thread_id: thread, anchor: selection.yjs.anchor, head: selection.yjs.head }; | |
| 45 | + | } else if (thread && (what === "resolve" || what === "unresolve") && method === "POST") action = { op: what, thread_id: thread }; | |
| 46 | + | else if (thread && what === "comments" && !comment && method === "POST") { | |
| 47 | + | const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown }; | |
| 48 | + | action = { op: "comment", thread_id: thread, body: c.body, metadata: c.metadata }; | |
| 49 | + | } else if (thread && what === "comments" && comment && !sub && method === "PUT") { | |
| 50 | + | const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown }; | |
| 51 | + | action = { op: "edit_comment", thread_id: thread, comment_id: comment, body: c.body, metadata: c.metadata }; | |
| 52 | + | } else if (thread && what === "comments" && comment && !sub && method === "DELETE") { | |
| 53 | + | action = { op: "delete_comment", thread_id: thread, comment_id: comment, soft: url.searchParams.get("soft") === "true" }; | |
| 54 | + | } else if (thread && what === "comments" && comment && sub === "reactions" && method === "POST") { | |
| 55 | + | action = { op: "react", thread_id: thread, comment_id: comment, emoji: String(body.emoji ?? "") }; | |
| 56 | + | } else if (thread && what === "comments" && comment && sub === "reactions" && emoji && method === "DELETE") { | |
| 57 | + | action = { op: "unreact", thread_id: thread, comment_id: comment, emoji }; | |
| 58 | + | } | |
| 59 | + | if (!action) return Response.json({ ok: false, error: { code: "invalid", message: "Unknown comment request." } }, { status: 404 }); | |
| 60 | + | try { | |
| 61 | + | const result = await docs.thread(slug, params.page, viewer, action); | |
| 62 | + | // The thread store reads the answer's body as the thread or comment. | |
| 63 | + | if (!result.ok) return Response.json(result, { status: result.error.code === "forbidden" ? 403 : result.error.code === "not_found" ? 404 : 422 }); | |
| 64 | + | return Response.json(result.value ?? {}, { headers: { "cache-control": "no-store" } }); | |
| 65 | + | } catch (error) { | |
| 66 | + | console.error("docs threads:", error); | |
| 67 | + | return Response.json({ ok: false, error: { code: "conflict", message: "Docs didn't answer." } }, { status: 503 }); | |
| 68 | + | } | |
| 69 | + | } |
| 1 | + | import type { DocPage } from "@g1t/contracts"; | |
| 2 | + | import { RotateCcw, Trash2 } from "lucide-react"; | |
| 3 | + | import { useState } from "react"; | |
| 4 | + | import { data } from "react-router"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/trash"; | |
| 7 | + | import { useDocsAction } from "../../../components/docs/actions"; | |
| 8 | + | import { PageIcon } from "../../../components/docs/sidebar"; | |
| 9 | + | import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui"; | |
| 10 | + | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog"; | |
| 11 | + | import { page } from "../../../lib/meta"; | |
| 12 | + | import { docs } from "../../../lib/services.server"; | |
| 13 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Trash · Docs · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ pages: DocPage[] | null }> { | |
| 20 | + | const viewer = requireUser(context, request); | |
| 21 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | + | const found = await docs.trash(params.owner.toLowerCase(), viewer).catch(() => null); | |
| 23 | + | return { pages: found?.ok ? found.value : null }; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /** Pages moved to the trash, in spaces the viewer can edit: restore them, or delete them for good. */ | |
| 27 | + | export default function DocsTrash({ loaderData, params }: Route.ComponentProps) { | |
| 28 | + | const slug = params.owner.toLowerCase(); | |
| 29 | + | const { send, error } = useDocsAction(slug); | |
| 30 | + | const [forever, setForever] = useState<DocPage | null>(null); | |
| 31 | + | const pages = loaderData.pages; | |
| 32 | + | return ( | |
| 33 | + | <div className="mx-auto max-w-3xl"> | |
| 34 | + | <h1 className="text-xl font-semibold tracking-tight">Trash</h1> | |
| 35 | + | <p className="mt-1 text-sm text-muted">Pages come back with what was under them. Deleting for good can't be undone and needs full access to the space.</p> | |
| 36 | + | {error && ( | |
| 37 | + | <div className="mt-3"> | |
| 38 | + | <ErrorText>{error}</ErrorText> | |
| 39 | + | </div> | |
| 40 | + | )} | |
| 41 | + | <div className="mt-6"> | |
| 42 | + | {pages === null ? ( | |
| 43 | + | <EmptyState title="Docs didn't answer">Try again in a moment.</EmptyState> | |
| 44 | + | ) : pages.length === 0 ? ( | |
| 45 | + | <EmptyState title="The trash is empty" /> | |
| 46 | + | ) : ( | |
| 47 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 48 | + | {pages.map((p) => ( | |
| 49 | + | <li key={p.id} className="flex items-center gap-3 px-4 py-2.5"> | |
| 50 | + | <PageIcon icon={p.icon} /> | |
| 51 | + | <span className="min-w-0 grow"> | |
| 52 | + | <span className="block truncate text-sm">{p.title || "Untitled"}</span> | |
| 53 | + | <span className="block text-xs text-faint"> | |
| 54 | + | {p.space_slug} · trashed {p.archived_at ? <TimeAgo at={p.archived_at} /> : null} | |
| 55 | + | </span> | |
| 56 | + | </span> | |
| 57 | + | <button type="button" onClick={() => send("restore_page", { page_id: p.id })} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-xs text-fg hover:bg-raised"> | |
| 58 | + | <RotateCcw size={13} /> Restore | |
| 59 | + | </button> | |
| 60 | + | <button type="button" onClick={() => setForever(p)} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-danger/40 px-2.5 text-xs text-danger hover:bg-danger/10"> | |
| 61 | + | <Trash2 size={13} /> Delete | |
| 62 | + | </button> | |
| 63 | + | </li> | |
| 64 | + | ))} | |
| 65 | + | </ul> | |
| 66 | + | )} | |
| 67 | + | </div> | |
| 68 | + | <AlertDialog open={!!forever} onOpenChange={(open) => !open && setForever(null)}> | |
| 69 | + | <AlertDialogContent> | |
| 70 | + | <AlertDialogHeader> | |
| 71 | + | <AlertDialogTitle>Delete “{forever?.title || "Untitled"}” for good?</AlertDialogTitle> | |
| 72 | + | <AlertDialogDescription>It goes with its history, comments and every page under it. This can't be undone.</AlertDialogDescription> | |
| 73 | + | </AlertDialogHeader> | |
| 74 | + | <AlertDialogFooter> | |
| 75 | + | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 76 | + | <AlertDialogAction | |
| 77 | + | onClick={() => { | |
| 78 | + | if (forever) void send("delete_page", { page_id: forever.id }); | |
| 79 | + | setForever(null); | |
| 80 | + | }} | |
| 81 | + | > | |
| 82 | + | Delete for good | |
| 83 | + | </AlertDialogAction> | |
| 84 | + | </AlertDialogFooter> | |
| 85 | + | </AlertDialogContent> | |
| 86 | + | </AlertDialog> | |
| 87 | + | </div> | |
| 88 | + | ); | |
| 89 | + | } |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | import { data } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { DOCS_VIEWER_HEADER, DOC_MAX_FILE_BYTES } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/upload"; | |
| 7 | + | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A file put in a page (an image, a PDF, an attachment): | |
| 11 | + | * `POST <site>/<workspace>/-/docs/upload?page=<id>&name=<file name>` with | |
| 12 | + | * the file as the body. The docs service checks the viewer can edit the | |
| 13 | + | * page, keeps the file, and answers with its address on the usercontent | |
| 14 | + | * origin, where it is served, never on the site. | |
| 15 | + | */ | |
| 16 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 17 | + | assertSameOrigin(request); | |
| 18 | + | const viewer = requireUser(context, request); | |
| 19 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 20 | + | const url = new URL(request.url); | |
| 21 | + | const length = Number(request.headers.get("content-length") ?? "0"); | |
| 22 | + | if (!length || length > DOC_MAX_FILE_BYTES) { | |
| 23 | + | return Response.json({ ok: false, error: { code: "invalid", message: `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.` } }); | |
| 24 | + | } | |
| 25 | + | const target = new URL("https://docs/files"); | |
| 26 | + | target.searchParams.set("workspace", params.owner.toLowerCase()); | |
| 27 | + | target.searchParams.set("page", url.searchParams.get("page") ?? ""); | |
| 28 | + | target.searchParams.set("name", url.searchParams.get("name") ?? "file"); | |
| 29 | + | try { | |
| 30 | + | const answer = await env.DOCS.fetch(target.toString(), { | |
| 31 | + | method: "PUT", | |
| 32 | + | headers: { | |
| 33 | + | "content-type": request.headers.get("content-type") ?? "application/octet-stream", | |
| 34 | + | "content-length": String(length), | |
| 35 | + | [DOCS_VIEWER_HEADER]: JSON.stringify(viewer), | |
| 36 | + | }, | |
| 37 | + | body: request.body, | |
| 38 | + | }); | |
| 39 | + | return new Response(answer.body, { headers: { "content-type": "application/json", "cache-control": "no-store" } }); | |
| 40 | + | } catch (error) { | |
| 41 | + | console.error("docs: upload", error); | |
| 42 | + | return Response.json({ ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again." } }); | |
| 43 | + | } | |
| 44 | + | } |
| 13 | 13 | "postinstall": "wrangler types --include-env=false" | |
| 14 | 14 | }, | |
| 15 | 15 | "dependencies": { | |
| 16 | + | "@blocknote/code-block": "^0.55.0", | |
| 17 | + | "@blocknote/core": "^0.55.0", | |
| 18 | + | "@blocknote/react": "^0.55.0", | |
| 19 | + | "@blocknote/shadcn": "^0.55.0", | |
| 16 | 20 | "@g1t/contracts": "*", | |
| 17 | 21 | "@g1t/theme": "*", | |
| 18 | 22 | "class-variance-authority": "^0.7.1", | |
| 19 | 23 | "clsx": "^2.1.1", | |
| 20 | 24 | "cmdk": "^1.1.1", | |
| 21 | − | "isbot": "^5.1.36", | |
| 22 | 25 | "hast-util-to-jsx-runtime": "^2.3.6", | |
| 23 | 26 | "html-url-attributes": "^3.0.1", | |
| 27 | + | "isbot": "^5.1.36", | |
| 28 | + | "katex": "^0.19.0", | |
| 29 | + | "lib0": "^0.2.119", | |
| 24 | 30 | "lucide-react": "^1.49.0", | |
| 25 | 31 | "mdast-util-find-and-replace": "^3.0.2", | |
| 32 | + | "mermaid": "^12.1.0", | |
| 26 | 33 | "radix-ui": "^1.6.7", | |
| 27 | 34 | "react": "^19.2.8", | |
| 28 | 35 | "react-dom": "^19.2.8", | |
| ⋯ | |||
| 36 | 43 | "shiki": "^4.5.0", | |
| 37 | 44 | "simple-icons": "^16.34.0", | |
| 38 | 45 | "tailwind-merge": "^3.7.0", | |
| 46 | + | "unified": "^11.0.5", | |
| 39 | 47 | "unist-util-visit": "^5.1.0", | |
| 40 | − | "unified": "^11.0.5", | |
| 41 | 48 | "uqr": "^0.1.3", | |
| 42 | − | "vfile": "^6.0.3" | |
| 49 | + | "vfile": "^6.0.3", | |
| 50 | + | "y-prosemirror": "^1.3.7", | |
| 51 | + | "y-protocols": "^1.0.7", | |
| 52 | + | "yjs": "^13.6.33" | |
| 43 | 53 | }, | |
| 44 | 54 | "devDependencies": { | |
| 45 | 55 | "@cloudflare/vite-plugin": "^1.62.4", | |
| 27 | 27 | * socket, forwarded as it is (app/lib/chat-live.server.ts). | |
| 28 | 28 | */ | |
| 29 | 29 | CHAT: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 30 | + | /** Docs (services/docs): RPC, each page's live socket, and files in pages. */ | |
| 31 | + | DOCS: ServiceBinding & { fetch(request: Request | string, init?: RequestInit): Promise<Response> }; | |
| 30 | 32 | /** The workspace's own agents: definitions, templates and desks. */ | |
| 31 | 33 | AGENTS: ServiceBinding; | |
| 32 | 34 | /** |
| 6 | 6 | */ | |
| 7 | 7 | import { reposClient } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | − | import { MAX_RAW_BYTES, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent"; | |
| 9 | + | import { MAX_RAW_BYTES, PDF_POLICY, USERCONTENT_POLICY, isCommit, parseRawPath, rawHeaders, verifyRaw } from "../app/lib/usercontent"; | |
| 10 | 10 | ||
| 11 | 11 | /** An uploaded avatar, by the SHA-256 of its bytes. */ | |
| 12 | 12 | export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 13 | 13 | /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */ | |
| 14 | 14 | export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/; | |
| 15 | + | /** A file put in a Docs page, by its random key: kept by the docs service (services/docs). */ | |
| 16 | + | export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/; | |
| 15 | 17 | /** The only types identity stores, having checked each image's bytes. */ | |
| 16 | 18 | const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); | |
| 17 | 19 | ||
| ⋯ | |||
| 40 | 42 | if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin); | |
| 41 | 43 | const emoji = EMOJI_PATH.exec(path); | |
| 42 | 44 | if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin); | |
| 45 | + | const docsFile = DOCS_FILE_PATH.exec(path); | |
| 46 | + | if (docsFile) return serveDocsFile(env, method, docsFile[1]!); | |
| 43 | 47 | const file = parseRawPath(path); | |
| 44 | 48 | if (file) return serveRaw(env, request, method, file); | |
| 45 | 49 | return plain(404, "Not found", "public, max-age=300"); | |
| ⋯ | |||
| 114 | 118 | ctx.waitUntil(cache.put(cacheKey, new Response(value, { headers }))); | |
| 115 | 119 | return new Response(method === "HEAD" ? null : value, { headers }); | |
| 116 | 120 | } | |
| 121 | + | ||
| 122 | + | /** | |
| 123 | + | * A file put in a Docs page. Its key is 256 random bits the docs service | |
| 124 | + | * made, so the address is the permission, as a shared link is; the docs | |
| 125 | + | * service serves images, media and PDFs as themselves and everything else | |
| 126 | + | * as a download, and nothing here can run script. | |
| 127 | + | */ | |
| 128 | + | async function serveDocsFile(env: Env, method: string, key: string): Promise<Response> { | |
| 129 | + | let answer: Response; | |
| 130 | + | try { | |
| 131 | + | answer = await env.DOCS.fetch(`https://docs/files/${key}`, { method }); | |
| 132 | + | } catch { | |
| 133 | + | return plain(503, "Docs didn't answer"); | |
| 134 | + | } | |
| 135 | + | if (!answer.ok) return plain(answer.status === 404 ? 404 : 502, "Not found", "public, max-age=60"); | |
| 136 | + | const headers = new Headers(answer.headers); | |
| 137 | + | headers.set("x-content-type-options", "nosniff"); | |
| 138 | + | headers.set("content-security-policy", headers.get("content-type") === "application/pdf" ? PDF_POLICY : USERCONTENT_POLICY); | |
| 139 | + | headers.set("cross-origin-resource-policy", "cross-origin"); | |
| 140 | + | return new Response(method === "HEAD" ? null : answer.body, { status: 200, headers }); | |
| 141 | + | } | |
| 47 | 47 | { "binding": "SEARCH", "service": "g1t-search" }, | |
| 48 | 48 | // Chat: channels, direct messages and the live socket (services/chat). | |
| 49 | 49 | { "binding": "CHAT", "service": "g1t-chat" }, | |
| 50 | + | // Docs: spaces, pages, each page's live socket, and files in pages (services/docs). | |
| 51 | + | { "binding": "DOCS", "service": "g1t-docs-service" }, | |
| 50 | 52 | // The workspace's own agents: definitions, templates, desks (services/agents). | |
| 51 | 53 | { "binding": "AGENTS", "service": "g1t-agents" }, | |
| 52 | 54 | // Live notifications, counts and browser push: each tab's feed socket (services/notify). |
| 127 | 127 | ], | |
| 128 | 128 | "self_host": "run" | |
| 129 | 129 | }, | |
| 130 | + | // Docs mode's service. Its Worker is g1t-docs-service: g1t-docs is | |
| 131 | + | // the documentation site (apps/docs). | |
| 132 | + | "docs-service": { | |
| 133 | + | "path": "services/docs", | |
| 134 | + | "kind": "ts-worker", | |
| 135 | + | "worker": "g1t-docs-service", | |
| 136 | + | "d1": { "database": "g1t-docs", "migrations": "migrations" }, | |
| 137 | + | "stage": "core", | |
| 138 | + | "secrets": [], | |
| 139 | + | "setup": [ | |
| 140 | + | "The D1 database, before the first deploy: npx wrangler d1 create g1t-docs, then put its id in services/docs/wrangler.jsonc", | |
| 141 | + | "The R2 bucket for files in pages: npx wrangler r2 bucket create g1t-docs-files" | |
| 142 | + | ], | |
| 143 | + | "self_host": "run" | |
| 144 | + | }, | |
| 130 | 145 | "notify": { | |
| 131 | 146 | "path": "services/notify", | |
| 132 | 147 | "kind": "ts-worker", |
| 112 | 112 | | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to | | |
| 113 | 113 | | `services/projects` | TS | Queue consumer | Runs unchanged | | |
| 114 | 114 | | `services/chat` | TS | Durable Objects (one room per channel, WebSocket hibernation), KV `AVATARS` (custom emoji images, under `emoji/`) | Runs unchanged; workerd runs its Durable Objects, and the site serves emoji images from the same KV | | |
| 115 | + | | `services/docs` | TS | Durable Objects (one room per page: the Yjs document, WebSocket hibernation, SQLite storage, alarms), **R2** (`FILES`, files in pages, behind the `FileStore` interface in `src/files.ts`), D1 with FTS5 | Runs unchanged; workerd runs its Durable Objects, and `FILES` is the local R2 bucket Wrangler keeps on disk (an S3 `FileStore` for RustFS is the next step) | | |
| 115 | 116 | | `services/notify` | TS | Durable Objects (one feed per person: WebSocket hibernation, SQLite storage); outbound HTTPS to browsers' push services | Runs unchanged; browser push needs a VAPID key pair (`node scripts/ops/vapid-keys.mjs`), else notifications are live in open tabs only | | |
| 116 | 117 | | `services/agents` | TS | Durable Objects (one desk per agent, alarms) | Runs unchanged; replies reach a model through the `MODELS` binding (the model proxy), which is off, so an agent answers with a short apology | | |
| 117 | 118 | | `services/search` | Rust | Queues (events and its own jobs); FTS5 | Runs unchanged | |
| 464 | 464 | - **Agents read it.** Pages are indexed by `services/context`, so the | |
| 465 | 465 | knowledge reaches every reply, session and plan. A space can be pinned to | |
| 466 | 466 | an agent as required reading. | |
| 467 | − | - **Agents write it.** An agent with write access to a space edits pages | |
| 468 | − | directly. Without it, the edit becomes a **suggestion**: tracked changes | |
| 469 | − | a person accepts or rejects inline, the same review loop as a pull | |
| 470 | − | request. Every edit is attributed and in the page history. | |
| 467 | + | - **Agents write it.** An agent edits pages directly where the space lets | |
| 468 | + | agents edit and the person it acts for can edit. Otherwise the edit | |
| 469 | + | becomes a **suggestion**: tracked changes a person accepts or rejects | |
| 470 | + | inline, the same review loop as a pull request. Every edit is attributed | |
| 471 | + | and in the page history. | |
| 471 | 472 | - **Pages know what they describe.** A page can cite code: paths, symbols, | |
| 472 | 473 | endpoints, environment variables. When a merged pull request changes | |
| 473 | 474 | something a page cites, the page is marked possibly stale and its owners | |
| ⋯ | |||
| 493 | 494 | ||
| 494 | 495 | ### How it is stored | |
| 495 | 496 | ||
| 496 | − | - **Live editing.** Each open page is a Durable Object holding a CRDT | |
| 497 | − | (Yjs), reached over a WebSocket. | |
| 498 | − | - **Durable storage.** On idle, the page is saved as Markdown. Each space | |
| 499 | − | is a git repository in g1t's own git storage, so: | |
| 500 | − | - history, blame and export come free; | |
| 501 | − | - agents can work on a space with the same tools they use on code; | |
| 502 | − | - self-hosted installs keep everything in git. | |
| 503 | − | - **Metadata** lives in `services/docs`' D1: tree, owners, permissions, | |
| 504 | − | citations, staleness, comments. | |
| 497 | + | Built (`services/docs`, contract `packages/contracts/src/docs.ts`): | |
| 498 | + | ||
| 499 | + | - **Live editing.** Each page is a Durable Object (`PageRoom`) that owns | |
| 500 | + | the page's Yjs document, reached over a WebSocket through the site | |
| 501 | + | (`/<workspace>/-/docs/live?page=<id>`), speaking the y-protocols sync | |
| 502 | + | and awareness messages. The editor is BlockNote (MPL-2.0) on that | |
| 503 | + | document. The room keeps the document in its own SQLite storage as a | |
| 504 | + | snapshot plus the updates since, and enforces the socket's role: a | |
| 505 | + | viewer or commenter never changes the document. | |
| 506 | + | - **Everything else in D1** (`g1t-docs`): spaces, members and roles, | |
| 507 | + | linked projects, the page tree, owners, favorites, views, backlinks, | |
| 508 | + | versions, suggestions, templates, files' metadata, and a full-text index | |
| 509 | + | (FTS5) over titles and Markdown. | |
| 510 | + | - **Markdown is derived.** A few seconds after a burst of edits the room | |
| 511 | + | saves the page's Markdown rendition to D1: what search indexes, agents | |
| 512 | + | read, export writes, and the page shows before its editor loads. Agents | |
| 513 | + | write Markdown too; it becomes blocks in the same document, so their | |
| 514 | + | changes merge with whatever people are typing. | |
| 515 | + | - **History** is a version (the Yjs state and its Markdown) at most every | |
| 516 | + | 10 minutes of editing, and for every agent edit, accepted suggestion and | |
| 517 | + | restore, with who made it. Restore copies an old version's blocks in as | |
| 518 | + | a new change. | |
| 519 | + | - **Comments** live in the page's document too (a `threads` map, in the | |
| 520 | + | shape BlockNote's comment UI reads), written only through the service, | |
| 521 | + | which checks the role; a passage's comment is a mark on its text. | |
| 522 | + | - **Files** go to R2 (`g1t-docs-files`) behind a small store interface and | |
| 523 | + | are served from the usercontent origin at `/docs-files/<key>`, 256 | |
| 524 | + | random bits per file. | |
| 525 | + | ||
| 526 | + | Decided: **not git, for now.** Spaces were planned as git repositories. | |
| 527 | + | D1 + Durable Objects ships live collaboration, comments, suggestions and | |
| 528 | + | search without a git write per keystroke burst, and Markdown export (a | |
| 529 | + | page, or a space as a zip in its tree's folders) keeps the content | |
| 530 | + | portable. Repository-backed spaces (a project's `docs/` as a read-only | |
| 531 | + | space, edits as pull requests) remain the next step for repository docs. | |
| 532 | + | For self-hosting, the Durable Object, R2 and D1 sit behind the room, | |
| 533 | + | `FileStore` and SQL; nothing above them depends on Cloudflare. | |
| 505 | 534 | ||
| 535 | + | Not built yet: citations and staleness, "write this up" from a thread, | |
| 536 | + | the documenter agent, repository docs as spaces, `doc.page.*` events and | |
| 537 | + | indexing pages in `services/context`. | |
| 538 | + | ||
| 506 | 539 | ## Chat | |
| 507 | 540 | ||
| 508 | 541 | Channels, direct messages, threads, reactions, read state and the | |
| ⋯ | |||
| 1073 | 1106 | | `services/notify` (new, TS) | One feed per person: live notifications and unread counts over each tab's socket, browser push (VAPID), preferences, presence, status and Do Not Disturb; one presence room per workspace. Durable Object SQLite storage, no D1. | | |
| 1074 | 1107 | | `services/chat` (new, TS) | Channels, members, messages, threads, reactions, read state; one Durable Object per channel for live delivery with WebSocket hibernation. | | |
| 1075 | 1108 | | `services/agents` (new, TS) | Agent definitions and versions, the desk Durable Object per agent, the coordinator Durable Object per workspace (claims), replies (the no-sandbox model loop over g1t MCP). | | |
| 1076 | − | | `services/docs` (new, TS) | Spaces, pages, the page Durable Object (CRDT), suggestions, comments, citations and staleness, git-backed storage. | | |
| 1109 | + | | `services/docs` (new, TS) | Spaces, pages, the page Durable Object (Yjs), history, suggestions, comments, templates, search (FTS5), files (R2); citations and staleness to come. | | |
| 1077 | 1110 | | `services/work` | Tasks and task links beside `agent_runs` (which gains `task_id`); `agent_messages` widened to task addresses. | | |
| 1078 | 1111 | | `services/runner` | Resumable sessions: transcript save and restore in R2, `--resume`, the steer hook reading task threads, claims checked at start and widened from diffs. | | |
| 1079 | 1112 | | `services/context` | Indexes doc pages and channel decisions; serves them to replies and sessions. | | |
| ⋯ | |||
| 1138 | 1171 | assigning work stays out. | |
| 1139 | 1172 | - **Replies without a sandbox.** Chat answers come from a Worker-side model | |
| 1140 | 1173 | loop, not a container. Recommended for speed and cost. | |
| 1141 | − | - **Docs stored in git.** Each space is a git repository, and live editing | |
| 1142 | − | is a CRDT saved to it. | |
| 1143 | − | - **Agent edits to docs** are suggestions unless the agent has write access | |
| 1144 | − | to that space. Recommended default. | |
| 1174 | + | - **Docs stored in git.** Decided against for now: D1 + a Durable Object | |
| 1175 | + | per page, with Markdown export (see "How it is stored"). | |
| 1176 | + | - **Agent edits to docs** are suggestions by default. Built: a space's | |
| 1177 | + | managers can set "Agents in this space" to edit directly, which applies | |
| 1178 | + | only where the person the agent acts for can edit. | |
| 1145 | 1179 | - **Default capacity** of 3 concurrent tasks per agent, and a hop limit | |
| 1146 | 1180 | of 6. | |
| 73 | 73 | "hasInstallScript": true, | |
| 74 | 74 | "license": "MIT", | |
| 75 | 75 | "dependencies": { | |
| 76 | + | "@blocknote/code-block": "^0.55.0", | |
| 77 | + | "@blocknote/core": "^0.55.0", | |
| 78 | + | "@blocknote/react": "^0.55.0", | |
| 79 | + | "@blocknote/shadcn": "^0.55.0", | |
| 76 | 80 | "@g1t/contracts": "*", | |
| 77 | 81 | "@g1t/theme": "*", | |
| 78 | 82 | "class-variance-authority": "^0.7.1", | |
| ⋯ | |||
| 81 | 85 | "hast-util-to-jsx-runtime": "^2.3.6", | |
| 82 | 86 | "html-url-attributes": "^3.0.1", | |
| 83 | 87 | "isbot": "^5.1.36", | |
| 88 | + | "katex": "^0.19.0", | |
| 89 | + | "lib0": "^0.2.119", | |
| 84 | 90 | "lucide-react": "^1.49.0", | |
| 85 | 91 | "mdast-util-find-and-replace": "^3.0.2", | |
| 92 | + | "mermaid": "^12.1.0", | |
| 86 | 93 | "radix-ui": "^1.6.7", | |
| 87 | 94 | "react": "^19.2.8", | |
| 88 | 95 | "react-dom": "^19.2.8", | |
| ⋯ | |||
| 99 | 106 | "unified": "^11.0.5", | |
| 100 | 107 | "unist-util-visit": "^5.1.0", | |
| 101 | 108 | "uqr": "^0.1.3", | |
| 102 | − | "vfile": "^6.0.3" | |
| 109 | + | "vfile": "^6.0.3", | |
| 110 | + | "y-prosemirror": "^1.3.7", | |
| 111 | + | "y-protocols": "^1.0.7", | |
| 112 | + | "yjs": "^13.6.33" | |
| 103 | 113 | }, | |
| 104 | 114 | "devDependencies": { | |
| 105 | 115 | "@cloudflare/vite-plugin": "^1.62.4", | |
| ⋯ | |||
| 114 | 124 | "wrangler": "^4.146.0" | |
| 115 | 125 | } | |
| 116 | 126 | }, | |
| 127 | + | "node_modules/@antfu/install-pkg": { | |
| 128 | + | "version": "2.1.0", | |
| 129 | + | "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", | |
| 130 | + | "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", | |
| 131 | + | "license": "MIT", | |
| 132 | + | "dependencies": { | |
| 133 | + | "package-manager-detector": "^1.8.0", | |
| 134 | + | "tinyexec": "^1.3.1" | |
| 135 | + | }, | |
| 136 | + | "funding": { | |
| 137 | + | "url": "https://github.com/sponsors/antfu" | |
| 138 | + | } | |
| 139 | + | }, | |
| 117 | 140 | "node_modules/@astrojs/compiler-binding": { | |
| 118 | 141 | "version": "0.5.1", | |
| 119 | 142 | "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.1.tgz", | |
| ⋯ | |||
| 684 | 707 | "node": ">=6.0.0" | |
| 685 | 708 | } | |
| 686 | 709 | }, | |
| 710 | + | "node_modules/@babel/runtime": { | |
| 711 | + | "version": "7.29.10", | |
| 712 | + | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", | |
| 713 | + | "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", | |
| 714 | + | "license": "MIT", | |
| 715 | + | "engines": { | |
| 716 | + | "node": ">=6.9.0" | |
| 717 | + | } | |
| 718 | + | }, | |
| 687 | 719 | "node_modules/@babel/template": { | |
| 688 | 720 | "version": "7.29.7", | |
| 689 | 721 | "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", | |
| ⋯ | |||
| 731 | 763 | "node": ">=6.9.0" | |
| 732 | 764 | } | |
| 733 | 765 | }, | |
| 766 | + | "node_modules/@base-ui/react": { | |
| 767 | + | "version": "1.8.0", | |
| 768 | + | "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.8.0.tgz", | |
| 769 | + | "integrity": "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==", | |
| 770 | + | "license": "MIT", | |
| 771 | + | "dependencies": { | |
| 772 | + | "@babel/runtime": "^7.29.7", | |
| 773 | + | "@base-ui/utils": "0.4.0", | |
| 774 | + | "@floating-ui/react-dom": "^2.1.9", | |
| 775 | + | "@floating-ui/utils": "^0.2.12", | |
| 776 | + | "use-sync-external-store": "^1.6.0" | |
| 777 | + | }, | |
| 778 | + | "engines": { | |
| 779 | + | "node": ">=14.0.0" | |
| 780 | + | }, | |
| 781 | + | "funding": { | |
| 782 | + | "type": "opencollective", | |
| 783 | + | "url": "https://opencollective.com/mui-org" | |
| 784 | + | }, | |
| 785 | + | "peerDependencies": { | |
| 786 | + | "@date-fns/tz": "^1.2.0", | |
| 787 | + | "@types/react": "^17 || ^18 || ^19", | |
| 788 | + | "date-fns": "^4.0.0", | |
| 789 | + | "react": "^17 || ^18 || ^19", | |
| 790 | + | "react-dom": "^17 || ^18 || ^19" | |
| 791 | + | }, | |
| 792 | + | "peerDependenciesMeta": { | |
| 793 | + | "@date-fns/tz": { | |
| 794 | + | "optional": true | |
| 795 | + | }, | |
| 796 | + | "@types/react": { | |
| 797 | + | "optional": true | |
| 798 | + | }, | |
| 799 | + | "date-fns": { | |
| 800 | + | "optional": true | |
| 801 | + | } | |
| 802 | + | } | |
| 803 | + | }, | |
| 804 | + | "node_modules/@base-ui/utils": { | |
| 805 | + | "version": "0.4.0", | |
| 806 | + | "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.4.0.tgz", | |
| 807 | + | "integrity": "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==", | |
| 808 | + | "license": "MIT", | |
| 809 | + | "dependencies": { | |
| 810 | + | "@babel/runtime": "^7.29.7", | |
| 811 | + | "@floating-ui/utils": "^0.2.12", | |
| 812 | + | "reselect": "^5.2.0", | |
| 813 | + | "use-sync-external-store": "^1.6.0" | |
| 814 | + | }, | |
| 815 | + | "peerDependencies": { | |
| 816 | + | "@types/react": "^17 || ^18 || ^19", | |
| 817 | + | "react": "^17 || ^18 || ^19", | |
| 818 | + | "react-dom": "^17 || ^18 || ^19" | |
| 819 | + | }, | |
| 820 | + | "peerDependenciesMeta": { | |
| 821 | + | "@types/react": { | |
| 822 | + | "optional": true | |
| 823 | + | } | |
| 824 | + | } | |
| 825 | + | }, | |
| 826 | + | "node_modules/@blocknote/code-block": { | |
| 827 | + | "version": "0.55.0", | |
| 828 | + | "resolved": "https://registry.npmjs.org/@blocknote/code-block/-/code-block-0.55.0.tgz", | |
| 829 | + | "integrity": "sha512-ctdD9YUEuK9nWbLkNcEd8jGdtmUpuSkJ2VP6iXTJm33ryMd1VnsWwaj/I1VNST1s/rMF3QvFq5Z1paIvsi4+zQ==", | |
| 830 | + | "license": "MPL-2.0", | |
| 831 | + | "dependencies": { | |
| 832 | + | "@shikijs/core": "^4.4.3", | |
| 833 | + | "@shikijs/engine-javascript": "^4.4.3", | |
| 834 | + | "@shikijs/langs-precompiled": "^4.4.3", | |
| 835 | + | "@shikijs/themes": "^4.4.3" | |
| 836 | + | }, | |
| 837 | + | "optionalDependencies": { | |
| 838 | + | "@shikijs/types": "^4.4.3" | |
| 839 | + | }, | |
| 840 | + | "peerDependencies": { | |
| 841 | + | "@blocknote/core": "^0.55.0" | |
| 842 | + | } | |
| 843 | + | }, | |
| 844 | + | "node_modules/@blocknote/core": { | |
| 845 | + | "version": "0.55.0", | |
| 846 | + | "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.55.0.tgz", | |
| 847 | + | "integrity": "sha512-QRRnOuVDrt0pPwaCXQ/dc4gxQ3KEGkBC8Hh4hyl4lxOU+5TMLNKKX9gZgeGkq7WKVK2EZbnPea6pcx25aS5SGA==", | |
| 848 | + | "license": "MPL-2.0", | |
| 849 | + | "dependencies": { | |
| 850 | + | "@emoji-mart/data": "^1.2.1", | |
| 851 | + | "@handlewithcare/prosemirror-inputrules": "^0.1.4", | |
| 852 | + | "@shikijs/types": "^4.4.3", | |
| 853 | + | "@tiptap/core": "^3.31.3", | |
| 854 | + | "@tiptap/extension-bold": "^3.31.3", | |
| 855 | + | "@tiptap/extension-code": "^3.31.3", | |
| 856 | + | "@tiptap/extension-italic": "^3.31.3", | |
| 857 | + | "@tiptap/extension-strike": "^3.31.3", | |
| 858 | + | "@tiptap/extension-text": "^3.31.3", | |
| 859 | + | "@tiptap/extension-underline": "^3.31.3", | |
| 860 | + | "@tiptap/extensions": "^3.31.3", | |
| 861 | + | "@tiptap/pm": "^3.31.3", | |
| 862 | + | "emoji-mart": "^5.6.0", | |
| 863 | + | "fast-deep-equal": "^3.1.3", | |
| 864 | + | "lib0": "1.0.0-rc.22", | |
| 865 | + | "prosemirror-highlight": "^0.15.3", | |
| 866 | + | "prosemirror-model": "^1.25.11", | |
| 867 | + | "prosemirror-state": "^1.4.4", | |
| 868 | + | "prosemirror-tables": "^1.8.5", | |
| 869 | + | "prosemirror-transform": "^1.12.1", | |
| 870 | + | "prosemirror-view": "^1.42.4" | |
| 871 | + | }, | |
| 872 | + | "peerDependencies": { | |
| 873 | + | "@y/prosemirror": "^2.0.0-6", | |
| 874 | + | "@y/protocols": "^1.0.6-rc.1", | |
| 875 | + | "@y/y": "^14.0.0-rc.23", | |
| 876 | + | "y-prosemirror": "^1.3.7", | |
| 877 | + | "y-protocols": "^1.0.6", | |
| 878 | + | "yjs": "^13.6.27" | |
| 879 | + | }, | |
| 880 | + | "peerDependenciesMeta": { | |
| 881 | + | "@y/prosemirror": { | |
| 882 | + | "optional": true | |
| 883 | + | }, | |
| 884 | + | "@y/protocols": { | |
| 885 | + | "optional": true | |
| 886 | + | }, | |
| 887 | + | "@y/y": { | |
| 888 | + | "optional": true | |
| 889 | + | }, | |
| 890 | + | "y-prosemirror": { | |
| 891 | + | "optional": true | |
| 892 | + | }, | |
| 893 | + | "y-protocols": { | |
| 894 | + | "optional": true | |
| 895 | + | }, | |
| 896 | + | "yjs": { | |
| 897 | + | "optional": true | |
| 898 | + | } | |
| 899 | + | } | |
| 900 | + | }, | |
| 901 | + | "node_modules/@blocknote/core/node_modules/lib0": { | |
| 902 | + | "version": "1.0.0-rc.22", | |
| 903 | + | "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.22.tgz", | |
| 904 | + | "integrity": "sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==", | |
| 905 | + | "license": "MIT", | |
| 906 | + | "bin": { | |
| 907 | + | "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", | |
| 908 | + | "0gentesthtml": "src/bin/gentesthtml.js", | |
| 909 | + | "0serve": "src/bin/0serve.js" | |
| 910 | + | }, | |
| 911 | + | "engines": { | |
| 912 | + | "node": ">=22" | |
| 913 | + | }, | |
| 914 | + | "funding": { | |
| 915 | + | "type": "GitHub Sponsors ❤", | |
| 916 | + | "url": "https://github.com/sponsors/dmonad" | |
| 917 | + | } | |
| 918 | + | }, | |
| 919 | + | "node_modules/@blocknote/react": { | |
| 920 | + | "version": "0.55.0", | |
| 921 | + | "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.55.0.tgz", | |
| 922 | + | "integrity": "sha512-t+4N8+3xHhlnesxpWhF3FZnoHcnwhO+VgHiz6WEx7kTOTgk2YH84EOD7FsPh/hCm3I9MH9cHuiMfo6W6PHj/9w==", | |
| 923 | + | "license": "MPL-2.0", | |
| 924 | + | "dependencies": { | |
| 925 | + | "@blocknote/core": "^0.55.0", | |
| 926 | + | "@emoji-mart/data": "^1.2.1", | |
| 927 | + | "@floating-ui/react": "^0.27.18", | |
| 928 | + | "@tiptap/core": "^3.31.3", | |
| 929 | + | "@tiptap/pm": "^3.31.3", | |
| 930 | + | "@tiptap/react": "^3.31.3", | |
| 931 | + | "emoji-mart": "^5.6.0", | |
| 932 | + | "fast-deep-equal": "^3.1.3", | |
| 933 | + | "use-sync-external-store": "1.6.0" | |
| 934 | + | }, | |
| 935 | + | "optionalDependencies": { | |
| 936 | + | "@types/use-sync-external-store": "1.5.0" | |
| 937 | + | }, | |
| 938 | + | "peerDependencies": { | |
| 939 | + | "react": "^18.0 || ^19.0 || >= 19.0.0-rc", | |
| 940 | + | "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" | |
| 941 | + | } | |
| 942 | + | }, | |
| 943 | + | "node_modules/@blocknote/shadcn": { | |
| 944 | + | "version": "0.55.0", | |
| 945 | + | "resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.55.0.tgz", | |
| 946 | + | "integrity": "sha512-r00Rijcn0Gb9btkjEf1ojthQuJ5hxsHMRo/ljrk3xIlC7hlskEyR9/50E1Su9CIYTf61nzebTtHUZiXYyNx59A==", | |
| 947 | + | "license": "MPL-2.0", | |
| 948 | + | "dependencies": { | |
| 949 | + | "@base-ui/react": "^1.6.0", | |
| 950 | + | "@blocknote/core": "^0.55.0", | |
| 951 | + | "@blocknote/react": "^0.55.0", | |
| 952 | + | "class-variance-authority": "^0.7.1", | |
| 953 | + | "clsx": "^2.1.1", | |
| 954 | + | "lucide-react": "^0.525.0", | |
| 955 | + | "tailwind-merge": "^2.6.0" | |
| 956 | + | }, | |
| 957 | + | "peerDependencies": { | |
| 958 | + | "react": "^18.0 || ^19.0 || >= 19.0.0-rc", | |
| 959 | + | "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", | |
| 960 | + | "tailwindcss": "^4.1.12" | |
| 961 | + | } | |
| 962 | + | }, | |
| 963 | + | "node_modules/@blocknote/shadcn/node_modules/lucide-react": { | |
| 964 | + | "version": "0.525.0", | |
| 965 | + | "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", | |
| 966 | + | "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", | |
| 967 | + | "license": "ISC", | |
| 968 | + | "peerDependencies": { | |
| 969 | + | "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" | |
| 970 | + | } | |
| 971 | + | }, | |
| 972 | + | "node_modules/@blocknote/shadcn/node_modules/tailwind-merge": { | |
| 973 | + | "version": "2.6.1", | |
| 974 | + | "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", | |
| 975 | + | "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", | |
| 976 | + | "license": "MIT", | |
| 977 | + | "funding": { | |
| 978 | + | "type": "github", | |
| 979 | + | "url": "https://github.com/sponsors/dcastil" | |
| 980 | + | } | |
| 981 | + | }, | |
| 982 | + | "node_modules/@braintree/sanitize-url": { | |
| 983 | + | "version": "7.1.2", | |
| 984 | + | "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", | |
| 985 | + | "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", | |
| 986 | + | "license": "MIT" | |
| 987 | + | }, | |
| 734 | 988 | "node_modules/@bruits/satteri-darwin-arm64": { | |
| 735 | 989 | "version": "0.10.5", | |
| 736 | 990 | "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", | |
| ⋯ | |||
| 908 | 1162 | "node": ">=18" | |
| 909 | 1163 | } | |
| 910 | 1164 | }, | |
| 1165 | + | "node_modules/@chevrotain/cst-dts-gen": { | |
| 1166 | + | "version": "13.2.0", | |
| 1167 | + | "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", | |
| 1168 | + | "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", | |
| 1169 | + | "license": "Apache-2.0", | |
| 1170 | + | "dependencies": { | |
| 1171 | + | "@chevrotain/gast": "13.2.0", | |
| 1172 | + | "@chevrotain/types": "13.2.0" | |
| 1173 | + | } | |
| 1174 | + | }, | |
| 1175 | + | "node_modules/@chevrotain/gast": { | |
| 1176 | + | "version": "13.2.0", | |
| 1177 | + | "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", | |
| 1178 | + | "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", | |
| 1179 | + | "license": "Apache-2.0", | |
| 1180 | + | "dependencies": { | |
| 1181 | + | "@chevrotain/types": "13.2.0" | |
| 1182 | + | } | |
| 1183 | + | }, | |
| 1184 | + | "node_modules/@chevrotain/regexp-to-ast": { | |
| 1185 | + | "version": "13.2.0", | |
| 1186 | + | "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", | |
| 1187 | + | "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", | |
| 1188 | + | "license": "Apache-2.0" | |
| 1189 | + | }, | |
| 1190 | + | "node_modules/@chevrotain/types": { | |
| 1191 | + | "version": "13.2.0", | |
| 1192 | + | "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", | |
| 1193 | + | "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", | |
| 1194 | + | "license": "Apache-2.0" | |
| 1195 | + | }, | |
| 1196 | + | "node_modules/@chevrotain/utils": { | |
| 1197 | + | "version": "13.2.0", | |
| 1198 | + | "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", | |
| 1199 | + | "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", | |
| 1200 | + | "license": "Apache-2.0" | |
| 1201 | + | }, | |
| 911 | 1202 | "node_modules/@clack/core": { | |
| 912 | 1203 | "version": "1.5.1", | |
| 913 | 1204 | "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", | |
| ⋯ | |||
| 1143 | 1434 | "tslib": "^2.4.0" | |
| 1144 | 1435 | } | |
| 1145 | 1436 | }, | |
| 1437 | + | "node_modules/@emoji-mart/data": { | |
| 1438 | + | "version": "1.2.1", | |
| 1439 | + | "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", | |
| 1440 | + | "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", | |
| 1441 | + | "license": "MIT" | |
| 1442 | + | }, | |
| 1146 | 1443 | "node_modules/@esbuild/aix-ppc64": { | |
| 1147 | 1444 | "version": "0.28.1", | |
| 1148 | 1445 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", | |
| ⋯ | |||
| 1623 | 1920 | "@floating-ui/utils": "^0.2.12" | |
| 1624 | 1921 | } | |
| 1625 | 1922 | }, | |
| 1923 | + | "node_modules/@floating-ui/react": { | |
| 1924 | + | "version": "0.27.20", | |
| 1925 | + | "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", | |
| 1926 | + | "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", | |
| 1927 | + | "license": "MIT", | |
| 1928 | + | "dependencies": { | |
| 1929 | + | "@floating-ui/react-dom": "^2.1.9", | |
| 1930 | + | "@floating-ui/utils": "^0.2.12", | |
| 1931 | + | "tabbable": "^6.0.0" | |
| 1932 | + | }, | |
| 1933 | + | "peerDependencies": { | |
| 1934 | + | "react": ">=17.0.0", | |
| 1935 | + | "react-dom": ">=17.0.0" | |
| 1936 | + | } | |
| 1937 | + | }, | |
| 1626 | 1938 | "node_modules/@floating-ui/react-dom": { | |
| 1627 | 1939 | "version": "2.1.9", | |
| 1628 | 1940 | "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", | |
| ⋯ | |||
| 1666 | 1978 | "resolved": "apps/docs", | |
| 1667 | 1979 | "link": true | |
| 1668 | 1980 | }, | |
| 1981 | + | "node_modules/@g1t/docs-service": { | |
| 1982 | + | "resolved": "services/docs", | |
| 1983 | + | "link": true | |
| 1984 | + | }, | |
| 1669 | 1985 | "node_modules/@g1t/models": { | |
| 1670 | 1986 | "resolved": "services/models", | |
| 1671 | 1987 | "link": true | |
| ⋯ | |||
| 1706 | 2022 | "resolved": "apps/web", | |
| 1707 | 2023 | "link": true | |
| 1708 | 2024 | }, | |
| 2025 | + | "node_modules/@handlewithcare/prosemirror-inputrules": { | |
| 2026 | + | "version": "0.1.4", | |
| 2027 | + | "resolved": "https://registry.npmjs.org/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz", | |
| 2028 | + | "integrity": "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==", | |
| 2029 | + | "license": "MIT", | |
| 2030 | + | "dependencies": { | |
| 2031 | + | "prosemirror-history": "^1.4.1", | |
| 2032 | + | "prosemirror-transform": "^1.0.0" | |
| 2033 | + | }, | |
| 2034 | + | "peerDependencies": { | |
| 2035 | + | "prosemirror-model": "^1.0.0", | |
| 2036 | + | "prosemirror-state": "^1.0.0", | |
| 2037 | + | "prosemirror-view": "^1.0.0" | |
| 2038 | + | } | |
| 2039 | + | }, | |
| 2040 | + | "node_modules/@iconify/types": { | |
| 2041 | + | "version": "2.0.0", | |
| 2042 | + | "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", | |
| 2043 | + | "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", | |
| 2044 | + | "license": "MIT" | |
| 2045 | + | }, | |
| 2046 | + | "node_modules/@iconify/utils": { | |
| 2047 | + | "version": "3.1.7", | |
| 2048 | + | "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", | |
| 2049 | + | "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", | |
| 2050 | + | "license": "MIT", | |
| 2051 | + | "dependencies": { | |
| 2052 | + | "@antfu/install-pkg": "^2.0.1", | |
| 2053 | + | "@iconify/types": "^2.0.0", | |
| 2054 | + | "import-meta-resolve": "^4.2.0" | |
| 2055 | + | } | |
| 2056 | + | }, | |
| 1709 | 2057 | "node_modules/@img/colour": { | |
| 1710 | 2058 | "version": "1.1.0", | |
| 1711 | 2059 | "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", | |
| ⋯ | |||
| 2334 | 2682 | "astro": "^4 || ^5 || ^6 || ^7" | |
| 2335 | 2683 | } | |
| 2336 | 2684 | }, | |
| 2685 | + | "node_modules/@mermaid-js/parser": { | |
| 2686 | + | "version": "2.0.1", | |
| 2687 | + | "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", | |
| 2688 | + | "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", | |
| 2689 | + | "license": "MIT", | |
| 2690 | + | "dependencies": { | |
| 2691 | + | "@chevrotain/types": "~13.2.0" | |
| 2692 | + | }, | |
| 2693 | + | "engines": { | |
| 2694 | + | "node": ">=22.12.0" | |
| 2695 | + | } | |
| 2696 | + | }, | |
| 2337 | 2697 | "node_modules/@napi-rs/wasm-runtime": { | |
| 2338 | 2698 | "version": "1.2.4", | |
| 2339 | 2699 | "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", | |
| ⋯ | |||
| 4441 | 4801 | "node": ">=20" | |
| 4442 | 4802 | } | |
| 4443 | 4803 | }, | |
| 4804 | + | "node_modules/@shikijs/langs-precompiled": { | |
| 4805 | + | "version": "4.5.0", | |
| 4806 | + | "resolved": "https://registry.npmjs.org/@shikijs/langs-precompiled/-/langs-precompiled-4.5.0.tgz", | |
| 4807 | + | "integrity": "sha512-l6x3sNA4qvSQPtFogjhVj9IjjYhQ6Z4zy+E2vP5/PteFYCtT7GR7xZIjUT8QQKgA7k6glMDQXI0265qmIkJXLg==", | |
| 4808 | + | "license": "MIT", | |
| 4809 | + | "dependencies": { | |
| 4810 | + | "@shikijs/types": "4.5.0", | |
| 4811 | + | "oniguruma-to-es": "^4.3.6" | |
| 4812 | + | }, | |
| 4813 | + | "engines": { | |
| 4814 | + | "node": ">=20" | |
| 4815 | + | } | |
| 4816 | + | }, | |
| 4444 | 4817 | "node_modules/@shikijs/primitive": { | |
| 4445 | 4818 | "version": "4.5.0", | |
| 4446 | 4819 | "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", | |
| ⋯ | |||
| 4806 | 5179 | "vite": "^5.2.0 || ^6 || ^7 || ^8" | |
| 4807 | 5180 | } | |
| 4808 | 5181 | }, | |
| 5182 | + | "node_modules/@tiptap/core": { | |
| 5183 | + | "version": "3.31.4", | |
| 5184 | + | "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz", | |
| 5185 | + | "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==", | |
| 5186 | + | "license": "MIT", | |
| 5187 | + | "funding": { | |
| 5188 | + | "type": "github", | |
| 5189 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5190 | + | }, | |
| 5191 | + | "peerDependencies": { | |
| 5192 | + | "@tiptap/pm": "3.31.4" | |
| 5193 | + | } | |
| 5194 | + | }, | |
| 5195 | + | "node_modules/@tiptap/extension-bold": { | |
| 5196 | + | "version": "3.31.4", | |
| 5197 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz", | |
| 5198 | + | "integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==", | |
| 5199 | + | "license": "MIT", | |
| 5200 | + | "funding": { | |
| 5201 | + | "type": "github", | |
| 5202 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5203 | + | }, | |
| 5204 | + | "peerDependencies": { | |
| 5205 | + | "@tiptap/core": "3.31.4" | |
| 5206 | + | } | |
| 5207 | + | }, | |
| 5208 | + | "node_modules/@tiptap/extension-bubble-menu": { | |
| 5209 | + | "version": "3.31.4", | |
| 5210 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.4.tgz", | |
| 5211 | + | "integrity": "sha512-Ml/yswuys+3QkOMajfXyAGlUKCxEbT3dMyF8vHwQ5mDk9MDm+dqp10It5CrDBkUT/KkVTa6AJI3kb0TXZJZiAA==", | |
| 5212 | + | "license": "MIT", | |
| 5213 | + | "optional": true, | |
| 5214 | + | "dependencies": { | |
| 5215 | + | "@floating-ui/dom": "^1.0.0" | |
| 5216 | + | }, | |
| 5217 | + | "funding": { | |
| 5218 | + | "type": "github", | |
| 5219 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5220 | + | }, | |
| 5221 | + | "peerDependencies": { | |
| 5222 | + | "@tiptap/core": "3.31.4", | |
| 5223 | + | "@tiptap/pm": "3.31.4" | |
| 5224 | + | } | |
| 5225 | + | }, | |
| 5226 | + | "node_modules/@tiptap/extension-code": { | |
| 5227 | + | "version": "3.31.4", | |
| 5228 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz", | |
| 5229 | + | "integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==", | |
| 5230 | + | "license": "MIT", | |
| 5231 | + | "funding": { | |
| 5232 | + | "type": "github", | |
| 5233 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5234 | + | }, | |
| 5235 | + | "peerDependencies": { | |
| 5236 | + | "@tiptap/core": "3.31.4" | |
| 5237 | + | } | |
| 5238 | + | }, | |
| 5239 | + | "node_modules/@tiptap/extension-floating-menu": { | |
| 5240 | + | "version": "3.31.4", | |
| 5241 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz", | |
| 5242 | + | "integrity": "sha512-pKzulkDmolpVt7IP9FSPsC5pKbXQ5pyeqkaAYr8cyOVW+KUObpPRIzAuXvEso8PCjWgwJ1f2qyishOEMw8MbfQ==", | |
| 5243 | + | "license": "MIT", | |
| 5244 | + | "optional": true, | |
| 5245 | + | "funding": { | |
| 5246 | + | "type": "github", | |
| 5247 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5248 | + | }, | |
| 5249 | + | "peerDependencies": { | |
| 5250 | + | "@floating-ui/dom": "^1.0.0", | |
| 5251 | + | "@tiptap/core": "3.31.4", | |
| 5252 | + | "@tiptap/pm": "3.31.4" | |
| 5253 | + | } | |
| 5254 | + | }, | |
| 5255 | + | "node_modules/@tiptap/extension-italic": { | |
| 5256 | + | "version": "3.31.4", | |
| 5257 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", | |
| 5258 | + | "integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==", | |
| 5259 | + | "license": "MIT", | |
| 5260 | + | "funding": { | |
| 5261 | + | "type": "github", | |
| 5262 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5263 | + | }, | |
| 5264 | + | "peerDependencies": { | |
| 5265 | + | "@tiptap/core": "3.31.4" | |
| 5266 | + | } | |
| 5267 | + | }, | |
| 5268 | + | "node_modules/@tiptap/extension-strike": { | |
| 5269 | + | "version": "3.31.4", | |
| 5270 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz", | |
| 5271 | + | "integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==", | |
| 5272 | + | "license": "MIT", | |
| 5273 | + | "funding": { | |
| 5274 | + | "type": "github", | |
| 5275 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5276 | + | }, | |
| 5277 | + | "peerDependencies": { | |
| 5278 | + | "@tiptap/core": "3.31.4" | |
| 5279 | + | } | |
| 5280 | + | }, | |
| 5281 | + | "node_modules/@tiptap/extension-text": { | |
| 5282 | + | "version": "3.31.4", | |
| 5283 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz", | |
| 5284 | + | "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==", | |
| 5285 | + | "license": "MIT", | |
| 5286 | + | "funding": { | |
| 5287 | + | "type": "github", | |
| 5288 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5289 | + | }, | |
| 5290 | + | "peerDependencies": { | |
| 5291 | + | "@tiptap/core": "3.31.4" | |
| 5292 | + | } | |
| 5293 | + | }, | |
| 5294 | + | "node_modules/@tiptap/extension-underline": { | |
| 5295 | + | "version": "3.31.4", | |
| 5296 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz", | |
| 5297 | + | "integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==", | |
| 5298 | + | "license": "MIT", | |
| 5299 | + | "funding": { | |
| 5300 | + | "type": "github", | |
| 5301 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5302 | + | }, | |
| 5303 | + | "peerDependencies": { | |
| 5304 | + | "@tiptap/core": "3.31.4" | |
| 5305 | + | } | |
| 5306 | + | }, | |
| 5307 | + | "node_modules/@tiptap/extensions": { | |
| 5308 | + | "version": "3.31.4", | |
| 5309 | + | "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz", | |
| 5310 | + | "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==", | |
| 5311 | + | "license": "MIT", | |
| 5312 | + | "funding": { | |
| 5313 | + | "type": "github", | |
| 5314 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5315 | + | }, | |
| 5316 | + | "peerDependencies": { | |
| 5317 | + | "@tiptap/core": "3.31.4", | |
| 5318 | + | "@tiptap/pm": "3.31.4" | |
| 5319 | + | } | |
| 5320 | + | }, | |
| 5321 | + | "node_modules/@tiptap/pm": { | |
| 5322 | + | "version": "3.31.4", | |
| 5323 | + | "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz", | |
| 5324 | + | "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==", | |
| 5325 | + | "license": "MIT", | |
| 5326 | + | "dependencies": { | |
| 5327 | + | "prosemirror-changeset": "^2.4.1", | |
| 5328 | + | "prosemirror-commands": "^1.7.1", | |
| 5329 | + | "prosemirror-dropcursor": "^1.8.2", | |
| 5330 | + | "prosemirror-gapcursor": "^1.4.1", | |
| 5331 | + | "prosemirror-history": "^1.5.0", | |
| 5332 | + | "prosemirror-inputrules": "^1.5.1", | |
| 5333 | + | "prosemirror-keymap": "^1.2.3", | |
| 5334 | + | "prosemirror-model": "^1.25.11", | |
| 5335 | + | "prosemirror-schema-list": "^1.5.1", | |
| 5336 | + | "prosemirror-state": "^1.4.4", | |
| 5337 | + | "prosemirror-tables": "^1.8.5", | |
| 5338 | + | "prosemirror-transform": "^1.12.0", | |
| 5339 | + | "prosemirror-view": "^1.42.3" | |
| 5340 | + | }, | |
| 5341 | + | "funding": { | |
| 5342 | + | "type": "github", | |
| 5343 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5344 | + | } | |
| 5345 | + | }, | |
| 5346 | + | "node_modules/@tiptap/react": { | |
| 5347 | + | "version": "3.31.4", | |
| 5348 | + | "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.4.tgz", | |
| 5349 | + | "integrity": "sha512-mBvvXgdPhMinT1NPfwHvFK1dFdMXHVWMSeNn0IPj7/zV0XBmOnASkEOwoIvl8NluKYEtpZBE1yA+6pN2enLa5g==", | |
| 5350 | + | "license": "MIT", | |
| 5351 | + | "dependencies": { | |
| 5352 | + | "@types/use-sync-external-store": "^0.0.6", | |
| 5353 | + | "fast-equals": "^5.3.3", | |
| 5354 | + | "use-sync-external-store": "^1.4.0" | |
| 5355 | + | }, | |
| 5356 | + | "funding": { | |
| 5357 | + | "type": "github", | |
| 5358 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5359 | + | }, | |
| 5360 | + | "optionalDependencies": { | |
| 5361 | + | "@tiptap/extension-bubble-menu": "3.31.4", | |
| 5362 | + | "@tiptap/extension-floating-menu": "3.31.4" | |
| 5363 | + | }, | |
| 5364 | + | "peerDependencies": { | |
| 5365 | + | "@tiptap/core": "3.31.4", | |
| 5366 | + | "@tiptap/pm": "3.31.4", | |
| 5367 | + | "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", | |
| 5368 | + | "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", | |
| 5369 | + | "react": "^17.0.0 || ^18.0.0 || ^19.0.0", | |
| 5370 | + | "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" | |
| 5371 | + | } | |
| 5372 | + | }, | |
| 5373 | + | "node_modules/@tiptap/react/node_modules/@types/use-sync-external-store": { | |
| 5374 | + | "version": "0.0.6", | |
| 5375 | + | "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", | |
| 5376 | + | "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", | |
| 5377 | + | "license": "MIT" | |
| 5378 | + | }, | |
| 4809 | 5379 | "node_modules/@tootallnate/quickjs-emscripten": { | |
| 4810 | 5380 | "version": "0.23.0", | |
| 4811 | 5381 | "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", | |
| ⋯ | |||
| 4822 | 5392 | "tslib": "^2.4.0" | |
| 4823 | 5393 | } | |
| 4824 | 5394 | }, | |
| 5395 | + | "node_modules/@types/d3": { | |
| 5396 | + | "version": "7.4.3", | |
| 5397 | + | "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", | |
| 5398 | + | "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", | |
| 5399 | + | "license": "MIT", | |
| 5400 | + | "dependencies": { | |
| 5401 | + | "@types/d3-array": "*", | |
| 5402 | + | "@types/d3-axis": "*", | |
| 5403 | + | "@types/d3-brush": "*", | |
| 5404 | + | "@types/d3-chord": "*", | |
| 5405 | + | "@types/d3-color": "*", | |
| 5406 | + | "@types/d3-contour": "*", | |
| 5407 | + | "@types/d3-delaunay": "*", | |
| 5408 | + | "@types/d3-dispatch": "*", | |
| 5409 | + | "@types/d3-drag": "*", | |
| 5410 | + | "@types/d3-dsv": "*", | |
| 5411 | + | "@types/d3-ease": "*", | |
| 5412 | + | "@types/d3-fetch": "*", | |
| 5413 | + | "@types/d3-force": "*", | |
| 5414 | + | "@types/d3-format": "*", | |
| 5415 | + | "@types/d3-geo": "*", | |
| 5416 | + | "@types/d3-hierarchy": "*", | |
| 5417 | + | "@types/d3-interpolate": "*", | |
| 5418 | + | "@types/d3-path": "*", | |
| 5419 | + | "@types/d3-polygon": "*", | |
| 5420 | + | "@types/d3-quadtree": "*", | |
| 5421 | + | "@types/d3-random": "*", | |
| 5422 | + | "@types/d3-scale": "*", | |
| 5423 | + | "@types/d3-scale-chromatic": "*", | |
| 5424 | + | "@types/d3-selection": "*", | |
| 5425 | + | "@types/d3-shape": "*", | |
| 5426 | + | "@types/d3-time": "*", | |
| 5427 | + | "@types/d3-time-format": "*", | |
| 5428 | + | "@types/d3-timer": "*", | |
| 5429 | + | "@types/d3-transition": "*", | |
| 5430 | + | "@types/d3-zoom": "*" | |
| 5431 | + | } | |
| 5432 | + | }, | |
| 5433 | + | "node_modules/@types/d3-array": { | |
| 5434 | + | "version": "3.2.2", | |
| 5435 | + | "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", | |
| 5436 | + | "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", | |
| 5437 | + | "license": "MIT" | |
| 5438 | + | }, | |
| 5439 | + | "node_modules/@types/d3-axis": { | |
| 5440 | + | "version": "3.0.6", | |
| 5441 | + | "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", | |
| 5442 | + | "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", | |
| 5443 | + | "license": "MIT", | |
| 5444 | + | "dependencies": { | |
| 5445 | + | "@types/d3-selection": "*" | |
| 5446 | + | } | |
| 5447 | + | }, | |
| 5448 | + | "node_modules/@types/d3-brush": { | |
| 5449 | + | "version": "3.0.6", | |
| 5450 | + | "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", | |
| 5451 | + | "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", | |
| 5452 | + | "license": "MIT", | |
| 5453 | + | "dependencies": { | |
| 5454 | + | "@types/d3-selection": "*" | |
| 5455 | + | } | |
| 5456 | + | }, | |
| 5457 | + | "node_modules/@types/d3-chord": { | |
| 5458 | + | "version": "3.0.6", | |
| 5459 | + | "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", | |
| 5460 | + | "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", | |
| 5461 | + | "license": "MIT" | |
| 5462 | + | }, | |
| 5463 | + | "node_modules/@types/d3-color": { | |
| 5464 | + | "version": "3.1.3", | |
| 5465 | + | "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", | |
| 5466 | + | "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", | |
| 5467 | + | "license": "MIT" | |
| 5468 | + | }, | |
| 5469 | + | "node_modules/@types/d3-contour": { | |
| 5470 | + | "version": "3.0.6", | |
| 5471 | + | "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", | |
| 5472 | + | "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", | |
| 5473 | + | "license": "MIT", | |
| 5474 | + | "dependencies": { | |
| 5475 | + | "@types/d3-array": "*", | |
| 5476 | + | "@types/geojson": "*" | |
| 5477 | + | } | |
| 5478 | + | }, | |
| 5479 | + | "node_modules/@types/d3-delaunay": { | |
| 5480 | + | "version": "6.0.4", | |
| 5481 | + | "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", | |
| 5482 | + | "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", | |
| 5483 | + | "license": "MIT" | |
| 5484 | + | }, | |
| 5485 | + | "node_modules/@types/d3-dispatch": { | |
| 5486 | + | "version": "3.0.7", | |
| 5487 | + | "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", | |
| 5488 | + | "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", | |
| 5489 | + | "license": "MIT" | |
| 5490 | + | }, | |
| 5491 | + | "node_modules/@types/d3-drag": { | |
| 5492 | + | "version": "3.0.7", | |
| 5493 | + | "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", | |
| 5494 | + | "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", | |
| 5495 | + | "license": "MIT", | |
| 5496 | + | "dependencies": { | |
| 5497 | + | "@types/d3-selection": "*" | |
| 5498 | + | } | |
| 5499 | + | }, | |
| 5500 | + | "node_modules/@types/d3-dsv": { | |
| 5501 | + | "version": "3.0.7", | |
| 5502 | + | "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", | |
| 5503 | + | "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", | |
| 5504 | + | "license": "MIT" | |
| 5505 | + | }, | |
| 5506 | + | "node_modules/@types/d3-ease": { | |
| 5507 | + | "version": "3.0.2", | |
| 5508 | + | "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", | |
| 5509 | + | "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", | |
| 5510 | + | "license": "MIT" | |
| 5511 | + | }, | |
| 5512 | + | "node_modules/@types/d3-fetch": { | |
| 5513 | + | "version": "3.0.7", | |
| 5514 | + | "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", | |
| 5515 | + | "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", | |
| 5516 | + | "license": "MIT", | |
| 5517 | + | "dependencies": { | |
| 5518 | + | "@types/d3-dsv": "*" | |
| 5519 | + | } | |
| 5520 | + | }, | |
| 5521 | + | "node_modules/@types/d3-force": { | |
| 5522 | + | "version": "3.0.10", | |
| 5523 | + | "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", | |
| 5524 | + | "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", | |
| 5525 | + | "license": "MIT" | |
| 5526 | + | }, | |
| 5527 | + | "node_modules/@types/d3-format": { | |
| 5528 | + | "version": "3.0.4", | |
| 5529 | + | "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", | |
| 5530 | + | "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", | |
| 5531 | + | "license": "MIT" | |
| 5532 | + | }, | |
| 5533 | + | "node_modules/@types/d3-geo": { | |
| 5534 | + | "version": "3.1.1", | |
| 5535 | + | "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", | |
| 5536 | + | "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", | |
| 5537 | + | "license": "MIT", | |
| 5538 | + | "dependencies": { | |
| 5539 | + | "@types/geojson": "*" | |
| 5540 | + | } | |
| 5541 | + | }, | |
| 5542 | + | "node_modules/@types/d3-hierarchy": { | |
| 5543 | + | "version": "3.1.7", | |
| 5544 | + | "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", | |
| 5545 | + | "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", | |
| 5546 | + | "license": "MIT" | |
| 5547 | + | }, | |
| 5548 | + | "node_modules/@types/d3-interpolate": { | |
| 5549 | + | "version": "3.0.4", | |
| 5550 | + | "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", | |
| 5551 | + | "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", | |
| 5552 | + | "license": "MIT", | |
| 5553 | + | "dependencies": { | |
| 5554 | + | "@types/d3-color": "*" | |
| 5555 | + | } | |
| 5556 | + | }, | |
| 5557 | + | "node_modules/@types/d3-path": { | |
| 5558 | + | "version": "3.1.1", | |
| 5559 | + | "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", | |
| 5560 | + | "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", | |
| 5561 | + | "license": "MIT" | |
| 5562 | + | }, | |
| 5563 | + | "node_modules/@types/d3-polygon": { | |
| 5564 | + | "version": "3.0.2", | |
| 5565 | + | "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", | |
| 5566 | + | "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", | |
| 5567 | + | "license": "MIT" | |
| 5568 | + | }, | |
| 5569 | + | "node_modules/@types/d3-quadtree": { | |
| 5570 | + | "version": "3.0.6", | |
| 5571 | + | "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", | |
| 5572 | + | "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", | |
| 5573 | + | "license": "MIT" | |
| 5574 | + | }, | |
| 5575 | + | "node_modules/@types/d3-random": { | |
| 5576 | + | "version": "3.0.4", | |
| 5577 | + | "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", | |
| 5578 | + | "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", | |
| 5579 | + | "license": "MIT" | |
| 5580 | + | }, | |
| 5581 | + | "node_modules/@types/d3-scale": { | |
| 5582 | + | "version": "4.0.9", | |
| 5583 | + | "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", | |
| 5584 | + | "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", | |
| 5585 | + | "license": "MIT", | |
| 5586 | + | "dependencies": { | |
| 5587 | + | "@types/d3-time": "*" | |
| 5588 | + | } | |
| 5589 | + | }, | |
| 5590 | + | "node_modules/@types/d3-scale-chromatic": { | |
| 5591 | + | "version": "3.1.0", | |
| 5592 | + | "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", | |
| 5593 | + | "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", | |
| 5594 | + | "license": "MIT" | |
| 5595 | + | }, | |
| 5596 | + | "node_modules/@types/d3-selection": { | |
| 5597 | + | "version": "3.0.12", | |
| 5598 | + | "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", | |
| 5599 | + | "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", | |
| 5600 | + | "license": "MIT" | |
| 5601 | + | }, | |
| 5602 | + | "node_modules/@types/d3-shape": { | |
| 5603 | + | "version": "3.2.0", | |
| 5604 | + | "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", | |
| 5605 | + | "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", | |
| 5606 | + | "license": "MIT", | |
| 5607 | + | "dependencies": { | |
| 5608 | + | "@types/d3-path": "*" | |
| 5609 | + | } | |
| 5610 | + | }, | |
| 5611 | + | "node_modules/@types/d3-time": { | |
| 5612 | + | "version": "3.0.4", | |
| 5613 | + | "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", | |
| 5614 | + | "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", | |
| 5615 | + | "license": "MIT" | |
| 5616 | + | }, | |
| 5617 | + | "node_modules/@types/d3-time-format": { | |
| 5618 | + | "version": "4.0.3", | |
| 5619 | + | "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", | |
| 5620 | + | "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", | |
| 5621 | + | "license": "MIT" | |
| 5622 | + | }, | |
| 5623 | + | "node_modules/@types/d3-timer": { | |
| 5624 | + | "version": "3.0.2", | |
| 5625 | + | "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", | |
| 5626 | + | "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", | |
| 5627 | + | "license": "MIT" | |
| 5628 | + | }, | |
| 5629 | + | "node_modules/@types/d3-transition": { | |
| 5630 | + | "version": "3.0.9", | |
| 5631 | + | "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", | |
| 5632 | + | "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", | |
| 5633 | + | "license": "MIT", | |
| 5634 | + | "dependencies": { | |
| 5635 | + | "@types/d3-selection": "*" | |
| 5636 | + | } | |
| 5637 | + | }, | |
| 5638 | + | "node_modules/@types/d3-zoom": { | |
| 5639 | + | "version": "3.0.9", | |
| 5640 | + | "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", | |
| 5641 | + | "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", | |
| 5642 | + | "license": "MIT", | |
| 5643 | + | "dependencies": { | |
| 5644 | + | "@types/d3-interpolate": "*", | |
| 5645 | + | "@types/d3-selection": "*" | |
| 5646 | + | } | |
| 5647 | + | }, | |
| 4825 | 5648 | "node_modules/@types/debug": { | |
| 4826 | 5649 | "version": "4.1.13", | |
| 4827 | 5650 | "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", | |
| ⋯ | |||
| 4846 | 5669 | "@types/estree": "*" | |
| 4847 | 5670 | } | |
| 4848 | 5671 | }, | |
| 5672 | + | "node_modules/@types/geojson": { | |
| 5673 | + | "version": "7946.0.16", | |
| 5674 | + | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", | |
| 5675 | + | "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", | |
| 5676 | + | "license": "MIT" | |
| 5677 | + | }, | |
| 4849 | 5678 | "node_modules/@types/hast": { | |
| 4850 | 5679 | "version": "3.0.5", | |
| 4851 | 5680 | "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", | |
| ⋯ | |||
| 4907 | 5736 | "version": "19.3.0", | |
| 4908 | 5737 | "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", | |
| 4909 | 5738 | "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", | |
| 4910 | − | "devOptional": true, | |
| 4911 | 5739 | "license": "MIT", | |
| 4912 | 5740 | "peerDependencies": { | |
| 4913 | 5741 | "@types/react": "^19.3.0" | |
| ⋯ | |||
| 4922 | 5750 | "@types/node": "*" | |
| 4923 | 5751 | } | |
| 4924 | 5752 | }, | |
| 5753 | + | "node_modules/@types/trusted-types": { | |
| 5754 | + | "version": "2.0.7", | |
| 5755 | + | "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", | |
| 5756 | + | "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", | |
| 5757 | + | "license": "MIT", | |
| 5758 | + | "optional": true | |
| 5759 | + | }, | |
| 4925 | 5760 | "node_modules/@types/unist": { | |
| 4926 | 5761 | "version": "3.0.3", | |
| 4927 | 5762 | "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", | |
| 4928 | 5763 | "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", | |
| 4929 | 5764 | "license": "MIT" | |
| 4930 | 5765 | }, | |
| 5766 | + | "node_modules/@types/use-sync-external-store": { | |
| 5767 | + | "version": "1.5.0", | |
| 5768 | + | "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", | |
| 5769 | + | "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", | |
| 5770 | + | "license": "MIT", | |
| 5771 | + | "optional": true | |
| 5772 | + | }, | |
| 4931 | 5773 | "node_modules/@types/yauzl": { | |
| 4932 | 5774 | "version": "2.10.3", | |
| 4933 | 5775 | "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", | |
| ⋯ | |||
| 4944 | 5786 | "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", | |
| 4945 | 5787 | "license": "ISC" | |
| 4946 | 5788 | }, | |
| 5789 | + | "node_modules/@upsetjs/venn.js": { | |
| 5790 | + | "version": "2.0.0", | |
| 5791 | + | "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", | |
| 5792 | + | "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", | |
| 5793 | + | "license": "MIT", | |
| 5794 | + | "optionalDependencies": { | |
| 5795 | + | "d3-selection": "^3.0.0", | |
| 5796 | + | "d3-transition": "^3.0.1" | |
| 5797 | + | } | |
| 5798 | + | }, | |
| 4947 | 5799 | "node_modules/agent-base": { | |
| 4948 | 5800 | "version": "7.1.4", | |
| 4949 | 5801 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", | |
| ⋯ | |||
| 5540 | 6392 | "url": "https://github.com/sponsors/wooorm" | |
| 5541 | 6393 | } | |
| 5542 | 6394 | }, | |
| 6395 | + | "node_modules/chevrotain": { | |
| 6396 | + | "version": "13.2.0", | |
| 6397 | + | "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", | |
| 6398 | + | "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", | |
| 6399 | + | "license": "Apache-2.0", | |
| 6400 | + | "dependencies": { | |
| 6401 | + | "@chevrotain/cst-dts-gen": "13.2.0", | |
| 6402 | + | "@chevrotain/gast": "13.2.0", | |
| 6403 | + | "@chevrotain/regexp-to-ast": "13.2.0", | |
| 6404 | + | "@chevrotain/types": "13.2.0", | |
| 6405 | + | "@chevrotain/utils": "13.2.0" | |
| 6406 | + | }, | |
| 6407 | + | "engines": { | |
| 6408 | + | "node": ">=22.0.0" | |
| 6409 | + | } | |
| 6410 | + | }, | |
| 5543 | 6411 | "node_modules/chokidar": { | |
| 5544 | 6412 | "version": "5.0.0", | |
| 5545 | 6413 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", | |
| ⋯ | |||
| 5701 | 6569 | "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", | |
| 5702 | 6570 | "license": "MIT" | |
| 5703 | 6571 | }, | |
| 6572 | + | "node_modules/cose-base": { | |
| 6573 | + | "version": "1.0.3", | |
| 6574 | + | "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", | |
| 6575 | + | "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", | |
| 6576 | + | "license": "MIT", | |
| 6577 | + | "dependencies": { | |
| 6578 | + | "layout-base": "^1.0.0" | |
| 6579 | + | } | |
| 6580 | + | }, | |
| 5704 | 6581 | "node_modules/crossws": { | |
| 5705 | 6582 | "version": "0.3.5", | |
| 5706 | 6583 | "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", | |
| ⋯ | |||
| 5859 | 6736 | "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 5860 | 6737 | "license": "MIT" | |
| 5861 | 6738 | }, | |
| 6739 | + | "node_modules/cytoscape": { | |
| 6740 | + | "version": "3.34.3", | |
| 6741 | + | "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", | |
| 6742 | + | "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", | |
| 6743 | + | "license": "MIT", | |
| 6744 | + | "engines": { | |
| 6745 | + | "node": ">=0.10" | |
| 6746 | + | } | |
| 6747 | + | }, | |
| 6748 | + | "node_modules/cytoscape-cose-bilkent": { | |
| 6749 | + | "version": "4.1.0", | |
| 6750 | + | "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", | |
| 6751 | + | "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", | |
| 6752 | + | "license": "MIT", | |
| 6753 | + | "dependencies": { | |
| 6754 | + | "cose-base": "^1.0.0" | |
| 6755 | + | }, | |
| 6756 | + | "peerDependencies": { | |
| 6757 | + | "cytoscape": "^3.2.0" | |
| 6758 | + | } | |
| 6759 | + | }, | |
| 6760 | + | "node_modules/cytoscape-fcose": { | |
| 6761 | + | "version": "2.2.0", | |
| 6762 | + | "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", | |
| 6763 | + | "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", | |
| 6764 | + | "license": "MIT", | |
| 6765 | + | "dependencies": { | |
| 6766 | + | "cose-base": "^2.2.0" | |
| 6767 | + | }, | |
| 6768 | + | "peerDependencies": { | |
| 6769 | + | "cytoscape": "^3.2.0" | |
| 6770 | + | } | |
| 6771 | + | }, | |
| 6772 | + | "node_modules/cytoscape-fcose/node_modules/cose-base": { | |
| 6773 | + | "version": "2.2.0", | |
| 6774 | + | "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", | |
| 6775 | + | "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", | |
| 6776 | + | "license": "MIT", | |
| 6777 | + | "dependencies": { | |
| 6778 | + | "layout-base": "^2.0.0" | |
| 6779 | + | } | |
| 6780 | + | }, | |
| 6781 | + | "node_modules/cytoscape-fcose/node_modules/layout-base": { | |
| 6782 | + | "version": "2.0.1", | |
| 6783 | + | "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", | |
| 6784 | + | "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", | |
| 6785 | + | "license": "MIT" | |
| 6786 | + | }, | |
| 6787 | + | "node_modules/d3": { | |
| 6788 | + | "version": "7.9.0", | |
| 6789 | + | "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", | |
| 6790 | + | "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", | |
| 6791 | + | "license": "ISC", | |
| 6792 | + | "dependencies": { | |
| 6793 | + | "d3-array": "3", | |
| 6794 | + | "d3-axis": "3", | |
| 6795 | + | "d3-brush": "3", | |
| 6796 | + | "d3-chord": "3", | |
| 6797 | + | "d3-color": "3", | |
| 6798 | + | "d3-contour": "4", | |
| 6799 | + | "d3-delaunay": "6", | |
| 6800 | + | "d3-dispatch": "3", | |
| 6801 | + | "d3-drag": "3", | |
| 6802 | + | "d3-dsv": "3", | |
| 6803 | + | "d3-ease": "3", | |
| 6804 | + | "d3-fetch": "3", | |
| 6805 | + | "d3-force": "3", | |
| 6806 | + | "d3-format": "3", | |
| 6807 | + | "d3-geo": "3", | |
| 6808 | + | "d3-hierarchy": "3", | |
| 6809 | + | "d3-interpolate": "3", | |
| 6810 | + | "d3-path": "3", | |
| 6811 | + | "d3-polygon": "3", | |
| 6812 | + | "d3-quadtree": "3", | |
| 6813 | + | "d3-random": "3", | |
| 6814 | + | "d3-scale": "4", | |
| 6815 | + | "d3-scale-chromatic": "3", | |
| 6816 | + | "d3-selection": "3", | |
| 6817 | + | "d3-shape": "3", | |
| 6818 | + | "d3-time": "3", | |
| 6819 | + | "d3-time-format": "4", | |
| 6820 | + | "d3-timer": "3", | |
| 6821 | + | "d3-transition": "3", | |
| 6822 | + | "d3-zoom": "3" | |
| 6823 | + | }, | |
| 6824 | + | "engines": { | |
| 6825 | + | "node": ">=12" | |
| 6826 | + | } | |
| 6827 | + | }, | |
| 6828 | + | "node_modules/d3-array": { | |
| 6829 | + | "version": "3.2.4", | |
| 6830 | + | "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", | |
| 6831 | + | "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", | |
| 6832 | + | "license": "ISC", | |
| 6833 | + | "dependencies": { | |
| 6834 | + | "internmap": "1 - 2" | |
| 6835 | + | }, | |
| 6836 | + | "engines": { | |
| 6837 | + | "node": ">=12" | |
| 6838 | + | } | |
| 6839 | + | }, | |
| 6840 | + | "node_modules/d3-axis": { | |
| 6841 | + | "version": "3.0.0", | |
| 6842 | + | "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", | |
| 6843 | + | "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", | |
| 6844 | + | "license": "ISC", | |
| 6845 | + | "engines": { | |
| 6846 | + | "node": ">=12" | |
| 6847 | + | } | |
| 6848 | + | }, | |
| 6849 | + | "node_modules/d3-brush": { | |
| 6850 | + | "version": "3.0.0", | |
| 6851 | + | "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", | |
| 6852 | + | "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", | |
| 6853 | + | "license": "ISC", | |
| 6854 | + | "dependencies": { | |
| 6855 | + | "d3-dispatch": "1 - 3", | |
| 6856 | + | "d3-drag": "2 - 3", | |
| 6857 | + | "d3-interpolate": "1 - 3", | |
| 6858 | + | "d3-selection": "3", | |
| 6859 | + | "d3-transition": "3" | |
| 6860 | + | }, | |
| 6861 | + | "engines": { | |
| 6862 | + | "node": ">=12" | |
| 6863 | + | } | |
| 6864 | + | }, | |
| 6865 | + | "node_modules/d3-chord": { | |
| 6866 | + | "version": "3.0.1", | |
| 6867 | + | "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", | |
| 6868 | + | "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", | |
| 6869 | + | "license": "ISC", | |
| 6870 | + | "dependencies": { | |
| 6871 | + | "d3-path": "1 - 3" | |
| 6872 | + | }, | |
| 6873 | + | "engines": { | |
| 6874 | + | "node": ">=12" | |
| 6875 | + | } | |
| 6876 | + | }, | |
| 6877 | + | "node_modules/d3-color": { | |
| 6878 | + | "version": "3.1.0", | |
| 6879 | + | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", | |
| 6880 | + | "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", | |
| 6881 | + | "license": "ISC", | |
| 6882 | + | "engines": { | |
| 6883 | + | "node": ">=12" | |
| 6884 | + | } | |
| 6885 | + | }, | |
| 6886 | + | "node_modules/d3-contour": { | |
| 6887 | + | "version": "4.0.2", | |
| 6888 | + | "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", | |
| 6889 | + | "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", | |
| 6890 | + | "license": "ISC", | |
| 6891 | + | "dependencies": { | |
| 6892 | + | "d3-array": "^3.2.0" | |
| 6893 | + | }, | |
| 6894 | + | "engines": { | |
| 6895 | + | "node": ">=12" | |
| 6896 | + | } | |
| 6897 | + | }, | |
| 6898 | + | "node_modules/d3-delaunay": { | |
| 6899 | + | "version": "6.0.4", | |
| 6900 | + | "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", | |
| 6901 | + | "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", | |
| 6902 | + | "license": "ISC", | |
| 6903 | + | "dependencies": { | |
| 6904 | + | "delaunator": "5" | |
| 6905 | + | }, | |
| 6906 | + | "engines": { | |
| 6907 | + | "node": ">=12" | |
| 6908 | + | } | |
| 6909 | + | }, | |
| 6910 | + | "node_modules/d3-dispatch": { | |
| 6911 | + | "version": "3.0.1", | |
| 6912 | + | "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", | |
| 6913 | + | "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", | |
| 6914 | + | "license": "ISC", | |
| 6915 | + | "engines": { | |
| 6916 | + | "node": ">=12" | |
| 6917 | + | } | |
| 6918 | + | }, | |
| 6919 | + | "node_modules/d3-drag": { | |
| 6920 | + | "version": "3.0.0", | |
| 6921 | + | "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", | |
| 6922 | + | "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", | |
| 6923 | + | "license": "ISC", | |
| 6924 | + | "dependencies": { | |
| 6925 | + | "d3-dispatch": "1 - 3", | |
| 6926 | + | "d3-selection": "3" | |
| 6927 | + | }, | |
| 6928 | + | "engines": { | |
| 6929 | + | "node": ">=12" | |
| 6930 | + | } | |
| 6931 | + | }, | |
| 6932 | + | "node_modules/d3-dsv": { | |
| 6933 | + | "version": "3.0.1", | |
| 6934 | + | "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", | |
| 6935 | + | "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", | |
| 6936 | + | "license": "ISC", | |
| 6937 | + | "dependencies": { | |
| 6938 | + | "commander": "7", | |
| 6939 | + | "iconv-lite": "0.6", | |
| 6940 | + | "rw": "1" | |
| 6941 | + | }, | |
| 6942 | + | "bin": { | |
| 6943 | + | "csv2json": "bin/dsv2json.js", | |
| 6944 | + | "csv2tsv": "bin/dsv2dsv.js", | |
| 6945 | + | "dsv2dsv": "bin/dsv2dsv.js", | |
| 6946 | + | "dsv2json": "bin/dsv2json.js", | |
| 6947 | + | "json2csv": "bin/json2dsv.js", | |
| 6948 | + | "json2dsv": "bin/json2dsv.js", | |
| 6949 | + | "json2tsv": "bin/json2dsv.js", | |
| 6950 | + | "tsv2csv": "bin/dsv2dsv.js", | |
| 6951 | + | "tsv2json": "bin/dsv2json.js" | |
| 6952 | + | }, | |
| 6953 | + | "engines": { | |
| 6954 | + | "node": ">=12" | |
| 6955 | + | } | |
| 6956 | + | }, | |
| 6957 | + | "node_modules/d3-dsv/node_modules/commander": { | |
| 6958 | + | "version": "7.2.0", | |
| 6959 | + | "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", | |
| 6960 | + | "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", | |
| 6961 | + | "license": "MIT", | |
| 6962 | + | "engines": { | |
| 6963 | + | "node": ">= 10" | |
| 6964 | + | } | |
| 6965 | + | }, | |
| 6966 | + | "node_modules/d3-ease": { | |
| 6967 | + | "version": "3.0.1", | |
| 6968 | + | "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", | |
| 6969 | + | "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", | |
| 6970 | + | "license": "BSD-3-Clause", | |
| 6971 | + | "engines": { | |
| 6972 | + | "node": ">=12" | |
| 6973 | + | } | |
| 6974 | + | }, | |
| 6975 | + | "node_modules/d3-fetch": { | |
| 6976 | + | "version": "3.0.1", | |
| 6977 | + | "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", | |
| 6978 | + | "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", | |
| 6979 | + | "license": "ISC", | |
| 6980 | + | "dependencies": { | |
| 6981 | + | "d3-dsv": "1 - 3" | |
| 6982 | + | }, | |
| 6983 | + | "engines": { | |
| 6984 | + | "node": ">=12" | |
| 6985 | + | } | |
| 6986 | + | }, | |
| 6987 | + | "node_modules/d3-force": { | |
| 6988 | + | "version": "3.0.0", | |
| 6989 | + | "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", | |
| 6990 | + | "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", | |
| 6991 | + | "license": "ISC", | |
| 6992 | + | "dependencies": { | |
| 6993 | + | "d3-dispatch": "1 - 3", | |
| 6994 | + | "d3-quadtree": "1 - 3", | |
| 6995 | + | "d3-timer": "1 - 3" | |
| 6996 | + | }, | |
| 6997 | + | "engines": { | |
| 6998 | + | "node": ">=12" | |
| 6999 | + | } | |
| 7000 | + | }, | |
| 7001 | + | "node_modules/d3-format": { | |
| 7002 | + | "version": "3.1.2", | |
| 7003 | + | "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", | |
| 7004 | + | "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", | |
| 7005 | + | "license": "ISC", | |
| 7006 | + | "engines": { | |
| 7007 | + | "node": ">=12" | |
| 7008 | + | } | |
| 7009 | + | }, | |
| 7010 | + | "node_modules/d3-geo": { | |
| 7011 | + | "version": "3.1.1", | |
| 7012 | + | "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", | |
| 7013 | + | "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", | |
| 7014 | + | "license": "ISC", | |
| 7015 | + | "dependencies": { | |
| 7016 | + | "d3-array": "2.5.0 - 3" | |
| 7017 | + | }, | |
| 7018 | + | "engines": { | |
| 7019 | + | "node": ">=12" | |
| 7020 | + | } | |
| 7021 | + | }, | |
| 7022 | + | "node_modules/d3-hierarchy": { | |
| 7023 | + | "version": "3.1.2", | |
| 7024 | + | "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", | |
| 7025 | + | "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", | |
| 7026 | + | "license": "ISC", | |
| 7027 | + | "engines": { | |
| 7028 | + | "node": ">=12" | |
| 7029 | + | } | |
| 7030 | + | }, | |
| 7031 | + | "node_modules/d3-interpolate": { | |
| 7032 | + | "version": "3.0.1", | |
| 7033 | + | "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", | |
| 7034 | + | "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", | |
| 7035 | + | "license": "ISC", | |
| 7036 | + | "dependencies": { | |
| 7037 | + | "d3-color": "1 - 3" | |
| 7038 | + | }, | |
| 7039 | + | "engines": { | |
| 7040 | + | "node": ">=12" | |
| 7041 | + | } | |
| 7042 | + | }, | |
| 7043 | + | "node_modules/d3-path": { | |
| 7044 | + | "version": "3.1.0", | |
| 7045 | + | "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", | |
| 7046 | + | "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", | |
| 7047 | + | "license": "ISC", | |
| 7048 | + | "engines": { | |
| 7049 | + | "node": ">=12" | |
| 7050 | + | } | |
| 7051 | + | }, | |
| 7052 | + | "node_modules/d3-polygon": { | |
| 7053 | + | "version": "3.0.1", | |
| 7054 | + | "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", | |
| 7055 | + | "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", | |
| 7056 | + | "license": "ISC", | |
| 7057 | + | "engines": { | |
| 7058 | + | "node": ">=12" | |
| 7059 | + | } | |
| 7060 | + | }, | |
| 7061 | + | "node_modules/d3-quadtree": { | |
| 7062 | + | "version": "3.0.1", | |
| 7063 | + | "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", | |
| 7064 | + | "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", | |
| 7065 | + | "license": "ISC", | |
| 7066 | + | "engines": { | |
| 7067 | + | "node": ">=12" | |
| 7068 | + | } | |
| 7069 | + | }, | |
| 7070 | + | "node_modules/d3-random": { | |
| 7071 | + | "version": "3.0.1", | |
| 7072 | + | "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", | |
| 7073 | + | "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", | |
| 7074 | + | "license": "ISC", | |
| 7075 | + | "engines": { | |
| 7076 | + | "node": ">=12" | |
| 7077 | + | } | |
| 7078 | + | }, | |
| 7079 | + | "node_modules/d3-sankey": { | |
| 7080 | + | "version": "0.12.3", | |
| 7081 | + | "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", | |
| 7082 | + | "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", | |
| 7083 | + | "license": "BSD-3-Clause", | |
| 7084 | + | "dependencies": { | |
| 7085 | + | "d3-array": "1 - 2", | |
| 7086 | + | "d3-shape": "^1.2.0" | |
| 7087 | + | } | |
| 7088 | + | }, | |
| 7089 | + | "node_modules/d3-sankey/node_modules/d3-array": { | |
| 7090 | + | "version": "2.12.1", | |
| 7091 | + | "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", | |
| 7092 | + | "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", | |
| 7093 | + | "license": "BSD-3-Clause", | |
| 7094 | + | "dependencies": { | |
| 7095 | + | "internmap": "^1.0.0" | |
| 7096 | + | } | |
| 7097 | + | }, | |
| 7098 | + | "node_modules/d3-sankey/node_modules/d3-path": { | |
| 7099 | + | "version": "1.0.9", | |
| 7100 | + | "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", | |
| 7101 | + | "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", | |
| 7102 | + | "license": "BSD-3-Clause" | |
| 7103 | + | }, | |
| 7104 | + | "node_modules/d3-sankey/node_modules/d3-shape": { | |
| 7105 | + | "version": "1.3.7", | |
| 7106 | + | "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", | |
| 7107 | + | "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", | |
| 7108 | + | "license": "BSD-3-Clause", | |
| 7109 | + | "dependencies": { | |
| 7110 | + | "d3-path": "1" | |
| 7111 | + | } | |
| 7112 | + | }, | |
| 7113 | + | "node_modules/d3-sankey/node_modules/internmap": { | |
| 7114 | + | "version": "1.0.1", | |
| 7115 | + | "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", | |
| 7116 | + | "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", | |
| 7117 | + | "license": "ISC" | |
| 7118 | + | }, | |
| 7119 | + | "node_modules/d3-scale": { | |
| 7120 | + | "version": "4.0.2", | |
| 7121 | + | "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", | |
| 7122 | + | "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", | |
| 7123 | + | "license": "ISC", | |
| 7124 | + | "dependencies": { | |
| 7125 | + | "d3-array": "2.10.0 - 3", | |
| 7126 | + | "d3-format": "1 - 3", | |
| 7127 | + | "d3-interpolate": "1.2.0 - 3", | |
| 7128 | + | "d3-time": "2.1.1 - 3", | |
| 7129 | + | "d3-time-format": "2 - 4" | |
| 7130 | + | }, | |
| 7131 | + | "engines": { | |
| 7132 | + | "node": ">=12" | |
| 7133 | + | } | |
| 7134 | + | }, | |
| 7135 | + | "node_modules/d3-scale-chromatic": { | |
| 7136 | + | "version": "3.1.0", | |
| 7137 | + | "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", | |
| 7138 | + | "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", | |
| 7139 | + | "license": "ISC", | |
| 7140 | + | "dependencies": { | |
| 7141 | + | "d3-color": "1 - 3", | |
| 7142 | + | "d3-interpolate": "1 - 3" | |
| 7143 | + | }, | |
| 7144 | + | "engines": { | |
| 7145 | + | "node": ">=12" | |
| 7146 | + | } | |
| 7147 | + | }, | |
| 7148 | + | "node_modules/d3-selection": { | |
| 7149 | + | "version": "3.0.0", | |
| 7150 | + | "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", | |
| 7151 | + | "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", | |
| 7152 | + | "license": "ISC", | |
| 7153 | + | "engines": { | |
| 7154 | + | "node": ">=12" | |
| 7155 | + | } | |
| 7156 | + | }, | |
| 7157 | + | "node_modules/d3-shape": { | |
| 7158 | + | "version": "3.2.0", | |
| 7159 | + | "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", | |
| 7160 | + | "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", | |
| 7161 | + | "license": "ISC", | |
| 7162 | + | "dependencies": { | |
| 7163 | + | "d3-path": "^3.1.0" | |
| 7164 | + | }, | |
| 7165 | + | "engines": { | |
| 7166 | + | "node": ">=12" | |
| 7167 | + | } | |
| 7168 | + | }, | |
| 7169 | + | "node_modules/d3-time": { | |
| 7170 | + | "version": "3.1.0", | |
| 7171 | + | "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", | |
| 7172 | + | "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", | |
| 7173 | + | "license": "ISC", | |
| 7174 | + | "dependencies": { | |
| 7175 | + | "d3-array": "2 - 3" | |
| 7176 | + | }, | |
| 7177 | + | "engines": { | |
| 7178 | + | "node": ">=12" | |
| 7179 | + | } | |
| 7180 | + | }, | |
| 7181 | + | "node_modules/d3-time-format": { | |
| 7182 | + | "version": "4.1.0", | |
| 7183 | + | "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", | |
| 7184 | + | "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", | |
| 7185 | + | "license": "ISC", | |
| 7186 | + | "dependencies": { | |
| 7187 | + | "d3-time": "1 - 3" | |
| 7188 | + | }, | |
| 7189 | + | "engines": { | |
| 7190 | + | "node": ">=12" | |
| 7191 | + | } | |
| 7192 | + | }, | |
| 7193 | + | "node_modules/d3-timer": { | |
| 7194 | + | "version": "3.0.1", | |
| 7195 | + | "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", | |
| 7196 | + | "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", | |
| 7197 | + | "license": "ISC", | |
| 7198 | + | "engines": { | |
| 7199 | + | "node": ">=12" | |
| 7200 | + | } | |
| 7201 | + | }, | |
| 7202 | + | "node_modules/d3-transition": { | |
| 7203 | + | "version": "3.0.1", | |
| 7204 | + | "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", | |
| 7205 | + | "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", | |
| 7206 | + | "license": "ISC", | |
| 7207 | + | "dependencies": { | |
| 7208 | + | "d3-color": "1 - 3", | |
| 7209 | + | "d3-dispatch": "1 - 3", | |
| 7210 | + | "d3-ease": "1 - 3", | |
| 7211 | + | "d3-interpolate": "1 - 3", | |
| 7212 | + | "d3-timer": "1 - 3" | |
| 7213 | + | }, | |
| 7214 | + | "engines": { | |
| 7215 | + | "node": ">=12" | |
| 7216 | + | }, | |
| 7217 | + | "peerDependencies": { | |
| 7218 | + | "d3-selection": "2 - 3" | |
| 7219 | + | } | |
| 7220 | + | }, | |
| 7221 | + | "node_modules/d3-zoom": { | |
| 7222 | + | "version": "3.0.0", | |
| 7223 | + | "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", | |
| 7224 | + | "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", | |
| 7225 | + | "license": "ISC", | |
| 7226 | + | "dependencies": { | |
| 7227 | + | "d3-dispatch": "1 - 3", | |
| 7228 | + | "d3-drag": "2 - 3", | |
| 7229 | + | "d3-interpolate": "1 - 3", | |
| 7230 | + | "d3-selection": "2 - 3", | |
| 7231 | + | "d3-transition": "2 - 3" | |
| 7232 | + | }, | |
| 7233 | + | "engines": { | |
| 7234 | + | "node": ">=12" | |
| 7235 | + | } | |
| 7236 | + | }, | |
| 7237 | + | "node_modules/dagre-d3-es": { | |
| 7238 | + | "version": "7.0.14", | |
| 7239 | + | "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", | |
| 7240 | + | "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", | |
| 7241 | + | "license": "MIT", | |
| 7242 | + | "dependencies": { | |
| 7243 | + | "d3": "^7.9.0", | |
| 7244 | + | "lodash-es": "^4.17.21" | |
| 7245 | + | } | |
| 7246 | + | }, | |
| 5862 | 7247 | "node_modules/data-uri-to-buffer": { | |
| 5863 | 7248 | "version": "6.0.2", | |
| 5864 | 7249 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", | |
| ⋯ | |||
| 5868 | 7253 | "node": ">= 14" | |
| 5869 | 7254 | } | |
| 5870 | 7255 | }, | |
| 7256 | + | "node_modules/dayjs": { | |
| 7257 | + | "version": "1.11.23", | |
| 7258 | + | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", | |
| 7259 | + | "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", | |
| 7260 | + | "license": "MIT" | |
| 7261 | + | }, | |
| 5871 | 7262 | "node_modules/debug": { | |
| 5872 | 7263 | "version": "4.4.3", | |
| 5873 | 7264 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |
| ⋯ | |||
| 5933 | 7324 | "node": ">= 14" | |
| 5934 | 7325 | } | |
| 5935 | 7326 | }, | |
| 7327 | + | "node_modules/delaunator": { | |
| 7328 | + | "version": "5.1.0", | |
| 7329 | + | "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", | |
| 7330 | + | "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", | |
| 7331 | + | "license": "ISC", | |
| 7332 | + | "dependencies": { | |
| 7333 | + | "robust-predicates": "^3.0.2" | |
| 7334 | + | } | |
| 7335 | + | }, | |
| 5936 | 7336 | "node_modules/dequal": { | |
| 5937 | 7337 | "version": "2.0.3", | |
| 5938 | 7338 | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | |
| ⋯ | |||
| 6051 | 7451 | "url": "https://github.com/fb55/domhandler?sponsor=1" | |
| 6052 | 7452 | } | |
| 6053 | 7453 | }, | |
| 7454 | + | "node_modules/dompurify": { | |
| 7455 | + | "version": "3.4.16", | |
| 7456 | + | "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", | |
| 7457 | + | "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", | |
| 7458 | + | "license": "(MPL-2.0 OR Apache-2.0)", | |
| 7459 | + | "optionalDependencies": { | |
| 7460 | + | "@types/trusted-types": "^2.0.7" | |
| 7461 | + | } | |
| 7462 | + | }, | |
| 6054 | 7463 | "node_modules/domutils": { | |
| 6055 | 7464 | "version": "3.2.2", | |
| 6056 | 7465 | "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", | |
| ⋯ | |||
| 6081 | 7490 | "dev": true, | |
| 6082 | 7491 | "license": "ISC" | |
| 6083 | 7492 | }, | |
| 7493 | + | "node_modules/elkjs": { | |
| 7494 | + | "version": "0.9.3", | |
| 7495 | + | "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", | |
| 7496 | + | "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", | |
| 7497 | + | "license": "EPL-2.0" | |
| 7498 | + | }, | |
| 7499 | + | "node_modules/emoji-mart": { | |
| 7500 | + | "version": "5.6.0", | |
| 7501 | + | "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", | |
| 7502 | + | "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", | |
| 7503 | + | "license": "MIT" | |
| 7504 | + | }, | |
| 6084 | 7505 | "node_modules/emoji-regex": { | |
| 6085 | 7506 | "version": "8.0.0", | |
| 6086 | 7507 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", | |
| ⋯ | |||
| 6147 | 7568 | "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", | |
| 6148 | 7569 | "license": "MIT" | |
| 6149 | 7570 | }, | |
| 7571 | + | "node_modules/es-toolkit": { | |
| 7572 | + | "version": "1.52.0", | |
| 7573 | + | "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", | |
| 7574 | + | "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", | |
| 7575 | + | "license": "MIT", | |
| 7576 | + | "workspaces": [ | |
| 7577 | + | "docs", | |
| 7578 | + | "benchmarks", | |
| 7579 | + | "tests/types", | |
| 7580 | + | "tests/browser-compat" | |
| 7581 | + | ] | |
| 7582 | + | }, | |
| 6150 | 7583 | "node_modules/esbuild": { | |
| 6151 | 7584 | "version": "0.28.1", | |
| 6152 | 7585 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", | |
| ⋯ | |||
| 6350 | 7783 | "@types/yauzl": "^2.9.1" | |
| 6351 | 7784 | } | |
| 6352 | 7785 | }, | |
| 7786 | + | "node_modules/fast-deep-equal": { | |
| 7787 | + | "version": "3.1.3", | |
| 7788 | + | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | |
| 7789 | + | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", | |
| 7790 | + | "license": "MIT" | |
| 7791 | + | }, | |
| 7792 | + | "node_modules/fast-equals": { | |
| 7793 | + | "version": "5.4.4", | |
| 7794 | + | "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz", | |
| 7795 | + | "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==", | |
| 7796 | + | "license": "MIT", | |
| 7797 | + | "engines": { | |
| 7798 | + | "node": ">=6.0.0" | |
| 7799 | + | } | |
| 7800 | + | }, | |
| 6353 | 7801 | "node_modules/fast-fifo": { | |
| 6354 | 7802 | "version": "1.3.2", | |
| 6355 | 7803 | "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", | |
| ⋯ | |||
| 6573 | 8021 | "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", | |
| 6574 | 8022 | "license": "MIT" | |
| 6575 | 8023 | }, | |
| 8024 | + | "node_modules/hachure-fill": { | |
| 8025 | + | "version": "0.5.2", | |
| 8026 | + | "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", | |
| 8027 | + | "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", | |
| 8028 | + | "license": "MIT" | |
| 8029 | + | }, | |
| 6576 | 8030 | "node_modules/hast-util-embedded": { | |
| 6577 | 8031 | "version": "3.0.0", | |
| 6578 | 8032 | "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", | |
| ⋯ | |||
| 7015 | 8469 | } | |
| 7016 | 8470 | } | |
| 7017 | 8471 | }, | |
| 8472 | + | "node_modules/iconv-lite": { | |
| 8473 | + | "version": "0.6.3", | |
| 8474 | + | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", | |
| 8475 | + | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", | |
| 8476 | + | "license": "MIT", | |
| 8477 | + | "dependencies": { | |
| 8478 | + | "safer-buffer": ">= 2.1.2 < 3.0.0" | |
| 8479 | + | }, | |
| 8480 | + | "engines": { | |
| 8481 | + | "node": ">=0.10.0" | |
| 8482 | + | } | |
| 8483 | + | }, | |
| 7018 | 8484 | "node_modules/ieee754": { | |
| 7019 | 8485 | "version": "1.2.1", | |
| 7020 | 8486 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", | |
| ⋯ | |||
| 7035 | 8501 | ], | |
| 7036 | 8502 | "license": "BSD-3-Clause" | |
| 7037 | 8503 | }, | |
| 8504 | + | "node_modules/import-meta-resolve": { | |
| 8505 | + | "version": "4.2.0", | |
| 8506 | + | "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", | |
| 8507 | + | "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", | |
| 8508 | + | "license": "MIT", | |
| 8509 | + | "funding": { | |
| 8510 | + | "type": "github", | |
| 8511 | + | "url": "https://github.com/sponsors/wooorm" | |
| 8512 | + | } | |
| 8513 | + | }, | |
| 7038 | 8514 | "node_modules/inline-style-parser": { | |
| 7039 | 8515 | "version": "0.2.7", | |
| 7040 | 8516 | "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", | |
| 7041 | 8517 | "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", | |
| 7042 | 8518 | "license": "MIT" | |
| 7043 | 8519 | }, | |
| 8520 | + | "node_modules/internmap": { | |
| 8521 | + | "version": "2.0.3", | |
| 8522 | + | "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", | |
| 8523 | + | "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", | |
| 8524 | + | "license": "ISC", | |
| 8525 | + | "engines": { | |
| 8526 | + | "node": ">=12" | |
| 8527 | + | } | |
| 8528 | + | }, | |
| 7044 | 8529 | "node_modules/ip-address": { | |
| 7045 | 8530 | "version": "10.7.3", | |
| 7046 | 8531 | "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", | |
| ⋯ | |||
| 7148 | 8633 | "node": ">=18" | |
| 7149 | 8634 | } | |
| 7150 | 8635 | }, | |
| 8636 | + | "node_modules/isomorphic.js": { | |
| 8637 | + | "version": "0.2.5", | |
| 8638 | + | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", | |
| 8639 | + | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", | |
| 8640 | + | "license": "MIT", | |
| 8641 | + | "funding": { | |
| 8642 | + | "type": "GitHub Sponsors ❤", | |
| 8643 | + | "url": "https://github.com/sponsors/dmonad" | |
| 8644 | + | } | |
| 8645 | + | }, | |
| 7151 | 8646 | "node_modules/jiti": { | |
| 7152 | 8647 | "version": "2.7.0", | |
| 7153 | 8648 | "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", | |
| ⋯ | |||
| 7219 | 8714 | "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", | |
| 7220 | 8715 | "license": "MIT" | |
| 7221 | 8716 | }, | |
| 8717 | + | "node_modules/katex": { | |
| 8718 | + | "version": "0.19.0", | |
| 8719 | + | "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz", | |
| 8720 | + | "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==", | |
| 8721 | + | "funding": [ | |
| 8722 | + | "https://opencollective.com/katex", | |
| 8723 | + | "https://github.com/sponsors/katex" | |
| 8724 | + | ], | |
| 8725 | + | "license": "MIT", | |
| 8726 | + | "dependencies": { | |
| 8727 | + | "commander": "^15.0.0" | |
| 8728 | + | }, | |
| 8729 | + | "bin": { | |
| 8730 | + | "katex": "cli.js" | |
| 8731 | + | } | |
| 8732 | + | }, | |
| 8733 | + | "node_modules/katex/node_modules/commander": { | |
| 8734 | + | "version": "15.0.0", | |
| 8735 | + | "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", | |
| 8736 | + | "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", | |
| 8737 | + | "license": "MIT", | |
| 8738 | + | "engines": { | |
| 8739 | + | "node": ">=22.12.0" | |
| 8740 | + | } | |
| 8741 | + | }, | |
| 8742 | + | "node_modules/khroma": { | |
| 8743 | + | "version": "2.1.0", | |
| 8744 | + | "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", | |
| 8745 | + | "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" | |
| 8746 | + | }, | |
| 7222 | 8747 | "node_modules/kleur": { | |
| 7223 | 8748 | "version": "4.1.5", | |
| 7224 | 8749 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", | |
| ⋯ | |||
| 7238 | 8763 | "node": ">= 8" | |
| 7239 | 8764 | } | |
| 7240 | 8765 | }, | |
| 8766 | + | "node_modules/layout-base": { | |
| 8767 | + | "version": "1.0.2", | |
| 8768 | + | "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", | |
| 8769 | + | "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", | |
| 8770 | + | "license": "MIT" | |
| 8771 | + | }, | |
| 8772 | + | "node_modules/lib0": { | |
| 8773 | + | "version": "0.2.119", | |
| 8774 | + | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", | |
| 8775 | + | "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", | |
| 8776 | + | "license": "MIT", | |
| 8777 | + | "dependencies": { | |
| 8778 | + | "isomorphic.js": "^0.2.4" | |
| 8779 | + | }, | |
| 8780 | + | "bin": { | |
| 8781 | + | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", | |
| 8782 | + | "0gentesthtml": "bin/gentesthtml.js", | |
| 8783 | + | "0serve": "bin/0serve.js" | |
| 8784 | + | }, | |
| 8785 | + | "engines": { | |
| 8786 | + | "node": ">=16" | |
| 8787 | + | }, | |
| 8788 | + | "funding": { | |
| 8789 | + | "type": "GitHub Sponsors ❤", | |
| 8790 | + | "url": "https://github.com/sponsors/dmonad" | |
| 8791 | + | } | |
| 8792 | + | }, | |
| 7241 | 8793 | "node_modules/lightningcss": { | |
| 7242 | 8794 | "version": "1.32.0", | |
| 7243 | 8795 | "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", | |
| ⋯ | |||
| 7528 | 9080 | "dev": true, | |
| 7529 | 9081 | "license": "MIT" | |
| 7530 | 9082 | }, | |
| 9083 | + | "node_modules/lodash-es": { | |
| 9084 | + | "version": "4.18.1", | |
| 9085 | + | "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", | |
| 9086 | + | "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", | |
| 9087 | + | "license": "MIT" | |
| 9088 | + | }, | |
| 7531 | 9089 | "node_modules/longest-streak": { | |
| 7532 | 9090 | "version": "3.1.0", | |
| 7533 | 9091 | "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", | |
| ⋯ | |||
| 7594 | 9152 | "url": "https://github.com/sponsors/wooorm" | |
| 7595 | 9153 | } | |
| 7596 | 9154 | }, | |
| 9155 | + | "node_modules/marked": { | |
| 9156 | + | "version": "16.4.2", | |
| 9157 | + | "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", | |
| 9158 | + | "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", | |
| 9159 | + | "license": "MIT", | |
| 9160 | + | "bin": { | |
| 9161 | + | "marked": "bin/marked.js" | |
| 9162 | + | }, | |
| 9163 | + | "engines": { | |
| 9164 | + | "node": ">= 20" | |
| 9165 | + | } | |
| 9166 | + | }, | |
| 7597 | 9167 | "node_modules/mdast-util-directive": { | |
| 7598 | 9168 | "version": "3.1.0", | |
| 7599 | 9169 | "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", | |
| ⋯ | |||
| 7893 | 9463 | "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", | |
| 7894 | 9464 | "license": "CC0-1.0" | |
| 7895 | 9465 | }, | |
| 9466 | + | "node_modules/mermaid": { | |
| 9467 | + | "version": "12.1.0", | |
| 9468 | + | "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz", | |
| 9469 | + | "integrity": "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==", | |
| 9470 | + | "license": "MIT", | |
| 9471 | + | "dependencies": { | |
| 9472 | + | "@braintree/sanitize-url": "^7.1.2", | |
| 9473 | + | "@iconify/utils": "^3.0.2", | |
| 9474 | + | "@mermaid-js/parser": "^2.0.1", | |
| 9475 | + | "@types/d3": "^7.4.3", | |
| 9476 | + | "@upsetjs/venn.js": "^2.0.0", | |
| 9477 | + | "chevrotain": "~13.2.0", | |
| 9478 | + | "cytoscape": "^3.34.0", | |
| 9479 | + | "cytoscape-cose-bilkent": "^4.1.0", | |
| 9480 | + | "cytoscape-fcose": "^2.2.0", | |
| 9481 | + | "d3": "^7.9.0", | |
| 9482 | + | "d3-sankey": "^0.12.3", | |
| 9483 | + | "dagre-d3-es": "7.0.14", | |
| 9484 | + | "dayjs": "^1.11.21", | |
| 9485 | + | "dompurify": "^3.4.12", | |
| 9486 | + | "elkjs": "^0.9.3", | |
| 9487 | + | "es-toolkit": "^1.45.1", | |
| 9488 | + | "katex": "^0.16.47", | |
| 9489 | + | "khroma": "^2.1.0", | |
| 9490 | + | "marked": "^16.3.0", | |
| 9491 | + | "roughjs": "^4.6.6", | |
| 9492 | + | "stylis": "^4.3.6", | |
| 9493 | + | "ts-dedent": "^2.2.0", | |
| 9494 | + | "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" | |
| 9495 | + | }, | |
| 9496 | + | "engines": { | |
| 9497 | + | "node": ">=22.12.0" | |
| 9498 | + | } | |
| 9499 | + | }, | |
| 9500 | + | "node_modules/mermaid/node_modules/commander": { | |
| 9501 | + | "version": "8.3.0", | |
| 9502 | + | "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", | |
| 9503 | + | "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", | |
| 9504 | + | "license": "MIT", | |
| 9505 | + | "engines": { | |
| 9506 | + | "node": ">= 12" | |
| 9507 | + | } | |
| 9508 | + | }, | |
| 9509 | + | "node_modules/mermaid/node_modules/katex": { | |
| 9510 | + | "version": "0.16.47", | |
| 9511 | + | "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", | |
| 9512 | + | "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", | |
| 9513 | + | "funding": [ | |
| 9514 | + | "https://opencollective.com/katex", | |
| 9515 | + | "https://github.com/sponsors/katex" | |
| 9516 | + | ], | |
| 9517 | + | "license": "MIT", | |
| 9518 | + | "dependencies": { | |
| 9519 | + | "commander": "^8.3.0" | |
| 9520 | + | }, | |
| 9521 | + | "bin": { | |
| 9522 | + | "katex": "cli.js" | |
| 9523 | + | } | |
| 9524 | + | }, | |
| 7896 | 9525 | "node_modules/micromark": { | |
| 7897 | 9526 | "version": "4.0.3", | |
| 7898 | 9527 | "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", | |
| ⋯ | |||
| 8677 | 10306 | "regex-recursion": "^6.0.2" | |
| 8678 | 10307 | } | |
| 8679 | 10308 | }, | |
| 10309 | + | "node_modules/orderedmap": { | |
| 10310 | + | "version": "2.1.1", | |
| 10311 | + | "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", | |
| 10312 | + | "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", | |
| 10313 | + | "license": "MIT" | |
| 10314 | + | }, | |
| 8680 | 10315 | "node_modules/p-limit": { | |
| 8681 | 10316 | "version": "7.3.3", | |
| 8682 | 10317 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", | |
| ⋯ | |||
| 8854 | 10489 | "url": "https://github.com/fb55/entities?sponsor=1" | |
| 8855 | 10490 | } | |
| 8856 | 10491 | }, | |
| 10492 | + | "node_modules/path-data-parser": { | |
| 10493 | + | "version": "0.1.0", | |
| 10494 | + | "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", | |
| 10495 | + | "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", | |
| 10496 | + | "license": "MIT" | |
| 10497 | + | }, | |
| 8857 | 10498 | "node_modules/path-to-regexp": { | |
| 8858 | 10499 | "version": "6.3.0", | |
| 8859 | 10500 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", | |
| ⋯ | |||
| 8910 | 10551 | "pathe": "^2.0.3" | |
| 8911 | 10552 | } | |
| 8912 | 10553 | }, | |
| 10554 | + | "node_modules/points-on-curve": { | |
| 10555 | + | "version": "0.2.0", | |
| 10556 | + | "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", | |
| 10557 | + | "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", | |
| 10558 | + | "license": "MIT" | |
| 10559 | + | }, | |
| 10560 | + | "node_modules/points-on-path": { | |
| 10561 | + | "version": "0.2.1", | |
| 10562 | + | "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", | |
| 10563 | + | "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", | |
| 10564 | + | "license": "MIT", | |
| 10565 | + | "dependencies": { | |
| 10566 | + | "path-data-parser": "0.1.0", | |
| 10567 | + | "points-on-curve": "0.2.0" | |
| 10568 | + | } | |
| 10569 | + | }, | |
| 8913 | 10570 | "node_modules/postcss": { | |
| 8914 | 10571 | "version": "8.5.28", | |
| 8915 | 10572 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", | |
| ⋯ | |||
| 9019 | 10676 | "url": "https://github.com/sponsors/wooorm" | |
| 9020 | 10677 | } | |
| 9021 | 10678 | }, | |
| 10679 | + | "node_modules/prosemirror-changeset": { | |
| 10680 | + | "version": "2.4.4", | |
| 10681 | + | "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", | |
| 10682 | + | "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", | |
| 10683 | + | "license": "MIT", | |
| 10684 | + | "dependencies": { | |
| 10685 | + | "prosemirror-transform": "^1.0.0" | |
| 10686 | + | } | |
| 10687 | + | }, | |
| 10688 | + | "node_modules/prosemirror-commands": { | |
| 10689 | + | "version": "1.7.2", | |
| 10690 | + | "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", | |
| 10691 | + | "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", | |
| 10692 | + | "license": "MIT", | |
| 10693 | + | "dependencies": { | |
| 10694 | + | "prosemirror-model": "^1.0.0", | |
| 10695 | + | "prosemirror-state": "^1.0.0", | |
| 10696 | + | "prosemirror-transform": "^1.10.2" | |
| 10697 | + | } | |
| 10698 | + | }, | |
| 10699 | + | "node_modules/prosemirror-dropcursor": { | |
| 10700 | + | "version": "1.8.4", | |
| 10701 | + | "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", | |
| 10702 | + | "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", | |
| 10703 | + | "license": "MIT", | |
| 10704 | + | "dependencies": { | |
| 10705 | + | "prosemirror-state": "^1.0.0", | |
| 10706 | + | "prosemirror-transform": "^1.1.0", | |
| 10707 | + | "prosemirror-view": "^1.1.0" | |
| 10708 | + | } | |
| 10709 | + | }, | |
| 10710 | + | "node_modules/prosemirror-gapcursor": { | |
| 10711 | + | "version": "1.4.1", | |
| 10712 | + | "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", | |
| 10713 | + | "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", | |
| 10714 | + | "license": "MIT", | |
| 10715 | + | "dependencies": { | |
| 10716 | + | "prosemirror-keymap": "^1.0.0", | |
| 10717 | + | "prosemirror-model": "^1.0.0", | |
| 10718 | + | "prosemirror-state": "^1.0.0", | |
| 10719 | + | "prosemirror-view": "^1.0.0" | |
| 10720 | + | } | |
| 10721 | + | }, | |
| 10722 | + | "node_modules/prosemirror-highlight": { | |
| 10723 | + | "version": "0.15.3", | |
| 10724 | + | "resolved": "https://registry.npmjs.org/prosemirror-highlight/-/prosemirror-highlight-0.15.3.tgz", | |
| 10725 | + | "integrity": "sha512-WVV2st0fX1w2TkAgmTmdbj77BlWYuLfW4BGXPo8JfIWsSly5xYcfID8QJQL+GT8kisMRuu4jgT6JAqj1DOwvvg==", | |
| 10726 | + | "license": "MIT", | |
| 10727 | + | "funding": { | |
| 10728 | + | "url": "https://github.com/sponsors/ocavue" | |
| 10729 | + | }, | |
| 10730 | + | "peerDependencies": { | |
| 10731 | + | "@lezer/common": "^1.0.0", | |
| 10732 | + | "@lezer/highlight": "^1.0.0", | |
| 10733 | + | "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0", | |
| 10734 | + | "@types/hast": "^3.0.0", | |
| 10735 | + | "highlight.js": "^11.9.0", | |
| 10736 | + | "lowlight": "^3.1.0", | |
| 10737 | + | "prosemirror-model": "^1.19.3", | |
| 10738 | + | "prosemirror-state": "^1.4.3", | |
| 10739 | + | "prosemirror-transform": "^1.8.0", | |
| 10740 | + | "prosemirror-view": "^1.32.4", | |
| 10741 | + | "refractor": "^5.0.0", | |
| 10742 | + | "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0" | |
| 10743 | + | }, | |
| 10744 | + | "peerDependenciesMeta": { | |
| 10745 | + | "@lezer/common": { | |
| 10746 | + | "optional": true | |
| 10747 | + | }, | |
| 10748 | + | "@lezer/highlight": { | |
| 10749 | + | "optional": true | |
| 10750 | + | }, | |
| 10751 | + | "@shikijs/types": { | |
| 10752 | + | "optional": true | |
| 10753 | + | }, | |
| 10754 | + | "@types/hast": { | |
| 10755 | + | "optional": true | |
| 10756 | + | }, | |
| 10757 | + | "highlight.js": { | |
| 10758 | + | "optional": true | |
| 10759 | + | }, | |
| 10760 | + | "lowlight": { | |
| 10761 | + | "optional": true | |
| 10762 | + | }, | |
| 10763 | + | "prosemirror-model": { | |
| 10764 | + | "optional": true | |
| 10765 | + | }, | |
| 10766 | + | "prosemirror-state": { | |
| 10767 | + | "optional": true | |
| 10768 | + | }, | |
| 10769 | + | "prosemirror-transform": { | |
| 10770 | + | "optional": true | |
| 10771 | + | }, | |
| 10772 | + | "prosemirror-view": { | |
| 10773 | + | "optional": true | |
| 10774 | + | }, | |
| 10775 | + | "refractor": { | |
| 10776 | + | "optional": true | |
| 10777 | + | }, | |
| 10778 | + | "sugar-high": { | |
| 10779 | + | "optional": true | |
| 10780 | + | } | |
| 10781 | + | } | |
| 10782 | + | }, | |
| 10783 | + | "node_modules/prosemirror-history": { | |
| 10784 | + | "version": "1.5.1", | |
| 10785 | + | "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", | |
| 10786 | + | "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", | |
| 10787 | + | "license": "MIT", | |
| 10788 | + | "dependencies": { | |
| 10789 | + | "prosemirror-state": "^1.2.2", | |
| 10790 | + | "prosemirror-transform": "^1.0.0", | |
| 10791 | + | "prosemirror-view": "^1.31.0", | |
| 10792 | + | "rope-sequence": "^1.3.0" | |
| 10793 | + | } | |
| 10794 | + | }, | |
| 10795 | + | "node_modules/prosemirror-inputrules": { | |
| 10796 | + | "version": "1.5.1", | |
| 10797 | + | "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", | |
| 10798 | + | "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", | |
| 10799 | + | "license": "MIT", | |
| 10800 | + | "dependencies": { | |
| 10801 | + | "prosemirror-state": "^1.0.0", | |
| 10802 | + | "prosemirror-transform": "^1.0.0" | |
| 10803 | + | } | |
| 10804 | + | }, | |
| 10805 | + | "node_modules/prosemirror-keymap": { | |
| 10806 | + | "version": "1.2.3", | |
| 10807 | + | "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", | |
| 10808 | + | "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", | |
| 10809 | + | "license": "MIT", | |
| 10810 | + | "dependencies": { | |
| 10811 | + | "prosemirror-state": "^1.0.0", | |
| 10812 | + | "w3c-keyname": "^2.2.0" | |
| 10813 | + | } | |
| 10814 | + | }, | |
| 10815 | + | "node_modules/prosemirror-model": { | |
| 10816 | + | "version": "1.25.12", | |
| 10817 | + | "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", | |
| 10818 | + | "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", | |
| 10819 | + | "license": "MIT", | |
| 10820 | + | "dependencies": { | |
| 10821 | + | "orderedmap": "^2.0.0" | |
| 10822 | + | } | |
| 10823 | + | }, | |
| 10824 | + | "node_modules/prosemirror-schema-list": { | |
| 10825 | + | "version": "1.5.1", | |
| 10826 | + | "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", | |
| 10827 | + | "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", | |
| 10828 | + | "license": "MIT", | |
| 10829 | + | "dependencies": { | |
| 10830 | + | "prosemirror-model": "^1.0.0", | |
| 10831 | + | "prosemirror-state": "^1.0.0", | |
| 10832 | + | "prosemirror-transform": "^1.7.3" | |
| 10833 | + | } | |
| 10834 | + | }, | |
| 10835 | + | "node_modules/prosemirror-state": { | |
| 10836 | + | "version": "1.4.4", | |
| 10837 | + | "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", | |
| 10838 | + | "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", | |
| 10839 | + | "license": "MIT", | |
| 10840 | + | "dependencies": { | |
| 10841 | + | "prosemirror-model": "^1.0.0", | |
| 10842 | + | "prosemirror-transform": "^1.0.0", | |
| 10843 | + | "prosemirror-view": "^1.27.0" | |
| 10844 | + | } | |
| 10845 | + | }, | |
| 10846 | + | "node_modules/prosemirror-tables": { | |
| 10847 | + | "version": "1.8.5", | |
| 10848 | + | "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", | |
| 10849 | + | "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", | |
| 10850 | + | "license": "MIT", | |
| 10851 | + | "dependencies": { | |
| 10852 | + | "prosemirror-keymap": "^1.2.3", | |
| 10853 | + | "prosemirror-model": "^1.25.4", | |
| 10854 | + | "prosemirror-state": "^1.4.4", | |
| 10855 | + | "prosemirror-transform": "^1.10.5", | |
| 10856 | + | "prosemirror-view": "^1.41.4" | |
| 10857 | + | } | |
| 10858 | + | }, | |
| 10859 | + | "node_modules/prosemirror-transform": { | |
| 10860 | + | "version": "1.12.2", | |
| 10861 | + | "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", | |
| 10862 | + | "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", | |
| 10863 | + | "license": "MIT", | |
| 10864 | + | "dependencies": { | |
| 10865 | + | "prosemirror-model": "^1.21.0" | |
| 10866 | + | } | |
| 10867 | + | }, | |
| 10868 | + | "node_modules/prosemirror-view": { | |
| 10869 | + | "version": "1.42.6", | |
| 10870 | + | "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", | |
| 10871 | + | "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", | |
| 10872 | + | "license": "MIT", | |
| 10873 | + | "dependencies": { | |
| 10874 | + | "prosemirror-model": "^1.25.12", | |
| 10875 | + | "prosemirror-state": "^1.0.0", | |
| 10876 | + | "prosemirror-transform": "^1.1.0" | |
| 10877 | + | } | |
| 10878 | + | }, | |
| 9022 | 10879 | "node_modules/proxy-agent": { | |
| 9023 | 10880 | "version": "6.5.0", | |
| 9024 | 10881 | "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", | |
| ⋯ | |||
| 9461 | 11318 | "node": ">=0.10.0" | |
| 9462 | 11319 | } | |
| 9463 | 11320 | }, | |
| 11321 | + | "node_modules/reselect": { | |
| 11322 | + | "version": "5.3.0", | |
| 11323 | + | "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", | |
| 11324 | + | "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", | |
| 11325 | + | "license": "MIT" | |
| 11326 | + | }, | |
| 9464 | 11327 | "node_modules/resolve-pkg-maps": { | |
| 9465 | 11328 | "version": "1.0.0", | |
| 9466 | 11329 | "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", | |
| ⋯ | |||
| 9485 | 11348 | "url": "https://opencollective.com/unified" | |
| 9486 | 11349 | } | |
| 9487 | 11350 | }, | |
| 11351 | + | "node_modules/robust-predicates": { | |
| 11352 | + | "version": "3.0.3", | |
| 11353 | + | "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", | |
| 11354 | + | "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", | |
| 11355 | + | "license": "Unlicense" | |
| 11356 | + | }, | |
| 9488 | 11357 | "node_modules/rolldown": { | |
| 9489 | 11358 | "version": "1.2.12", | |
| 9490 | 11359 | "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", | |
| ⋯ | |||
| 9518 | 11387 | "@rolldown/binding-win32-x64-msvc": "1.2.12" | |
| 9519 | 11388 | } | |
| 9520 | 11389 | }, | |
| 11390 | + | "node_modules/rope-sequence": { | |
| 11391 | + | "version": "1.3.4", | |
| 11392 | + | "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", | |
| 11393 | + | "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", | |
| 11394 | + | "license": "MIT" | |
| 11395 | + | }, | |
| 11396 | + | "node_modules/roughjs": { | |
| 11397 | + | "version": "4.6.6", | |
| 11398 | + | "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", | |
| 11399 | + | "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", | |
| 11400 | + | "license": "MIT", | |
| 11401 | + | "dependencies": { | |
| 11402 | + | "hachure-fill": "^0.5.2", | |
| 11403 | + | "path-data-parser": "^0.1.0", | |
| 11404 | + | "points-on-curve": "^0.2.0", | |
| 11405 | + | "points-on-path": "^0.2.1" | |
| 11406 | + | } | |
| 11407 | + | }, | |
| 11408 | + | "node_modules/rw": { | |
| 11409 | + | "version": "1.3.3", | |
| 11410 | + | "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", | |
| 11411 | + | "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", | |
| 11412 | + | "license": "BSD-3-Clause" | |
| 11413 | + | }, | |
| 11414 | + | "node_modules/safer-buffer": { | |
| 11415 | + | "version": "2.1.2", | |
| 11416 | + | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", | |
| 11417 | + | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", | |
| 11418 | + | "license": "MIT" | |
| 11419 | + | }, | |
| 9521 | 11420 | "node_modules/satteri": { | |
| 9522 | 11421 | "version": "0.10.5", | |
| 9523 | 11422 | "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", | |
| ⋯ | |||
| 9849 | 11748 | "inline-style-parser": "0.2.7" | |
| 9850 | 11749 | } | |
| 9851 | 11750 | }, | |
| 11751 | + | "node_modules/stylis": { | |
| 11752 | + | "version": "4.4.0", | |
| 11753 | + | "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", | |
| 11754 | + | "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", | |
| 11755 | + | "license": "MIT" | |
| 11756 | + | }, | |
| 9852 | 11757 | "node_modules/supports-color": { | |
| 9853 | 11758 | "version": "10.2.2", | |
| 9854 | 11759 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", | |
| ⋯ | |||
| 9887 | 11792 | "url": "https://opencollective.com/svgo" | |
| 9888 | 11793 | } | |
| 9889 | 11794 | }, | |
| 11795 | + | "node_modules/tabbable": { | |
| 11796 | + | "version": "6.5.0", | |
| 11797 | + | "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", | |
| 11798 | + | "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", | |
| 11799 | + | "license": "MIT" | |
| 11800 | + | }, | |
| 9890 | 11801 | "node_modules/tailwind-merge": { | |
| 9891 | 11802 | "version": "3.7.0", | |
| 9892 | 11803 | "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", | |
| ⋯ | |||
| 9901 | 11812 | "version": "4.3.3", | |
| 9902 | 11813 | "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", | |
| 9903 | 11814 | "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", | |
| 9904 | − | "dev": true, | |
| 9905 | 11815 | "license": "MIT" | |
| 9906 | 11816 | }, | |
| 9907 | 11817 | "node_modules/tapable": { | |
| ⋯ | |||
| 10028 | 11938 | "url": "https://github.com/sponsors/wooorm" | |
| 10029 | 11939 | } | |
| 10030 | 11940 | }, | |
| 11941 | + | "node_modules/ts-dedent": { | |
| 11942 | + | "version": "2.3.0", | |
| 11943 | + | "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", | |
| 11944 | + | "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", | |
| 11945 | + | "license": "MIT", | |
| 11946 | + | "engines": { | |
| 11947 | + | "node": ">=6.10" | |
| 11948 | + | } | |
| 11949 | + | }, | |
| 10031 | 11950 | "node_modules/tslib": { | |
| 10032 | 11951 | "version": "2.8.1", | |
| 10033 | 11952 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", | |
| ⋯ | |||
| 10430 | 12349 | } | |
| 10431 | 12350 | } | |
| 10432 | 12351 | }, | |
| 12352 | + | "node_modules/use-sync-external-store": { | |
| 12353 | + | "version": "1.6.0", | |
| 12354 | + | "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", | |
| 12355 | + | "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", | |
| 12356 | + | "license": "MIT", | |
| 12357 | + | "peerDependencies": { | |
| 12358 | + | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" | |
| 12359 | + | } | |
| 12360 | + | }, | |
| 10433 | 12361 | "node_modules/util-deprecate": { | |
| 10434 | 12362 | "version": "1.0.2", | |
| 10435 | 12363 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", | |
| 10436 | 12364 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", | |
| 10437 | 12365 | "license": "MIT" | |
| 10438 | 12366 | }, | |
| 12367 | + | "node_modules/uuid": { | |
| 12368 | + | "version": "14.0.2", | |
| 12369 | + | "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", | |
| 12370 | + | "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", | |
| 12371 | + | "funding": [ | |
| 12372 | + | "https://github.com/sponsors/broofa", | |
| 12373 | + | "https://github.com/sponsors/ctavan" | |
| 12374 | + | ], | |
| 12375 | + | "license": "MIT", | |
| 12376 | + | "bin": { | |
| 12377 | + | "uuid": "dist-node/bin/uuid" | |
| 12378 | + | } | |
| 12379 | + | }, | |
| 10439 | 12380 | "node_modules/valibot": { | |
| 10440 | 12381 | "version": "1.5.0", | |
| 10441 | 12382 | "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", | |
| ⋯ | |||
| 10862 | 12803 | } | |
| 10863 | 12804 | } | |
| 10864 | 12805 | }, | |
| 12806 | + | "node_modules/w3c-keyname": { | |
| 12807 | + | "version": "2.2.8", | |
| 12808 | + | "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", | |
| 12809 | + | "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", | |
| 12810 | + | "license": "MIT" | |
| 12811 | + | }, | |
| 10865 | 12812 | "node_modules/web-namespaces": { | |
| 10866 | 12813 | "version": "2.0.1", | |
| 10867 | 12814 | "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", | |
| ⋯ | |||
| 10979 | 12926 | "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", | |
| 10980 | 12927 | "license": "MIT" | |
| 10981 | 12928 | }, | |
| 12929 | + | "node_modules/y-prosemirror": { | |
| 12930 | + | "version": "1.3.7", | |
| 12931 | + | "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", | |
| 12932 | + | "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", | |
| 12933 | + | "license": "MIT", | |
| 12934 | + | "dependencies": { | |
| 12935 | + | "lib0": "^0.2.109" | |
| 12936 | + | }, | |
| 12937 | + | "engines": { | |
| 12938 | + | "node": ">=16.0.0", | |
| 12939 | + | "npm": ">=8.0.0" | |
| 12940 | + | }, | |
| 12941 | + | "funding": { | |
| 12942 | + | "type": "GitHub Sponsors ❤", | |
| 12943 | + | "url": "https://github.com/sponsors/dmonad" | |
| 12944 | + | }, | |
| 12945 | + | "peerDependencies": { | |
| 12946 | + | "prosemirror-model": "^1.7.1", | |
| 12947 | + | "prosemirror-state": "^1.2.3", | |
| 12948 | + | "prosemirror-view": "^1.9.10", | |
| 12949 | + | "y-protocols": "^1.0.1", | |
| 12950 | + | "yjs": "^13.5.38" | |
| 12951 | + | } | |
| 12952 | + | }, | |
| 12953 | + | "node_modules/y-protocols": { | |
| 12954 | + | "version": "1.0.7", | |
| 12955 | + | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", | |
| 12956 | + | "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", | |
| 12957 | + | "license": "MIT", | |
| 12958 | + | "dependencies": { | |
| 12959 | + | "lib0": "^0.2.85" | |
| 12960 | + | }, | |
| 12961 | + | "engines": { | |
| 12962 | + | "node": ">=16.0.0", | |
| 12963 | + | "npm": ">=8.0.0" | |
| 12964 | + | }, | |
| 12965 | + | "funding": { | |
| 12966 | + | "type": "GitHub Sponsors ❤", | |
| 12967 | + | "url": "https://github.com/sponsors/dmonad" | |
| 12968 | + | }, | |
| 12969 | + | "peerDependencies": { | |
| 12970 | + | "yjs": "^13.0.0" | |
| 12971 | + | } | |
| 12972 | + | }, | |
| 10982 | 12973 | "node_modules/y18n": { | |
| 10983 | 12974 | "version": "5.0.8", | |
| 10984 | 12975 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", | |
| ⋯ | |||
| 11056 | 13047 | "fd-slicer": "~1.1.0" | |
| 11057 | 13048 | } | |
| 11058 | 13049 | }, | |
| 13050 | + | "node_modules/yjs": { | |
| 13051 | + | "version": "13.6.33", | |
| 13052 | + | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", | |
| 13053 | + | "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", | |
| 13054 | + | "license": "MIT", | |
| 13055 | + | "dependencies": { | |
| 13056 | + | "lib0": "^0.2.99" | |
| 13057 | + | }, | |
| 13058 | + | "engines": { | |
| 13059 | + | "node": ">=16.0.0", | |
| 13060 | + | "npm": ">=8.0.0" | |
| 13061 | + | }, | |
| 13062 | + | "funding": { | |
| 13063 | + | "type": "GitHub Sponsors ❤", | |
| 13064 | + | "url": "https://github.com/sponsors/dmonad" | |
| 13065 | + | } | |
| 13066 | + | }, | |
| 11059 | 13067 | "node_modules/yocto-queue": { | |
| 11060 | 13068 | "version": "1.2.2", | |
| 11061 | 13069 | "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", | |
| ⋯ | |||
| 11161 | 13169 | "@g1t/contracts": "*" | |
| 11162 | 13170 | } | |
| 11163 | 13171 | }, | |
| 13172 | + | "services/docs": { | |
| 13173 | + | "name": "@g1t/docs-service", | |
| 13174 | + | "version": "0.1.0", | |
| 13175 | + | "license": "MIT", | |
| 13176 | + | "dependencies": { | |
| 13177 | + | "@g1t/contracts": "*", | |
| 13178 | + | "lib0": "^0.2.117", | |
| 13179 | + | "y-protocols": "^1.0.7", | |
| 13180 | + | "yjs": "^13.6.33" | |
| 13181 | + | } | |
| 13182 | + | }, | |
| 11164 | 13183 | "services/models": { | |
| 11165 | 13184 | "name": "@g1t/models", | |
| 11166 | 13185 | "version": "0.1.0", | |
| 1 | + | /** | |
| 2 | + | * Docs: the workspace's written knowledge, kept by the docs service | |
| 3 | + | * (`services/docs`). Spaces hold trees of pages; each page is a CRDT | |
| 4 | + | * document (Yjs) edited live over a socket, saved with a Markdown | |
| 5 | + | * rendition that search, agents, export and the read view use. Plan and | |
| 6 | + | * decisions: docs/WORKSPACE.md, "Docs". | |
| 7 | + | * | |
| 8 | + | * Wire shapes are snake_case end to end: the site, agents and the live | |
| 9 | + | * socket all carry the same objects. | |
| 10 | + | * | |
| 11 | + | * Access, in one place: | |
| 12 | + | * | |
| 13 | + | * - A space has a kind: `workspace` (every member gets `default_role`), | |
| 14 | + | * `team` (the team's members get `default_role`) or `private` (only the | |
| 15 | + | * people, agents and teams listed as its members). | |
| 16 | + | * - Members are listed with a role: view < comment < edit < manage. A | |
| 17 | + | * person's role is the highest of the space's base role (when it applies | |
| 18 | + | * to them) and every listing that names them or one of their teams. | |
| 19 | + | * Workspace owners manage every workspace and team space; a private space | |
| 20 | + | * is its members' alone. | |
| 21 | + | * - An agent never sees or changes more than the person it acts for (the | |
| 22 | + | * `viewer` on every agent call). It reads what that person can read, | |
| 23 | + | * narrowed further to what every person in the `audience` can read; it | |
| 24 | + | * suggests where that person can comment; and it edits directly only | |
| 25 | + | * where that person can edit AND the space lets agents edit | |
| 26 | + | * (`agent_mode: "edit"`). Otherwise its edit becomes a suggestion. | |
| 27 | + | */ | |
| 28 | + | import type { MemberProfile, Principal } from "./chat"; | |
| 29 | + | import type { ServiceBinding } from "./clients"; | |
| 30 | + | import type { User } from "./identity"; | |
| 31 | + | import type { Result } from "./result"; | |
| 32 | + | ||
| 33 | + | /** What someone may do in a space, weakest first. */ | |
| 34 | + | export type DocRole = "view" | "comment" | "edit" | "manage"; | |
| 35 | + | export const DOC_ROLES: readonly DocRole[] = ["view", "comment", "edit", "manage"]; | |
| 36 | + | ||
| 37 | + | export const DOC_ROLE_LABELS: Record<DocRole, string> = { | |
| 38 | + | view: "Can view", | |
| 39 | + | comment: "Can comment", | |
| 40 | + | edit: "Can edit", | |
| 41 | + | manage: "Full access", | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | export const DOC_ROLE_SUMMARIES: Record<DocRole, string> = { | |
| 45 | + | view: "Read pages and their history.", | |
| 46 | + | comment: "Read pages and comment on them.", | |
| 47 | + | edit: "Write and organize pages, and accept or reject suggestions.", | |
| 48 | + | manage: "Edit, and change who has access and how agents work here.", | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | /** Who a space is for. */ | |
| 52 | + | export type DocSpaceKind = "workspace" | "team" | "private"; | |
| 53 | + | ||
| 54 | + | export const DOC_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = { | |
| 55 | + | workspace: "Everyone in the workspace", | |
| 56 | + | team: "A team", | |
| 57 | + | private: "Only its members", | |
| 58 | + | }; | |
| 59 | + | ||
| 60 | + | /** How agents change pages in a space: as tracked suggestions (the default), or directly. */ | |
| 61 | + | export type DocAgentMode = "suggest" | "edit"; | |
| 62 | + | ||
| 63 | + | export const DOC_AGENT_MODE_LABELS: Record<DocAgentMode, string> = { | |
| 64 | + | suggest: "Suggest changes", | |
| 65 | + | edit: "Edit directly", | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | /** A key for a space member: `user:<id>`, `agent:<id>` or `team:<slug>`. */ | |
| 69 | + | export type DocMemberKey = string; | |
| 70 | + | ||
| 71 | + | export type DocSpace = { | |
| 72 | + | id: string; | |
| 73 | + | workspace_id: string; | |
| 74 | + | /** Unique in the workspace; in the URL: `/<workspace>/-/docs/<slug>`. */ | |
| 75 | + | slug: string; | |
| 76 | + | name: string; | |
| 77 | + | description: string | null; | |
| 78 | + | /** An emoji, or null for the default book. */ | |
| 79 | + | icon: string | null; | |
| 80 | + | kind: DocSpaceKind; | |
| 81 | + | /** The team's slug, for a team space. */ | |
| 82 | + | team: string | null; | |
| 83 | + | /** What every workspace member (workspace) or team member (team) gets; null for a private space. */ | |
| 84 | + | default_role: DocRole | null; | |
| 85 | + | agent_mode: DocAgentMode; | |
| 86 | + | /** The workspace's General space, made the first time Docs is opened. Can't be archived. */ | |
| 87 | + | is_default: boolean; | |
| 88 | + | /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */ | |
| 89 | + | projects: string[]; | |
| 90 | + | created_by: Principal; | |
| 91 | + | created_at: string; | |
| 92 | + | archived_at: string | null; | |
| 93 | + | /** The viewer's role in it. */ | |
| 94 | + | viewer_role: DocRole; | |
| 95 | + | /** Pages in it that are not in the trash. */ | |
| 96 | + | page_count: number; | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | /** A space member, as the space's settings show it. */ | |
| 100 | + | export type DocSpaceMember = { | |
| 101 | + | key: DocMemberKey; | |
| 102 | + | kind: "user" | "agent" | "team"; | |
| 103 | + | /** Username, agent handle or team slug. */ | |
| 104 | + | name: string; | |
| 105 | + | display_name: string; | |
| 106 | + | avatar: string | null; | |
| 107 | + | avatar_seed?: string | null; | |
| 108 | + | role: DocRole; | |
| 109 | + | }; | |
| 110 | + | ||
| 111 | + | /** Enough to link to a page. */ | |
| 112 | + | export type DocPageRef = { | |
| 113 | + | id: string; | |
| 114 | + | space_id: string; | |
| 115 | + | space_slug: string; | |
| 116 | + | title: string; | |
| 117 | + | /** An emoji, or null for the default page icon. */ | |
| 118 | + | icon: string | null; | |
| 119 | + | /** `<title-slug>-<id>`: the last part of the page's URL. */ | |
| 120 | + | slug: string; | |
| 121 | + | /** `/<workspace>/-/docs/<space>/<slug>`. */ | |
| 122 | + | path: string; | |
| 123 | + | }; | |
| 124 | + | ||
| 125 | + | export type DocPage = DocPageRef & { | |
| 126 | + | parent_id: string | null; | |
| 127 | + | position: number; | |
| 128 | + | /** A cover image's URL, or a CSS gradient name (`gradient:<n>`); null for none. */ | |
| 129 | + | cover: string | null; | |
| 130 | + | created_by: MemberProfile; | |
| 131 | + | created_at: string; | |
| 132 | + | updated_by: MemberProfile | null; | |
| 133 | + | updated_at: string; | |
| 134 | + | /** When it went to the trash; null when it is not there. */ | |
| 135 | + | archived_at: string | null; | |
| 136 | + | has_children: boolean; | |
| 137 | + | /** Projects (`owner/name`) the page is about, besides its space's. */ | |
| 138 | + | projects: string[]; | |
| 139 | + | owners: MemberProfile[]; | |
| 140 | + | /** The first lines of its text, for cards. */ | |
| 141 | + | excerpt: string; | |
| 142 | + | }; | |
| 143 | + | ||
| 144 | + | /** A page as the sidebar's tree lists it: flat, ordered by `position` within each parent. */ | |
| 145 | + | export type DocTreeNode = { | |
| 146 | + | id: string; | |
| 147 | + | parent_id: string | null; | |
| 148 | + | position: number; | |
| 149 | + | title: string; | |
| 150 | + | icon: string | null; | |
| 151 | + | slug: string; | |
| 152 | + | }; | |
| 153 | + | ||
| 154 | + | export type DocsSidebarSpace = DocSpace & { pages: DocTreeNode[] }; | |
| 155 | + | ||
| 156 | + | export type DocsSidebar = { | |
| 157 | + | spaces: DocsSidebarSpace[]; | |
| 158 | + | favorites: DocPageRef[]; | |
| 159 | + | /** The pages the viewer opened last, newest first. */ | |
| 160 | + | recent: DocPageRef[]; | |
| 161 | + | /** Whether the viewer may make spaces (members of the workspace may). */ | |
| 162 | + | can_create_space: boolean; | |
| 163 | + | trash_count: number; | |
| 164 | + | }; | |
| 165 | + | ||
| 166 | + | export type DocsHome = { | |
| 167 | + | /** Recently edited pages the viewer can read, newest first. */ | |
| 168 | + | recent: DocPage[]; | |
| 169 | + | /** Pages the viewer made or owns. */ | |
| 170 | + | mine: DocPage[]; | |
| 171 | + | spaces: DocSpace[]; | |
| 172 | + | /** Every project some space or page is linked to, for the filter. */ | |
| 173 | + | projects: string[]; | |
| 174 | + | /** The project the lists are filtered to, or null. */ | |
| 175 | + | project: string | null; | |
| 176 | + | }; | |
| 177 | + | ||
| 178 | + | /** A tracked change an agent proposed. `blocks` targets name top-level block ids from `page_markdown`. */ | |
| 179 | + | export type DocEditTarget = | |
| 180 | + | /** Add to the end of the page. */ | |
| 181 | + | | { kind: "append" } | |
| 182 | + | /** Replace the whole page. */ | |
| 183 | + | | { kind: "document" } | |
| 184 | + | /** Replace a section: the heading whose text matches (case-insensitive) and everything under it, up to the next heading of the same or a higher level. The new Markdown should include the heading if it is to stay. */ | |
| 185 | + | | { kind: "section"; heading: string } | |
| 186 | + | /** Replace top-level blocks `from_block` through `to_block`, inclusive (with any blocks nested under them). */ | |
| 187 | + | | { kind: "blocks"; from_block: string; to_block: string }; | |
| 188 | + | ||
| 189 | + | export type DocSuggestionStatus = "open" | "accepted" | "rejected" | "stale"; | |
| 190 | + | ||
| 191 | + | export type DocSuggestion = { | |
| 192 | + | id: string; | |
| 193 | + | page_id: string; | |
| 194 | + | /** The agent that suggested it. */ | |
| 195 | + | author: MemberProfile; | |
| 196 | + | /** The person it acted for. */ | |
| 197 | + | asked_by: MemberProfile | null; | |
| 198 | + | target: DocEditTarget; | |
| 199 | + | /** The target's Markdown when it was suggested. */ | |
| 200 | + | before_markdown: string; | |
| 201 | + | /** What it proposes instead. */ | |
| 202 | + | after_markdown: string; | |
| 203 | + | /** Why, in a line. */ | |
| 204 | + | note: string | null; | |
| 205 | + | status: DocSuggestionStatus; | |
| 206 | + | created_at: string; | |
| 207 | + | decided_by: MemberProfile | null; | |
| 208 | + | decided_at: string | null; | |
| 209 | + | /** The top-level blocks it covers now, so the editor can mark them; empty for an append or when the target is gone. */ | |
| 210 | + | block_ids: string[]; | |
| 211 | + | }; | |
| 212 | + | ||
| 213 | + | export type DocVersionKind = "created" | "edit" | "agent" | "suggestion" | "restore"; | |
| 214 | + | ||
| 215 | + | export type DocVersion = { | |
| 216 | + | id: string; | |
| 217 | + | page_id: string; | |
| 218 | + | created_at: string; | |
| 219 | + | /** Everyone whose changes are in it, people and agents. */ | |
| 220 | + | authors: MemberProfile[]; | |
| 221 | + | kind: DocVersionKind; | |
| 222 | + | /** "Suggested by @inky, accepted by @ana"; "Restored from Oct 3, 14:02". */ | |
| 223 | + | note: string | null; | |
| 224 | + | }; | |
| 225 | + | ||
| 226 | + | export type DocDiffLine = { op: "same" | "add" | "del"; text: string }; | |
| 227 | + | ||
| 228 | + | export type DocVersionDetail = DocVersion & { | |
| 229 | + | markdown: string; | |
| 230 | + | /** Against the version before it (or nothing, for the first). */ | |
| 231 | + | diff: DocDiffLine[]; | |
| 232 | + | }; | |
| 233 | + | ||
| 234 | + | export type DocPageDetail = { | |
| 235 | + | page: DocPage; | |
| 236 | + | space: DocSpace; | |
| 237 | + | /** The page's ancestors, root first. */ | |
| 238 | + | breadcrumbs: DocPageRef[]; | |
| 239 | + | /** As last saved: the read view, and what the editor shows before the socket connects. */ | |
| 240 | + | markdown: string; | |
| 241 | + | role: DocRole; | |
| 242 | + | backlinks: DocPageRef[]; | |
| 243 | + | children: DocPageRef[]; | |
| 244 | + | favorite: boolean; | |
| 245 | + | /** When the viewer last opened it, before now. */ | |
| 246 | + | last_viewed_at: string | null; | |
| 247 | + | suggestions: DocSuggestion[]; | |
| 248 | + | }; | |
| 249 | + | ||
| 250 | + | export type DocSearchHit = DocPageRef & { | |
| 251 | + | space_name: string; | |
| 252 | + | /** Text around the match; `[[` and `]]` mark matched words. */ | |
| 253 | + | snippet: string; | |
| 254 | + | updated_at: string; | |
| 255 | + | projects: string[]; | |
| 256 | + | }; | |
| 257 | + | ||
| 258 | + | export type DocTemplate = { | |
| 259 | + | id: string; | |
| 260 | + | name: string; | |
| 261 | + | description: string; | |
| 262 | + | icon: string; | |
| 263 | + | /** Built in (meeting notes, RFC, ...) rather than saved by the workspace. */ | |
| 264 | + | builtin: boolean; | |
| 265 | + | markdown: string; | |
| 266 | + | created_by: Principal | null; | |
| 267 | + | }; | |
| 268 | + | ||
| 269 | + | /** One top-level block, as agents see a page. */ | |
| 270 | + | export type DocBlockOutline = { | |
| 271 | + | id: string; | |
| 272 | + | /** BlockNote's type: `heading`, `paragraph`, `bulletListItem`, `codeBlock`, `callout`, ... */ | |
| 273 | + | type: string; | |
| 274 | + | /** For a heading. */ | |
| 275 | + | level: number | null; | |
| 276 | + | /** The block and anything nested under it. */ | |
| 277 | + | markdown: string; | |
| 278 | + | }; | |
| 279 | + | ||
| 280 | + | /** What an agent may do on a page, for the person it acts for. */ | |
| 281 | + | export type DocAgentAbilities = { read: boolean; suggest: boolean; edit: boolean }; | |
| 282 | + | ||
| 283 | + | export type DocAgentPage = { | |
| 284 | + | page: DocPageRef & { updated_at: string }; | |
| 285 | + | space: { id: string; slug: string; name: string; agent_mode: DocAgentMode }; | |
| 286 | + | markdown: string; | |
| 287 | + | blocks: DocBlockOutline[]; | |
| 288 | + | can: DocAgentAbilities; | |
| 289 | + | }; | |
| 290 | + | ||
| 291 | + | /** Who will see what an agent says; it reads only what they all can. */ | |
| 292 | + | export type DocAudience = | |
| 293 | + | /** These people (by user id), e.g. a DM's or a private channel's members. */ | |
| 294 | + | | { kind: "people"; user_ids: string[] } | |
| 295 | + | /** Everyone in the workspace, e.g. a public channel: only workspace-wide spaces. */ | |
| 296 | + | | { kind: "workspace" }; | |
| 297 | + | ||
| 298 | + | /** What `apply_edit` did: applied it, or filed a suggestion because it may not edit there. */ | |
| 299 | + | export type DocAgentEditResult = | |
| 300 | + | | { mode: "applied"; version_id: string | null; page: DocPageRef } | |
| 301 | + | | { mode: "suggested"; suggestion: DocSuggestion; page: DocPageRef }; | |
| 302 | + | ||
| 303 | + | export type NewDocSpace = { | |
| 304 | + | name: string; | |
| 305 | + | slug?: string | null; | |
| 306 | + | description?: string | null; | |
| 307 | + | icon?: string | null; | |
| 308 | + | kind: DocSpaceKind; | |
| 309 | + | team?: string | null; | |
| 310 | + | default_role?: DocRole | null; | |
| 311 | + | agent_mode?: DocAgentMode | null; | |
| 312 | + | projects?: string[] | null; | |
| 313 | + | }; | |
| 314 | + | ||
| 315 | + | export type DocSpaceChange = Partial<Omit<NewDocSpace, "kind">> & { kind?: DocSpaceKind; archived?: boolean }; | |
| 316 | + | ||
| 317 | + | export type NewDocPage = { | |
| 318 | + | space_id: string; | |
| 319 | + | parent_id?: string | null; | |
| 320 | + | title?: string | null; | |
| 321 | + | icon?: string | null; | |
| 322 | + | /** A built-in template's id (`builtin:<name>`) or a saved one's. */ | |
| 323 | + | template_id?: string | null; | |
| 324 | + | /** Starting Markdown, when there is no template. */ | |
| 325 | + | markdown?: string | null; | |
| 326 | + | projects?: string[] | null; | |
| 327 | + | }; | |
| 328 | + | ||
| 329 | + | export type DocPageChange = { | |
| 330 | + | title?: string; | |
| 331 | + | icon?: string | null; | |
| 332 | + | cover?: string | null; | |
| 333 | + | projects?: string[]; | |
| 334 | + | /** Member keys (`user:<id>`, `agent:<id>`). */ | |
| 335 | + | owners?: string[]; | |
| 336 | + | }; | |
| 337 | + | ||
| 338 | + | /** Where a page goes: under `parent_id` (null for the top of the space), before `before_id` (null for the end). */ | |
| 339 | + | export type DocMove = { space_id?: string | null; parent_id: string | null; before_id?: string | null }; | |
| 340 | + | ||
| 341 | + | export type DocSearchQuery = { | |
| 342 | + | query: string; | |
| 343 | + | space_id?: string | null; | |
| 344 | + | /** `owner/name`: pages linked to it, or in a space linked to it. */ | |
| 345 | + | project?: string | null; | |
| 346 | + | limit?: number | null; | |
| 347 | + | }; | |
| 348 | + | ||
| 349 | + | /** | |
| 350 | + | * The comment operations of the editor's thread store (BlockNote's | |
| 351 | + | * `RESTYjsThreadStore`), applied by the page's room to the `threads` map in | |
| 352 | + | * the page's Yjs document, so every open editor sees them at once. | |
| 353 | + | */ | |
| 354 | + | export type DocThreadAction = | |
| 355 | + | | { op: "create"; body: unknown; metadata?: unknown; page_level?: boolean } | |
| 356 | + | /** Anchors a thread to the text between two Yjs relative positions (JSON). */ | |
| 357 | + | | { op: "anchor"; thread_id: string; anchor: unknown; head: unknown } | |
| 358 | + | | { op: "comment"; thread_id: string; body: unknown; metadata?: unknown } | |
| 359 | + | | { op: "edit_comment"; thread_id: string; comment_id: string; body: unknown; metadata?: unknown } | |
| 360 | + | | { op: "delete_comment"; thread_id: string; comment_id: string; soft?: boolean } | |
| 361 | + | | { op: "delete_thread"; thread_id: string } | |
| 362 | + | | { op: "resolve" | "unresolve"; thread_id: string } | |
| 363 | + | | { op: "react" | "unreact"; thread_id: string; comment_id: string; emoji: string }; | |
| 364 | + | ||
| 365 | + | /** A comment thread, as agents and the inbox see it. */ | |
| 366 | + | export type DocThread = { | |
| 367 | + | id: string; | |
| 368 | + | /** The text it is on; null for a comment on the whole page. */ | |
| 369 | + | quote: string | null; | |
| 370 | + | resolved: boolean; | |
| 371 | + | comments: { id: string; author: MemberProfile; text: string; created_at: string }[]; | |
| 372 | + | }; | |
| 373 | + | ||
| 374 | + | /** A file put in a page: served from the usercontent origin. */ | |
| 375 | + | export type DocFile = { id: string; url: string; name: string; content_type: string; bytes: number }; | |
| 376 | + | ||
| 377 | + | /** | |
| 378 | + | * What a page's live socket carries besides the Yjs protocol (binary | |
| 379 | + | * frames: y-protocols sync and awareness). These are JSON text frames. | |
| 380 | + | */ | |
| 381 | + | export type DocsLiveEvent = | |
| 382 | + | | { type: "page.updated"; page: DocPage } | |
| 383 | + | | { type: "page.archived"; page_id: string } | |
| 384 | + | | { type: "suggestion.created" | "suggestion.updated"; suggestion: DocSuggestion } | |
| 385 | + | | { type: "version.created"; version: DocVersion } | |
| 386 | + | /** The viewer's role changed, or their access ended (`role` null). */ | |
| 387 | + | | { type: "access"; role: DocRole | null }; | |
| 388 | + | ||
| 389 | + | /** | |
| 390 | + | * Header the site sets on a forwarded live socket and on uploads: the | |
| 391 | + | * viewer, as JSON. Trusted only because the docs service is reachable | |
| 392 | + | * through service bindings alone. | |
| 393 | + | */ | |
| 394 | + | export const DOCS_VIEWER_HEADER = "x-g1t-docs-viewer"; | |
| 395 | + | ||
| 396 | + | /** The largest file a page takes, in bytes. */ | |
| 397 | + | export const DOC_MAX_FILE_BYTES = 25 * 1024 * 1024; | |
| 398 | + | ||
| 399 | + | /** The name of the Yjs XML fragment that holds a page's blocks. */ | |
| 400 | + | export const DOC_FRAGMENT = "document-store"; | |
| 401 | + | /** The name of the Yjs map that holds a page's comment threads. */ | |
| 402 | + | export const DOC_THREADS = "threads"; | |
| 403 | + | ||
| 404 | + | export type DocsApi = { | |
| 405 | + | // ── The site ───────────────────────────────────────────────────────── | |
| 406 | + | ||
| 407 | + | /** Spaces with their page trees, favorites, recent pages. Makes the General space the first time. */ | |
| 408 | + | sidebar(workspace: string, viewer: User): Promise<Result<DocsSidebar>>; | |
| 409 | + | home(workspace: string, viewer: User, options?: { project?: string | null }): Promise<Result<DocsHome>>; | |
| 410 | + | space(workspace: string, spaceSlug: string, viewer: User): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>>; | |
| 411 | + | createSpace(workspace: string, viewer: User, input: NewDocSpace): Promise<Result<DocSpace>>; | |
| 412 | + | /** Manage role only. */ | |
| 413 | + | updateSpace(workspace: string, spaceId: string, viewer: User, change: DocSpaceChange): Promise<Result<DocSpace>>; | |
| 414 | + | /** Adds, changes (`role`) or removes (`role` null) a member. Manage role only; the last manager stays. */ | |
| 415 | + | setSpaceMember(workspace: string, spaceId: string, viewer: User, member: DocMemberKey, role: DocRole | null): Promise<Result<DocSpaceMember[]>>; | |
| 416 | + | ||
| 417 | + | /** A page, and the viewer's role in it; records the view. Not found when they can't read it. */ | |
| 418 | + | page(workspace: string, pageId: string, viewer: User): Promise<Result<DocPageDetail>>; | |
| 419 | + | createPage(workspace: string, viewer: User, input: NewDocPage): Promise<Result<DocPage>>; | |
| 420 | + | updatePage(workspace: string, pageId: string, viewer: User, change: DocPageChange): Promise<Result<DocPage>>; | |
| 421 | + | /** Edit role in both spaces. Refuses moving a page under itself. */ | |
| 422 | + | movePage(workspace: string, pageId: string, viewer: User, move: DocMove): Promise<Result<DocPage>>; | |
| 423 | + | duplicatePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; | |
| 424 | + | /** To the trash, with every page under it. */ | |
| 425 | + | archivePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; | |
| 426 | + | restorePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; | |
| 427 | + | /** For good: only from the trash, manage role. */ | |
| 428 | + | deletePage(workspace: string, pageId: string, viewer: User): Promise<Result<boolean>>; | |
| 429 | + | trash(workspace: string, viewer: User): Promise<Result<DocPage[]>>; | |
| 430 | + | favorite(workspace: string, pageId: string, viewer: User, on: boolean): Promise<Result<boolean>>; | |
| 431 | + | ||
| 432 | + | search(workspace: string, viewer: User, query: DocSearchQuery): Promise<Result<DocSearchHit[]>>; | |
| 433 | + | ||
| 434 | + | versions(workspace: string, pageId: string, viewer: User): Promise<Result<DocVersion[]>>; | |
| 435 | + | version(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersionDetail>>; | |
| 436 | + | /** Makes the page what it was then, as a new version. Edit role. */ | |
| 437 | + | restoreVersion(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersion>>; | |
| 438 | + | ||
| 439 | + | templates(workspace: string, viewer: User): Promise<Result<DocTemplate[]>>; | |
| 440 | + | /** Saves a page as one of the workspace's templates. */ | |
| 441 | + | saveTemplate(workspace: string, viewer: User, input: { page_id: string; name: string; description?: string | null }): Promise<Result<DocTemplate>>; | |
| 442 | + | deleteTemplate(workspace: string, templateId: string, viewer: User): Promise<Result<boolean>>; | |
| 443 | + | ||
| 444 | + | exportPage(workspace: string, pageId: string, viewer: User): Promise<Result<{ filename: string; markdown: string }>>; | |
| 445 | + | /** Every page in a space as Markdown, at paths that follow the tree. */ | |
| 446 | + | exportSpace(workspace: string, spaceId: string, viewer: User): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>>; | |
| 447 | + | ||
| 448 | + | suggestions(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>; | |
| 449 | + | /** Accept (applies it to the live document, attributed to both) or reject. Edit role. */ | |
| 450 | + | decideSuggestion(workspace: string, suggestionId: string, viewer: User, decision: "accept" | "reject"): Promise<Result<DocSuggestion>>; | |
| 451 | + | acceptAll(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>; | |
| 452 | + | ||
| 453 | + | /** A comment operation from the editor; comment role (deleting others' comments or threads needs edit). */ | |
| 454 | + | thread(workspace: string, pageId: string, viewer: User, action: DocThreadAction): Promise<Result<unknown>>; | |
| 455 | + | threads(workspace: string, pageId: string, viewer: User): Promise<Result<DocThread[]>>; | |
| 456 | + | ||
| 457 | + | // ── Agents (services/agents) ───────────────────────────────────────── | |
| 458 | + | // | |
| 459 | + | // Each takes the agent and the person it acts for (`viewer`, the asker). | |
| 460 | + | // The service checks the agent is a live agent of the workspace and caps | |
| 461 | + | // everything by the viewer's access; `audience`, when given, narrows | |
| 462 | + | // reads further to what every person in it can read. A page the agent | |
| 463 | + | // may not read is not found, exactly as one that does not exist. | |
| 464 | + | ||
| 465 | + | /** Spaces the agent may read for the viewer (and audience), with what it may do in each. */ | |
| 466 | + | spacesForAgent(workspace: string, agentId: string, viewer: User, audience?: DocAudience | null): Promise<Result<(Pick<DocSpace, "id" | "slug" | "name" | "description" | "kind" | "agent_mode" | "projects"> & { can: DocAgentAbilities })[]>>; | |
| 467 | + | /** A page's Markdown and its top-level blocks (ids for `blocks` targets). */ | |
| 468 | + | pageMarkdown(workspace: string, agentId: string, viewer: User, pageId: string, audience?: DocAudience | null): Promise<Result<DocAgentPage>>; | |
| 469 | + | /** Full-text search over pages every reader can read; at most 20, best first. */ | |
| 470 | + | searchForAgent(workspace: string, agentId: string, viewer: User, query: DocSearchQuery, audience?: DocAudience | null): Promise<Result<DocSearchHit[]>>; | |
| 471 | + | /** Files a tracked suggestion; people with edit access accept or reject it inline. Needs the viewer's comment role. Notifies the page's owners. */ | |
| 472 | + | suggestEdit(workspace: string, agentId: string, viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note?: string | null }): Promise<Result<DocSuggestion>>; | |
| 473 | + | /** | |
| 474 | + | * Applies an edit to the live document when the space lets agents edit | |
| 475 | + | * and the viewer can edit; otherwise files it as a suggestion (and says | |
| 476 | + | * so in `mode`). Attributed to the agent in the page's history. | |
| 477 | + | */ | |
| 478 | + | applyEdit(workspace: string, agentId: string, viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note?: string | null }): Promise<Result<DocAgentEditResult>>; | |
| 479 | + | /** | |
| 480 | + | * A new page, written by the agent: in `space_id` (the General space | |
| 481 | + | * when null), under `parent_id`. Needs the viewer's edit role there | |
| 482 | + | * (whatever the space's agent mode: a new page changes nothing anyone | |
| 483 | + | * wrote). `source` links where it came from ("write this up"). | |
| 484 | + | */ | |
| 485 | + | createPageAsAgent( | |
| 486 | + | workspace: string, | |
| 487 | + | agentId: string, | |
| 488 | + | viewer: User, | |
| 489 | + | input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null }, | |
| 490 | + | ): Promise<Result<DocPageRef>>; | |
| 491 | + | /** A page's comment threads, for an agent asked about them. */ | |
| 492 | + | threadsForAgent(workspace: string, agentId: string, viewer: User, pageId: string, audience?: DocAudience | null): Promise<Result<DocThread[]>>; | |
| 493 | + | }; | |
| 494 | + | ||
| 495 | + | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| 496 | + | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 497 | + | method: "POST", | |
| 498 | + | headers: { "content-type": "application/json" }, | |
| 499 | + | body: JSON.stringify(args), | |
| 500 | + | }); | |
| 501 | + | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 502 | + | return (await response.json()) as T; | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | export function docsClient(service: ServiceBinding): DocsApi { | |
| 506 | + | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 507 | + | return { | |
| 508 | + | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), | |
| 509 | + | home: (workspace, viewer, options) => call("home", { workspace, viewer, project: options?.project ?? null }), | |
| 510 | + | space: (workspace, spaceSlug, viewer) => call("space", { workspace, space: spaceSlug, viewer }), | |
| 511 | + | createSpace: (workspace, viewer, input) => call("create_space", { workspace, viewer, input }), | |
| 512 | + | updateSpace: (workspace, spaceId, viewer, change) => call("update_space", { workspace, space_id: spaceId, viewer, change }), | |
| 513 | + | setSpaceMember: (workspace, spaceId, viewer, member, role) => call("set_space_member", { workspace, space_id: spaceId, viewer, member, role }), | |
| 514 | + | page: (workspace, pageId, viewer) => call("page", { workspace, page_id: pageId, viewer }), | |
| 515 | + | createPage: (workspace, viewer, input) => call("create_page", { workspace, viewer, input }), | |
| 516 | + | updatePage: (workspace, pageId, viewer, change) => call("update_page", { workspace, page_id: pageId, viewer, change }), | |
| 517 | + | movePage: (workspace, pageId, viewer, move) => call("move_page", { workspace, page_id: pageId, viewer, move }), | |
| 518 | + | duplicatePage: (workspace, pageId, viewer) => call("duplicate_page", { workspace, page_id: pageId, viewer }), | |
| 519 | + | archivePage: (workspace, pageId, viewer) => call("archive_page", { workspace, page_id: pageId, viewer }), | |
| 520 | + | restorePage: (workspace, pageId, viewer) => call("restore_page", { workspace, page_id: pageId, viewer }), | |
| 521 | + | deletePage: (workspace, pageId, viewer) => call("delete_page", { workspace, page_id: pageId, viewer }), | |
| 522 | + | trash: (workspace, viewer) => call("trash", { workspace, viewer }), | |
| 523 | + | favorite: (workspace, pageId, viewer, on) => call("favorite", { workspace, page_id: pageId, viewer, on }), | |
| 524 | + | search: (workspace, viewer, query) => call("search", { workspace, viewer, query }), | |
| 525 | + | versions: (workspace, pageId, viewer) => call("versions", { workspace, page_id: pageId, viewer }), | |
| 526 | + | version: (workspace, pageId, versionId, viewer) => call("version", { workspace, page_id: pageId, version_id: versionId, viewer }), | |
| 527 | + | restoreVersion: (workspace, pageId, versionId, viewer) => call("restore_version", { workspace, page_id: pageId, version_id: versionId, viewer }), | |
| 528 | + | templates: (workspace, viewer) => call("templates", { workspace, viewer }), | |
| 529 | + | saveTemplate: (workspace, viewer, input) => call("save_template", { workspace, viewer, input }), | |
| 530 | + | deleteTemplate: (workspace, templateId, viewer) => call("delete_template", { workspace, template_id: templateId, viewer }), | |
| 531 | + | exportPage: (workspace, pageId, viewer) => call("export_page", { workspace, page_id: pageId, viewer }), | |
| 532 | + | exportSpace: (workspace, spaceId, viewer) => call("export_space", { workspace, space_id: spaceId, viewer }), | |
| 533 | + | suggestions: (workspace, pageId, viewer) => call("suggestions", { workspace, page_id: pageId, viewer }), | |
| 534 | + | decideSuggestion: (workspace, suggestionId, viewer, decision) => call("decide_suggestion", { workspace, suggestion_id: suggestionId, viewer, decision }), | |
| 535 | + | acceptAll: (workspace, pageId, viewer) => call("accept_all", { workspace, page_id: pageId, viewer }), | |
| 536 | + | thread: (workspace, pageId, viewer, action) => call("thread", { workspace, page_id: pageId, viewer, action }), | |
| 537 | + | threads: (workspace, pageId, viewer) => call("threads", { workspace, page_id: pageId, viewer }), | |
| 538 | + | spacesForAgent: (workspace, agentId, viewer, audience) => call("spaces_for_agent", { workspace, agent_id: agentId, viewer, audience: audience ?? null }), | |
| 539 | + | pageMarkdown: (workspace, agentId, viewer, pageId, audience) => | |
| 540 | + | call("page_markdown", { workspace, agent_id: agentId, viewer, page_id: pageId, audience: audience ?? null }), | |
| 541 | + | searchForAgent: (workspace, agentId, viewer, query, audience) => call("search_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }), | |
| 542 | + | suggestEdit: (workspace, agentId, viewer, pageId, edit) => call("suggest_edit", { workspace, agent_id: agentId, viewer, page_id: pageId, edit }), | |
| 543 | + | applyEdit: (workspace, agentId, viewer, pageId, edit) => call("apply_edit", { workspace, agent_id: agentId, viewer, page_id: pageId, edit }), | |
| 544 | + | createPageAsAgent: (workspace, agentId, viewer, input) => call("create_page_as_agent", { workspace, agent_id: agentId, viewer, input }), | |
| 545 | + | threadsForAgent: (workspace, agentId, viewer, pageId, audience) => | |
| 546 | + | call("threads_for_agent", { workspace, agent_id: agentId, viewer, page_id: pageId, audience: audience ?? null }), | |
| 547 | + | }; | |
| 548 | + | } |
| 13 | 13 | export * from "./compute"; | |
| 14 | 14 | export * from "./context"; | |
| 15 | 15 | export * from "./d1"; | |
| 16 | + | export * from "./docs"; | |
| 16 | 17 | export * from "./deploy-keys"; | |
| 17 | 18 | export * from "./deployments"; | |
| 18 | 19 | export * from "./events"; |
| 7 | 7 | import { | |
| 8 | 8 | type ServiceBinding, | |
| 9 | 9 | type User, | |
| 10 | + | type DocPageRef, | |
| 10 | 11 | chatClient, | |
| 12 | + | docsClient, | |
| 11 | 13 | identityClient, | |
| 12 | 14 | reposClient, | |
| 13 | 15 | searchClient, | |
| ⋯ | |||
| 15 | 17 | } from "@g1t/contracts"; | |
| 16 | 18 | ||
| 17 | 19 | import type { AudiencePorts, RepoRef } from "./audience.ts"; | |
| 18 | − | import type { FoundMessage, ToolPorts } from "./tools.ts"; | |
| 20 | + | import type { DocsPorts, FoundMessage, ToolPorts } from "./tools.ts"; | |
| 19 | 21 | ||
| 20 | 22 | export type PortsEnv = { | |
| 21 | 23 | DB: D1Database; | |
| ⋯ | |||
| 24 | 26 | REPOS: ServiceBinding; | |
| 25 | 27 | WORK: ServiceBinding; | |
| 26 | 28 | SEARCH: ServiceBinding; | |
| 29 | + | /** Docs, for agents reading and writing pages; absent on an installation without it. */ | |
| 30 | + | DOCS?: ServiceBinding; | |
| 27 | 31 | }; | |
| 28 | 32 | ||
| 29 | 33 | const refOf = (repo: { id: string; namespace: string; name: string; isPrivate: boolean; defaultBranch: string; forkOf?: string | null }): RepoRef => ({ | |
| ⋯ | |||
| 64 | 68 | workspaceId: string, | |
| 65 | 69 | channelId: string, | |
| 66 | 70 | consult: ToolPorts["consult"], | |
| 71 | + | agentId: string | null = null, | |
| 67 | 72 | ): ToolPorts { | |
| 68 | 73 | const repos = reposClient(env.REPOS); | |
| 69 | 74 | const work = workClient(env.WORK); | |
| ⋯ | |||
| 151 | 156 | return `People:\n${people}${teamLines ? `\n\nTeams:\n${teamLines}` : ""}\n\nAgents:\n${agentLines}`; | |
| 152 | 157 | }, | |
| 153 | 158 | consult, | |
| 159 | + | ...(env.DOCS && agentId ? { docs: docsPorts(env.DOCS, workspace, agentId) } : {}), | |
| 160 | + | }; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /** Docs as an agent reads and writes them: Markdown in, Markdown out, every call checked by the docs service. */ | |
| 164 | + | function docsPorts(binding: ServiceBinding, workspace: string, agentId: string): DocsPorts { | |
| 165 | + | const docs = docsClient(binding); | |
| 166 | + | const where = (page: DocPageRef) => `${page.title} (${page.path}, id ${page.id})`; | |
| 167 | + | return { | |
| 168 | + | async spaces(viewer, audience) { | |
| 169 | + | const found = await docs.spacesForAgent(workspace, agentId, viewer, audience); | |
| 170 | + | if (!found.ok) return null; | |
| 171 | + | if (!found.value.length) return "There are no Docs spaces everyone here can read."; | |
| 172 | + | return found.value | |
| 173 | + | .map((s) => { | |
| 174 | + | const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only"; | |
| 175 | + | const projects = s.projects?.length ? `; about ${s.projects.join(", ")}` : ""; | |
| 176 | + | return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`; | |
| 177 | + | }) | |
| 178 | + | .join("\n"); | |
| 179 | + | }, | |
| 180 | + | async search(viewer, audience, query, project) { | |
| 181 | + | const found = await docs.searchForAgent(workspace, agentId, viewer, { query, project, limit: 10 }, audience); | |
| 182 | + | if (!found.ok) return null; | |
| 183 | + | if (!found.value.length) return "No pages found."; | |
| 184 | + | return found.value.map((hit) => `- ${where(hit)} in ${hit.space_name}, updated ${hit.updated_at.slice(0, 10)}: ${hit.snippet.replace(/\[\[|\]\]/g, "")}`).join("\n"); | |
| 185 | + | }, | |
| 186 | + | async read(viewer, audience, pageId) { | |
| 187 | + | const found = await docs.pageMarkdown(workspace, agentId, viewer, pageId, audience); | |
| 188 | + | if (!found.ok) return null; | |
| 189 | + | const p = found.value; | |
| 190 | + | const can = p.can.edit ? "you can edit it" : p.can.suggest ? "you can suggest edits" : "you can only read it"; | |
| 191 | + | const blocks = p.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", "); | |
| 192 | + | return `# ${where(p.page)}\nSpace: ${p.space.name}; ${can}. Updated ${p.page.updated_at.slice(0, 16)}.\nTop-level blocks: ${blocks}\n\n${p.markdown}`; | |
| 193 | + | }, | |
| 194 | + | async edit(viewer, pageId, edit, suggestOnly) { | |
| 195 | + | const done = suggestOnly | |
| 196 | + | ? await docs.suggestEdit(workspace, agentId, viewer, pageId, edit).then((r) => (r.ok ? { ok: true as const, value: { mode: "suggested" as const, suggestion: r.value, page: null } } : r)) | |
| 197 | + | : await docs.applyEdit(workspace, agentId, viewer, pageId, edit); | |
| 198 | + | if (!done.ok) return { ok: false, message: `That didn't work: ${done.error.message}` }; | |
| 199 | + | const v = done.value; | |
| 200 | + | const page = v.page ? ` on ${where(v.page)}` : ""; | |
| 201 | + | return v.mode === "applied" | |
| 202 | + | ? { ok: true, message: `Changed${page}. It's in the page's history as yours.` } | |
| 203 | + | : { ok: true, message: `Suggested${page}: people accept or reject it on the page. Link the page so they can.` }; | |
| 204 | + | }, | |
| 205 | + | async create(viewer, input) { | |
| 206 | + | const made = await docs.createPageAsAgent(workspace, agentId, viewer, input); | |
| 207 | + | if (!made.ok) return { ok: false, message: `The page couldn't be made: ${made.error.message}` }; | |
| 208 | + | return { ok: true, message: `Wrote ${where(made.value)}. Link it.` }; | |
| 209 | + | }, | |
| 154 | 210 | }; | |
| 155 | 211 | } | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.