Compare commits

..

152 Commits

Author SHA1 Message Date
Philipinho 123771e841 ms 2026-04-23 19:40:27 +01:00
Philipinho 8c21675a75 fix(base): update base.module import to renamed QueryCacheModule 2026-04-23 16:59:28 +01:00
Philipinho 02a78b2ec7 test(base): wire integration + parity specs to duckdb runtime 2026-04-23 16:58:16 +01:00
Philipinho dbc1eb539c fix(base): serialize writer operations and prune dead code in cache service 2026-04-23 16:50:11 +01:00
Philipinho 38cd94b2d7 refactor(base): single duckdb instance with per-base attached databases 2026-04-23 16:40:14 +01:00
Philipinho 4437dcbb62 feat(base): single-instance duckdb runtime with writer + reader pool 2026-04-23 16:23:24 +01:00
Philipinho 568d94be1f feat(base): schema-qualified query builder for single-instance duckdb 2026-04-23 16:19:47 +01:00
Philipinho f12a0675ea feat(base): schema-qualified loader sql for single-instance duckdb 2026-04-23 16:15:42 +01:00
Philipinho 838d8892f0 feat(base): minimal async connection pool for duckdb reader pool 2026-04-23 16:10:32 +01:00
Philipinho 08711791d6 feat(base): add baseSchemaName helper for duckdb schema naming 2026-04-23 16:05:45 +01:00
Philipinho b04bcb5b0c feat(base): env var for duckdb reader-pool size 2026-04-23 15:52:35 +01:00
Philipinho 709d927544 fix(base): declare primary key on loaded rows so upsert has a conflict target 2026-04-23 14:31:26 +01:00
Philipinho 5b96dfe6c9 feat(base): log duckdb heap + spill per base on cold load 2026-04-23 14:07:36 +01:00
Philipinho 17db634029 fix(base): enable duckdb disk spill + raise memory default to avoid oom on large bases 2026-04-23 13:56:31 +01:00
Philipinho 5ebab5cd9e fix(base): make cell-extractor pg functions genuinely parallel-safe
The plpgsql + EXCEPTION versions of base_cell_numeric,
base_cell_timestamptz, and base_cell_bool were labeled PARALLEL SAFE
but EXCEPTION blocks require subtransactions, which Postgres cannot
start in a parallel worker. Any parallel scan that invoked them
crashed with 'cannot start subtransactions during a parallel
operation' — notably DuckDB's postgres extension on large base COPY
reads.

Rewrite each as a pure SQL function using jsonb_typeof + regex
validation for the 'coerce-or-null' semantics. No plpgsql, no
subtransactions, genuinely parallel-safe. Signatures unchanged so
existing call sites (loader, expression indexes, engine predicates)
are untouched.
2026-04-23 13:52:20 +01:00
Philipinho 2d9e060d9e feat(base): add BASE_QUERY_CACHE_TRACE flag for duckdb operation logging 2026-04-23 13:37:25 +01:00
Philipinho b2ed8f9936 Revert "refactor(base): use uuid package instead of inlined uuid7 in tests"
This reverts commit f819f633c9.
2026-04-23 13:14:09 +01:00
Philipinho 7192b4bacb Revert "refactor(base): use uuid package validator in loader-sql"
This reverts commit cfc50b7cae.
2026-04-23 13:14:09 +01:00
Philipinho cfc50b7cae refactor(base): use uuid package validator in loader-sql 2026-04-23 13:09:25 +01:00
Philipinho f819f633c9 refactor(base): use uuid package instead of inlined uuid7 in tests 2026-04-23 13:07:19 +01:00
Philipinho db1b1464e2 test(base): assert pure-postgres path when query cache is disabled 2026-04-23 12:50:59 +01:00
Philipinho cc47a6d65c refactor(base): drop prepared binding now that loader sql inlines uuids 2026-04-23 12:39:33 +01:00
Philipinho 378d17350c fix(base): use postgres_query to invoke pg-side udfs from duckdb loader 2026-04-23 12:39:30 +01:00
Philipinho eea989260a test(base): filter/sort parity matrix against postgres
Integration spec that seeds a 10K-row base with diverse property shapes
(text, number, date, checkbox, select, multi-select) and runs an
exhaustive matrix of filter/sort combinations against both the DuckDB
cache path and the Postgres-direct path. Asserts identical row ids and,
where semantics allow, identical cursor strings and pagination meta.

The suite is gated by INTEGRATION_DB_URL and skips cleanly without it.
34 tests total: 26 flat filter ops (text/number/date/checkbox/select/
multi-select), 4 nested boolean trees (AND/OR/mixed/max-depth), 3
multi-key sorts, and one full filter+sort+pagination walk.

Seed tuning to keep both engines in lock-step:
  * digit-only row positions so PG default collation and DuckDB
    bytewise collation agree on the tail tiebreak.
  * lowercase name pool so mixed-case locale/bytewise divergence
    doesn't surface on text-secondary sort.
  * priority is non-NULL to avoid the PG keyset stall when a boundary
    cursor encodes the '+/-Infinity' numeric sentinel (postgres.js
    parses it as NaN, which applyCursor re-emits as null).
2026-04-23 05:00:17 +01:00
Philipinho fc08cffd37 test(server): init LRU test module so pg extension bootstraps 2026-04-23 04:40:30 +01:00
Philipinho fde0ccb3c7 refactor(base): replace streaming loader with pg-extension CREATE TABLE AS SELECT 2026-04-23 04:28:25 +01:00
Philipinho e663d7eecf test(server): align integration stubs with new config + pg-extension injection 2026-04-23 04:28:21 +01:00
Philipinho 96e875f1de test(base): tighten loader-sql mapping assertions to full projections 2026-04-23 03:37:23 +01:00
Philipinho 6544ff6d38 feat(base): pure SQL builder for pg-extension loader 2026-04-23 03:31:00 +01:00
Philipinho 7ca712c9ab fix(base): propagate pg-extension bootstrap failure reason; align closeSync style 2026-04-23 03:26:41 +01:00
Philipinho a798397af0 feat(base): postgres extension service with bootstrap install + per-connection attach 2026-04-23 03:17:36 +01:00
Philipinho 9ba6459427 feat(base): env vars for per-instance duckdb memory limit + threads 2026-04-23 03:09:58 +01:00
Philipinho 14827ec6a0 test(server): add getBaseQueryCacheDebug to integration test env stubs 2026-04-19 23:41:06 +01:00
Philipinho c931fa5ec9 perf(server): skip per-request row count when collection is resident 2026-04-19 23:39:27 +01:00
Philipinho 7e07d77510 chore(server): add per-request perf logs for base query cache diagnostics 2026-04-19 22:44:39 +01:00
Philipinho 02c3bdf028 docs(base): add implementation plan for duckdb query cache 2026-04-19 22:35:56 +01:00
Philipinho 55feb01249 test(server): assert duckdb cache matches postgres on a 100K-row base 2026-04-19 22:28:07 +01:00
Philipinho 4636af3870 feat(server): warm duckdb collections on boot from redis recent-access set 2026-04-19 22:16:20 +01:00
Philipinho c9adf84260 feat(server): evict least-recently-used duckdb collections when cap exceeded 2026-04-19 22:11:55 +01:00
Philipinho 4f38c61725 fix(server): avoid acquiring redis client when base query cache is disabled 2026-04-19 22:05:56 +01:00
Philipinho df22efb290 feat(server): propagate row mutations to duckdb cache via redis pubsub 2026-04-19 22:00:37 +01:00
Philipinho 7534b44e6e refactor(server): preserve cache-failure stack trace and reuse hasSearch 2026-04-19 21:50:34 +01:00
Philipinho cf6b48cd58 feat(server): route large base list queries through the duckdb cache 2026-04-19 21:46:27 +01:00
Philipinho 45000bbd8b fix(server): close duckdb resources on load failure, dedupe concurrent loads, drop unused cells projection 2026-04-19 21:39:05 +01:00
Philipinho 91ad3de258 feat(server): load bases into DuckDB and serve list queries from cache
- collection-loader streams base rows via postgres and bulk-inserts into an
  in-memory DuckDB instance using the Appender API, then builds an index on
  each indexable column
- base-query-cache service routes list() calls through the prepared-statement
  path; ensureLoaded does schema-version checks with single-pass LRU eviction
- keyset param-ordering bug in the DuckDB builder fixed: placeholders appear
  head-to-tail but were being pushed tail-to-head, which made DuckDB bind the
  wrong value for each ? and throw Binder Error on typed columns
- base-row repo gains countActiveRows for the router to use in task 6
- seed script split into an importable helper so integration tests can seed a
  10k-row base deterministically without shelling out
- new integration spec compares Postgres vs DuckDB pagination end-to-end for
  a numeric sort and guards against duplicate rows from DuckDB

Integration test is skipped unless INTEGRATION_DB_URL is set.
2026-04-19 21:31:05 +01:00
Philipinho b28597125d fix(server): use DuckDB json_contains for multi-select filters and expand builder coverage 2026-04-19 21:11:29 +01:00
Philipinho a9db3ef008 feat(server): add DuckDB SQL builder for base list queries 2026-04-19 21:06:41 +01:00
Philipinho 574c5316f0 feat(server): scaffold base query-cache module behind feature flag 2026-04-19 20:59:24 +01:00
Philipinho 3af2db7a8b feat(server): add property-type to DuckDB column-spec mapping 2026-04-19 20:54:59 +01:00
Philipinho f181c6d9e8 fix(server): case-insensitive parse for BASE_QUERY_CACHE_ENABLED env var 2026-04-19 20:52:06 +01:00
Philipinho 8ac4c97c98 docs(server): explain base-query-cache max-collections default 2026-04-19 20:50:21 +01:00
Philipinho abd42fd007 chore(server): add duckdb dependency and query-cache env getters 2026-04-19 20:48:16 +01:00
Philipinho eb0f37bfe5 update packages 2026-04-19 02:05:48 +01:00
Philipinho 4c0348e46a docs(base): add working plans for recent base feature work 2026-04-19 02:05:34 +01:00
Philipinho cac4774641 fix(base): stop runaway pagination loop caused by browser scroll anchoring
Browser overflow-anchor silently bumped scrollTop by one page's worth
of pixels every time a new page of rows committed — anchoring on the
AddRowButton that sits below paddingBottom. This kept the near-bottom
threshold satisfied and re-fired onFetchNextPage indefinitely, even
after the user released the scrollbar. Disabling scroll anchoring on
the grid scroll container stops the browser from adjusting scrollTop
in response to content growth.
2026-04-19 02:05:30 +01:00
Philipinho c4d8b6c300 fix(base): stop infinite fetch loop when sorted list scrolled to bottom 2026-04-19 00:27:52 +01:00
Philipinho 95d0457a7e refactor(base): drop /list suffix from base endpoints to match codebase convention 2026-04-18 23:36:52 +01:00
Philipinho 83d28a8505 perf(base): defer rows query until base info loads to avoid bland first request 2026-04-18 23:34:02 +01:00
Philipinho f9bbbc7ebf fix(base): ignore nested listbox and portal clicks so select doesnt close toolbar popover 2026-04-18 23:31:53 +01:00
Philipinho d9e2d7ba3d chore(server): one-shot script to clean poisoned base view configs 2026-04-18 23:27:03 +01:00
Philipinho 44ec2dbe88 fix(base): stop jsonb char-key corruption in seed and guard view config spread 2026-04-18 23:26:03 +01:00
Philipinho a6e9e66bbd fix(base): don't override server sort with client-side position sort 2026-04-18 22:55:15 +01:00
Philipinho a9ea2a99b4 chore(server): let seed-base-rows script take row count via env var 2026-04-18 22:44:52 +01:00
Philipinho 2f6bad141c feat(base): draft flow with save and cancel for new view filters 2026-04-18 22:39:30 +01:00
Philipinho fd1257f61c feat(base): draft flow with save and cancel for new view sorts 2026-04-18 22:38:28 +01:00
Philipinho 321184394d feat(base): show table skeleton instead of centered loader on load 2026-04-18 22:22:49 +01:00
Philipinho b01f6e9af9 feat(base): add layout-matching skeleton loading component 2026-04-18 22:22:11 +01:00
Philipinho 93b1fc534b fix(base): adopt server view state when no local edit is pending 2026-04-18 22:03:25 +01:00
Philipinho 1aa92b1bb5 fix(base): stop synthesized switch input click from re-firing hide toggle 2026-04-18 21:57:28 +01:00
Philipinho d385099eb1 fix(base): fire hide toggle once per click instead of twice 2026-04-18 21:51:43 +01:00
Philipinho d4fe0e0a69 fix(base): re-render grid header and rows when column visibility changes 2026-04-18 21:41:32 +01:00
Philipinho ab9b00f91c fix(base): include new properties in local column state so the grid can scroll to them 2026-04-18 21:11:09 +01:00
Philipinho 64dafe5ac0 fix(base): prompt unsaved changes when discarding dirty rename 2026-04-18 20:58:59 +01:00
Philipinho 097b1c76d4 feat(base): add save and cancel buttons to property rename panel 2026-04-18 20:52:26 +01:00
Philipinho 2c1f66b603 fix(base): refresh hide-fields popover when a property is renamed 2026-04-18 20:52:24 +01:00
Philipinho f812162a26 fix(base): refresh grid headers when a property is renamed 2026-04-18 20:51:14 +01:00
Philipinho b88c060df8 fix(base): escape on dirty property options triggers discard prompt 2026-04-18 20:39:02 +01:00
Philipinho 97cd88405d fix(base): close property menu on escape from main and options panels 2026-04-18 20:35:30 +01:00
Philipinho 5de9a69130 fix(base): close toolbar popovers on escape via document keydown 2026-04-18 20:31:54 +01:00
Philipinho 83d55d9bd3 fix(base): close toolbar popovers on outside click via custom listener 2026-04-18 20:26:57 +01:00
Philipinho 9c71a90637 fix(base): dismiss hide-fields popover on escape and outside click 2026-04-18 19:49:13 +01:00
Philipinho c6f993b610 fix(base): only re-seed column state when view identity changes 2026-04-18 19:23:56 +01:00
Philipinho c331e0ffd3 fix(base): merge live table state into sort and filter mutations 2026-04-18 19:22:41 +01:00
Philipinho 53ee685874 refactor(base): extract buildViewConfigFromTable helper 2026-04-18 19:21:17 +01:00
Philipinho 082a32faa0 fix(client): exempt base csv export from response interceptor unwrap 2026-04-18 18:51:49 +01:00
Philipinho 5c11e59128 fix(base): stabilize properties identity to break render loop 2026-04-18 18:48:41 +01:00
Philipinho 5a4d10081d feat(base): add csv export button to base toolbar 2026-04-18 18:24:24 +01:00
Philipinho 18668c7bcf feat(base): add client csv export service call 2026-04-18 18:23:43 +01:00
Philipinho f119d728a8 fix(base): handle csv export client abort and mid-stream errors 2026-04-18 18:18:34 +01:00
Philipinho 66f9194e96 feat(base): add csv export http endpoint 2026-04-18 18:14:41 +01:00
Philipinho 19b3f26cbb feat(base): register csv export service in module 2026-04-18 18:14:01 +01:00
Philipinho 56c57afff3 feat(base): add streaming csv export service 2026-04-18 18:13:20 +01:00
Philipinho d84aadadbb feat(base): add export base csv dto 2026-04-18 18:11:34 +01:00
Philipinho da0321b468 feat(base): add csv cell serializer with per-type rules 2026-04-18 18:10:47 +01:00
Philipinho db6f82ff7a chore(server): add csv-stringify dependency 2026-04-18 18:08:09 +01:00
Philipinho 207c74427d style(base): unify hover state across selected row cells 2026-04-18 17:15:57 +01:00
Philipinho c53d70b64e style(base): darken select option hover for better visibility 2026-04-18 17:15:23 +01:00
Philipinho 9a1cbc8ea9 style(base): nudge row drag grip past left table border 2026-04-18 17:14:49 +01:00
Philipinho 8b343d25f0 style(base): push row drag grip flush to left table border 2026-04-18 17:13:03 +01:00
Philipinho 2d47ffb25a style(base): align row drag grip flush with cell left edge 2026-04-18 17:12:42 +01:00
Philipinho b6882d774b fix(base): widen row-number column so drag grip sits left of checkbox 2026-04-18 17:09:00 +01:00
Philipinho 4dc6d32e49 fix(base): absolutely position row-number content to eliminate layout shift 2026-04-18 17:03:06 +01:00
Philipinho 8994575437 feat(base): confirm before bulk deleting selected rows 2026-04-18 17:00:43 +01:00
Philipinho 3f52e54207 fix(base): pin selection bar to viewport with Confluence-style dark pill 2026-04-18 16:54:49 +01:00
Philipinho b6b6e1809a feat(base): reconcile bulk delete over socket + prune selection 2026-04-18 16:47:05 +01:00
Philipinho d8adcd44c2 feat(base): clear row selection on view or base change 2026-04-18 16:46:07 +01:00
Philipinho 6a230b14ca feat(base): keyboard delete and esc to clear selection 2026-04-18 16:45:38 +01:00
Philipinho 05406640f0 feat(base): floating selection action bar with bulk delete 2026-04-18 16:44:07 +01:00
Philipinho 4c4bbe9b15 feat(base): header select-all with tri-state checkbox 2026-04-18 16:42:06 +01:00
Philipinho 3fca962c9f feat(base): row-number cell renders checkbox + drag handle on hover 2026-04-18 16:40:21 +01:00
Philipinho fda163311a feat(base): add use-row-selection hook 2026-04-18 16:37:47 +01:00
Philipinho 0d824dcd24 feat(base): add row selection atoms 2026-04-18 16:35:07 +01:00
Philipinho 8d793ec26b feat(base): add useDeleteRowsMutation with optimistic update 2026-04-18 16:35:00 +01:00
Philipinho 0bbcc7ee30 feat(base): add deleteRows client service + type 2026-04-18 16:34:19 +01:00
Philipinho e017209d76 feat(base): emit base:rows:deleted websocket event 2026-04-18 16:32:27 +01:00
Philipinho fc734475df feat(base): add POST /bases/rows/delete-many endpoint 2026-04-18 16:31:44 +01:00
Philipinho a7f9d66778 feat(base): add deleteMany service method for batch row delete 2026-04-18 16:31:11 +01:00
Philipinho 4a9e891582 feat(base): add BASE_ROWS_DELETED event type 2026-04-18 16:29:26 +01:00
Philipinho 65c5bb11b8 feat(base): add DeleteRowsDto for batch row delete 2026-04-18 16:29:02 +01:00
Philipinho 1466d95078 feat(base): add findByIds and softDeleteMany to base-row repo 2026-04-18 16:28:39 +01:00
Philipinho 901445305d docs: drop unused selectionCount in row-number-header-cell sample 2026-04-18 16:20:40 +01:00
Philipinho 5985238b4b docs: tighten row selection plan per review (consolidate tasks 13-14, fix deps) 2026-04-18 16:20:02 +01:00
Philipinho 10ee8d0c85 docs: add base row selection and bulk delete implementation plan 2026-04-18 16:16:58 +01:00
Philipinho d2f19b2aa0 docs: clarify base row selection spec edge cases per review 2026-04-18 16:09:02 +01:00
Philipinho 493915a0c3 docs: add base row selection and bulk delete design spec 2026-04-18 16:07:58 +01:00
Philipinho da49ffc332 fix orderBy 2026-04-18 15:17:20 +01:00
Philipinho b95f3033d1 style(base): add focus-preservation comment to status cell mousedown 2026-04-18 15:07:05 +01:00
Philipinho 88c906cdcd feat(base): keyboard navigation for status cell dropdown 2026-04-18 15:05:30 +01:00
Philipinho 836a25cdbf feat(base): keyboard navigation for multi-select cell dropdown 2026-04-18 15:03:04 +01:00
Philipinho b02b2cd5d8 refactor(base): hoist NavItem type and drop IIFE in select cell 2026-04-18 15:01:12 +01:00
Philipinho bb398bb7d6 feat(base): keyboard navigation for single-select cell dropdown 2026-04-18 14:58:19 +01:00
Philipinho 4cefa40f5b refactor(base): destructure useListKeyboardNav and use clsx in person cell 2026-04-18 14:55:59 +01:00
Philipinho 2ca27f16a1 feat(base): keyboard navigation for person cell dropdown 2026-04-18 14:52:09 +01:00
Philipinho f8edb587e4 feat(base): add useListKeyboardNav hook for dropdown keyboard nav 2026-04-18 14:48:30 +01:00
Philipinho 0f4a819ec5 style(base): add keyboard-active option style for cell dropdowns 2026-04-18 14:47:59 +01:00
Philipinho ede1a799f2 feat(base): disable type-conversion API for v1, preserve engine for v2 2026-04-18 14:13:08 +01:00
Philipinho 845b49968e feat(base): replace property type picker with read-only display 2026-04-18 13:34:33 +01:00
Philipinho b244f831da refactor(base): drop type-change invalidation branch from update-property mutation 2026-04-18 13:29:32 +01:00
Philipinho 2ececc8203 fix(base): remove maxPages cap that caused infinite scroll loop past row 500 2026-04-18 13:26:25 +01:00
Philipinho 6d9107b727 refactor(base): drop unused schema:bumped socket handler 2026-04-18 13:23:36 +01:00
Philipinho 5ae49cab49 refactor(base): prune deleted property cells locally instead of invalidating rows 2026-04-18 13:18:51 +01:00
Philipinho 89638fb11d refactor(base): append remote row creates to cache instead of invalidating 2026-04-18 13:15:49 +01:00
Philipinho f5b19316af Base WIP 2026-04-18 13:13:53 +01:00
Philipinho 081bb67239 Merge branch 'main' into base 2026-04-17 13:48:49 +01:00
Philipinho eb0538b856 fix 2026-04-17 13:41:24 +01:00
Philipinho 084746e65a WIP 2026-03-09 01:08:15 +00:00
Philipinho 4ff13cef62 sort cursor pagination 2026-03-08 04:00:44 +00:00
Philipinho 2a6e604bf8 person cell 2026-03-08 03:36:57 +00:00
Philipinho 674b0ec64a filter/sort, file, person 2026-03-08 03:15:49 +00:00
Philipinho ac03a54ae6 make recent 2026-03-08 02:36:00 +00:00
Philipinho 2cf7958dac Merge branch 'main' into base 2026-03-08 01:57:17 +00:00
Philipinho 94ee1e80fb feat: bases - WIP 2026-03-08 00:56:24 +00:00
607 changed files with 35570 additions and 23161 deletions
-7
View File
@@ -48,13 +48,6 @@ GOTENBERG_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
IFRAME_EMBED_ALLOWED=false
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Enable debug logging in production (default: false) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
+71 -75
View File
@@ -1,95 +1,91 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.80.1", "version": "0.80.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.8.1", "@casl/react": "^5.0.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5", "@dnd-kit/core": "^6.3.1",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15", "@dnd-kit/modifiers": "^9.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", "@dnd-kit/sortable": "^10.0.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4", "@dnd-kit/utilities": "^3.2.2",
"@casl/react": "5.0.1",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18", "@mantine/core": "^8.3.18",
"@mantine/dates": "8.3.18", "@mantine/dates": "^8.3.18",
"@mantine/form": "8.3.18", "@mantine/form": "^8.3.18",
"@mantine/hooks": "8.3.18", "@mantine/hooks": "^8.3.18",
"@mantine/modals": "8.3.18", "@mantine/modals": "^8.3.18",
"@mantine/notifications": "8.3.18", "@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "8.3.18", "@mantine/spotlight": "^8.3.18",
"@slidoapp/emoji-mart": "5.8.7", "@tabler/icons-react": "^3.40.0",
"@slidoapp/emoji-mart-data": "1.2.4", "@tanstack/react-query": "5.99.1",
"@slidoapp/emoji-mart-react": "1.1.5", "@tanstack/react-table": "^8.21.3",
"@tabler/icons-react": "3.40.0", "@tanstack/react-virtual": "^3.13.24",
"@tanstack/react-query": "5.90.17", "alfaaz": "^1.1.0",
"@tanstack/react-virtual": "3.13.24", "axios": "1.15.0",
"alfaaz": "1.1.0", "blueimp-load-image": "^5.16.0",
"axios": "1.16.0", "clsx": "^2.1.1",
"blueimp-load-image": "5.16.0", "emoji-mart": "^5.6.0",
"clsx": "2.1.1", "file-saver": "^2.0.5",
"file-saver": "2.0.5", "highlightjs-sap-abap": "^0.3.0",
"highlightjs-sap-abap": "0.3.0", "i18next": "^25.10.1",
"i18next": "25.10.1", "i18next-http-backend": "^3.0.2",
"i18next-http-backend": "3.0.6", "jotai": "^2.18.1",
"jotai": "2.18.1", "jotai-optics": "^0.4.0",
"jotai-optics": "0.4.0", "js-cookie": "^3.0.5",
"js-cookie": "3.0.5", "jwt-decode": "^4.0.0",
"jwt-decode": "4.0.0",
"katex": "0.16.40", "katex": "0.16.40",
"lowlight": "3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "11.15.0", "mermaid": "^11.13.0",
"mitt": "3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.372.2", "posthog-js": "1.363.1",
"react": "18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "6.1.1", "react-error-boundary": "^6.1.1",
"react-helmet-async": "3.0.0", "react-helmet-async": "^3.0.0",
"react-i18next": "16.5.8", "react-i18next": "^16.5.8",
"react-router-dom": "7.13.1", "react-router-dom": "^7.13.1",
"semver": "7.7.4", "semver": "^7.7.4",
"socket.io-client": "4.8.3", "socket.io-client": "^4.8.3",
"zod": "4.3.6" "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.28.0", "@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4", "@tanstack/eslint-plugin-query": "^5.94.4",
"@testing-library/jest-dom": "6.6.0", "@types/blueimp-load-image": "^5.16.6",
"@testing-library/react": "16.1.0", "@types/file-saver": "^2.0.7",
"@types/blueimp-load-image": "5.16.6", "@types/js-cookie": "^3.0.6",
"@types/file-saver": "2.0.7", "@types/katex": "^0.16.8",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1", "@types/node": "22.19.1",
"@types/react": "18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "9.28.0", "eslint": "^9.28.0",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "15.13.0", "globals": "^15.13.0",
"jsdom": "25.0.0", "optics-ts": "^2.4.1",
"optics-ts": "2.4.1", "postcss": "^8.5.8",
"postcss": "8.5.14", "postcss-preset-mantine": "^1.18.0",
"postcss-preset-mantine": "1.18.0", "postcss-simple-vars": "^7.0.1",
"postcss-simple-vars": "7.0.1", "prettier": "^3.8.1",
"prettier": "3.8.1", "typescript": "^5.9.3",
"typescript": "5.9.3", "typescript-eslint": "^8.57.1",
"typescript-eslint": "8.57.1", "vite": "8.0.5"
"vite": "8.0.5",
"vitest": "4.1.6"
} }
} }
@@ -391,7 +391,7 @@
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein", "Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
"Write...": "\"Schreiben...\"", "Write...": "\"Schreiben...\"",
"Column count": "Spaltenanzahl", "Column count": "Spaltenanzahl",
"{{count}} Columns": "{{count}} Spalten", "{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}",
"Equal columns": "Gleich breite Spalten", "Equal columns": "Gleich breite Spalten",
"Left sidebar": "Linke Seitenleiste", "Left sidebar": "Linke Seitenleiste",
"Right sidebar": "Rechte Seitenleiste", "Right sidebar": "Rechte Seitenleiste",
@@ -71,7 +71,6 @@
"Export": "Export", "Export": "Export",
"Failed to create page": "Failed to create page", "Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page", "Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages", "Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages", "Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
@@ -287,19 +286,6 @@
"Add row above": "Add row above", "Add row above": "Add row above",
"Add row below": "Add row below", "Add row below": "Add row below",
"Delete table": "Delete table", "Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info", "Info": "Info",
"Note": "Note", "Note": "Note",
"Success": "Success", "Success": "Success",
@@ -362,8 +348,6 @@
"Create block quote.": "Create block quote.", "Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.", "Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.", "Upload any audio from your device.": "Upload any audio from your device.",
@@ -408,10 +392,6 @@
"Write...": "Write...", "Write...": "Write...",
"Column count": "Column count", "Column count": "Column count",
"{{count}} Columns": "{{count}} Columns", "{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"Equal columns": "Equal columns", "Equal columns": "Equal columns",
"Left sidebar": "Left sidebar", "Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar", "Right sidebar": "Right sidebar",
@@ -436,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} is available", "{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode", "Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.", "Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Choose {{format}} file": "Choose {{format}} file",
"Reading": "Reading", "Reading": "Reading",
"Delete member": "Delete member", "Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully", "Member deleted successfully": "Member deleted successfully",
@@ -586,8 +565,6 @@
"Move to trash": "Move to trash", "Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?", "Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page", "Restore page": "Restore page",
"Permanently delete": "Permanently delete",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash", "Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully", "Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by", "Deleted by": "Deleted by",
@@ -631,21 +608,25 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully", "Image removed successfully": "Image removed successfully",
"API key": "API key", "API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys", "API keys": "API keys",
"API management": "API management", "API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date", "Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name", "Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration", "Expiration": "Expiration",
"Expired": "Expired", "Expired": "Expired",
"Expires": "Expires", "Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used", "Last use": "Last Used",
"No API keys found": "No API keys found", "No API keys found": "No API keys found",
"No expiration": "No expiration", "No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully", "Revoked successfully": "Revoked successfully",
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update": "Update", "Update API key": "Update API key",
"Update {{credential}}": "Update {{credential}}",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"Restrict API key creation to admins": "Restrict API key creation to admins", "Restrict API key creation to admins": "Restrict API key creation to admins",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.", "Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
@@ -877,12 +858,9 @@
"AI Chat": "AI Chat", "AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights", "Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...", "Ask anything...": "Ask anything...",
"Assistant said:": "Assistant said:",
"Chat history": "Chat history", "Chat history": "Chat history",
"Chat name": "Chat name", "Chat name": "Chat name",
"Chat transcript": "Chat transcript",
"Close": "Close", "Close": "Close",
"Copy assistant response": "Copy assistant response",
"Docmost AI": "Docmost AI", "Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.", "Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.", "Failed to render this message.": "Failed to render this message.",
@@ -892,17 +870,9 @@
"No chats found": "No chats found", "No chats found": "No chats found",
"No conversations yet": "No conversations yet", "No conversations yet": "No conversations yet",
"Open full page": "Open full page", "Open full page": "Open full page",
"Scroll to bottom": "Scroll to bottom",
"You said:": "You said:",
"Previous 7 days": "Previous 7 days", "Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days", "Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...", "Search chats...": "Search chats...",
"Search chats": "Search chats",
"Ask anything... Use @ to mention pages": "Ask anything... Use @ to mention pages",
"Ask anything or search your workspace": "Ask anything or search your workspace",
"Welcome to {{name}}": "Welcome to {{name}}",
"Add files": "Add files",
"Mention a page": "Mention a page",
"Start a new chat to see it here.": "Start a new chat to see it here.", "Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page", "Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat", "Toggle AI Chat": "Toggle AI Chat",
@@ -910,176 +880,5 @@
"Try a different search term.": "Try a different search term.", "Try a different search term.": "Try a different search term.",
"Try again": "Try again", "Try again": "Try again",
"Untitled chat": "Untitled chat", "Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?", "What can I help you with?": "What can I help you with?"
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
"Create {{credential}}": "Create {{credential}}",
"{{credential}} created": "{{credential}} created",
"{{credential}} created successfully": "{{credential}} created successfully",
"Created by": "Created by",
"Custom": "Custom",
"Enable SCIM": "Enable SCIM",
"Enter a descriptive name": "Enter a descriptive name",
"I've saved my {{credential}}": "I've saved my {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
"Never": "Never",
"Revoke {{credential}}": "Revoke {{credential}}",
"SCIM endpoint URL": "SCIM endpoint URL",
"SCIM provisioning": "SCIM provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
"SCIM token": "SCIM token",
"SCIM tokens": "SCIM tokens",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
"Token": "Token",
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
"Breadcrumbs": "Breadcrumbs",
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Use": "Use",
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",
"No spaces found": "No spaces found",
"Browse all templates": "Browse all templates",
"This space": "This space",
"All templates": "All templates",
"Global": "Global",
"New template": "New template",
"Edit template": "Edit template",
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
"Template scope updated": "Template scope updated",
"Choose which space this template belongs to": "Choose which space this template belongs to",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
"Saving...": "Saving...",
"Saved": "Saved",
"Save failed. Retry": "Save failed. Retry",
"By {{name}}": "By {{name}}",
"Updated {{time}}": "Updated {{time}}",
"Choose destination": "Choose destination",
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
"Slash commands": "Slash commands",
"Mention suggestions": "Mention suggestions",
"Link suggestions": "Link suggestions",
"Diagram editor": "Diagram editor",
"Add comment": "Add comment",
"Find and replace": "Find and replace",
"Main navigation": "Main navigation",
"Space navigation": "Space navigation",
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original",
"Copy synced block": "Copy synced block",
"Unsync": "Unsync",
"Delete synced block": "Delete synced block",
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "THIS PAGE",
"No pages": "No pages",
"The original synced block no longer exists": "The original synced block no longer exists",
"You don't have access to this synced block": "You don't have access to this synced block",
"Failed to load this synced block": "Failed to load this synced block",
"Fixed editor toolbar": "Fixed editor toolbar",
"Show a formatting toolbar above the editor with quick access to common actions.": "Show a formatting toolbar above the editor with quick access to common actions.",
"Toggle fixed editor toolbar": "Toggle fixed editor toolbar",
"Normal text": "Normal text",
"More inline formatting": "More inline formatting",
"Subscript": "Subscript",
"Superscript": "Superscript",
"Inline code": "Inline code",
"Insert media": "Insert media",
"Mention": "Mention",
"Emoji": "Emoji",
"Columns": "Columns",
"More inserts": "More inserts",
"Embeds": "Embeds",
"Diagrams": "Diagrams",
"Advanced": "Advanced",
"Utility": "Utility",
"Decrease indent": "Decrease indent",
"Increase indent": "Increase indent",
"Clear formatting": "Clear formatting",
"Code block": "Code block",
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
"Word count": "Word count",
"Characters": "Characters",
"Incoming links": "Incoming links",
"Outgoing links": "Outgoing links",
"Incoming links ({{count}})": "Incoming links ({{count}})",
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions",
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
"Template settings": "Template settings",
"Edit diagram": "Edit diagram",
"Edit embed": "Edit embed",
"Edit drawing": "Edit drawing",
"Delete equation": "Delete equation",
"Invite actions": "Invite actions",
"Get started": "Get started",
"* indicates required fields": "* indicates required fields",
"List of spaces in this workspace": "List of spaces in this workspace",
"Active sessions": "Active sessions",
"Add {{name}} to favorites": "Add {{name}} to favorites",
"Remove {{name}} from favorites": "Remove {{name}} from favorites",
"Added to favorites": "Added to favorites",
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
} }
+3 -2
View File
@@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/pages/base/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list"; import TemplateList from "@/ee/template/pages/template-list";
@@ -45,7 +46,6 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page"; import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -93,7 +93,6 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} /> <Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} /> <Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} /> <Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} /> <Route path={"/templates"} element={<TemplateList />} />
<Route <Route
path={"/templates/:templateId"} path={"/templates/:templateId"}
@@ -106,6 +105,8 @@ export default function App() {
element={<Page />} element={<Page />}
/> />
<Route path={"/base/:baseId"} element={<BasePage />} />
<Route path={"/settings"}> <Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} /> <Route path={"account/profile"} element={<AccountSettings />} />
<Route <Route
@@ -80,20 +80,6 @@ export default function AvatarUploader({
} }
}; };
const actionLabel = {
[AvatarIconType.AVATAR]: t("Change avatar"),
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
}[type];
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
// visible text. When no image is set, the avatar renders the name's
// initials, so prepend the name to the action label.
const ariaLabel =
!currentImageUrl && fallbackName
? `${fallbackName} ${actionLabel}`
: actionLabel;
const handleRemove = async () => { const handleRemove = async () => {
if (disabled) return; if (disabled) return;
@@ -118,8 +104,6 @@ export default function AvatarUploader({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg" accept="image/png,image/jpeg,image/jpg"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}
/> />
@@ -131,8 +115,6 @@ export default function AvatarUploader({
size={size} size={size}
avatarUrl={currentImageUrl} avatarUrl={currentImageUrl}
name={fallbackName} name={fallbackName}
aria-label={ariaLabel}
aria-haspopup="menu"
style={{ style={{
cursor: disabled || isLoading ? "default" : "pointer", cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1, opacity: isLoading ? 0.6 : 1,
+2 -7
View File
@@ -8,19 +8,15 @@ interface CopyProps {
text: string; text: string;
size?: MantineSize; size?: MantineSize;
color?: MantineColor; color?: MantineColor;
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
label?: string;
} }
export default function CopyTextButton({ text, size, label }: CopyProps) { export default function CopyTextButton({ text, size }: CopyProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return ( return (
<CopyButton value={text} timeout={2000}> <CopyButton value={text} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
label={copied ? t("Copied") : copyLabel} label={copied ? t("Copied") : t("Copy")}
withArrow withArrow
position="right" position="right"
> >
@@ -29,7 +25,6 @@ export default function CopyTextButton({ text, size, label }: CopyProps) {
variant="subtle" variant="subtle"
onClick={copy} onClick={copy}
size={size} size={size}
aria-label={copied ? t("Copied") : copyLabel}
> >
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />} {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon> </ActionIcon>
@@ -81,7 +81,7 @@ export default function ExportModal({
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title> <Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
@@ -4,7 +4,7 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ThemeIcon, ActionIcon,
Button, Button,
} from "@mantine/core"; } from "@mantine/core";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -17,7 +17,6 @@ import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts"; import { getInitialsColor } from "@/lib/get-initials-color.ts";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
interface Props { interface Props {
spaceId?: string; spaceId?: string;
@@ -42,18 +41,17 @@ export default function RecentChanges({ spaceId }: Props) {
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Tbody> <Table.Tbody>
{pages.map((page) => ( {pages.map((page) => (
<Table.Tr key={page.id} className={rowClasses.row}> <Table.Tr key={page.id}>
<Table.Td> <Table.Td>
<UnstyledButton <UnstyledButton
className={rowClasses.link}
component={Link} component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)} to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || ( {page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}> <ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} /> <IconFileDescription size={18} />
</ThemeIcon> </ActionIcon>
)} )}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
@@ -6,14 +6,12 @@ import { useTranslation } from "react-i18next";
export interface SearchInputProps { export interface SearchInputProps {
placeholder?: string; placeholder?: string;
ariaLabel?: string;
debounceDelay?: number; debounceDelay?: number;
onSearch: (value: string) => void; onSearch: (value: string) => void;
} }
export function SearchInput({ export function SearchInput({
placeholder, placeholder,
ariaLabel,
debounceDelay = 500, debounceDelay = 500,
onSearch, onSearch,
}: SearchInputProps) { }: SearchInputProps) {
@@ -30,7 +28,6 @@ export function SearchInput({
<TextInput <TextInput
size="sm" size="sm"
placeholder={placeholder || t("Search...")} placeholder={placeholder || t("Search...")}
aria-label={ariaLabel || placeholder || t("Search")}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
value={value} value={value}
onChange={(e) => setValue(e.currentTarget.value)} onChange={(e) => setValue(e.currentTarget.value)}
@@ -1,11 +1,11 @@
import { ThemeIcon } from "@mantine/core"; import { ActionIcon, rem } from "@mantine/core";
import React from "react"; import React from "react";
import { IconUsersGroup } from "@tabler/icons-react"; import { IconUsersGroup } from "@tabler/icons-react";
export function IconGroupCircle() { export function IconGroupCircle() {
return ( return (
<ThemeIcon variant="light" size="lg" color="gray" radius="xl"> <ActionIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} /> <IconUsersGroup stroke={1.5} />
</ThemeIcon> </ActionIcon>
); );
} }
@@ -27,3 +27,5 @@
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5)) background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
} }
} }
@@ -1,27 +1,18 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { Box, ScrollArea, Text } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react"; import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
export default function Aside() { export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
if (!isAsideOpen) return;
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
let title: string; let title: string;
let component: ReactNode; let component: ReactNode;
@@ -39,10 +30,6 @@ export default function Aside() {
component = <AsideChatPanel />; component = <AsideChatPanel />;
title = "AI Chat"; title = "AI Chat";
break; break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default: default:
component = null; component = null;
title = null; title = null;
@@ -53,19 +40,9 @@ export default function Aside() {
{component && ( {component && (
<> <>
{tab !== "chat" && ( {tab !== "chat" && (
<Group justify="space-between" wrap="nowrap" mb="md"> <Text mb="md" fw={500}>
<Title order={2} size="h6" fw={500}>{t(title)}</Title> {t(title)}
<Tooltip label={t("Close")} withArrow> </Text>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)} )}
{tab === "comments" || tab === "chat" ? ( {tab === "comments" || tab === "chat" ? (
@@ -1,7 +1,6 @@
import { AppShell, Container } from "@mantine/core"; import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
@@ -18,20 +17,17 @@ import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx"; import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx"; import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
export default function GlobalAppShell({ export default function GlobalAppShell({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useTranslation();
useTrialEndAction(); useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom); const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom); const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null); const sidebarRef = useRef(null);
@@ -83,8 +79,6 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return ( return (
<>
<SkipToMain />
<AppShell <AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
@@ -111,15 +105,6 @@ export default function GlobalAppShell({
className={classes.navbar} className={classes.navbar}
withBorder={false} withBorder={false}
ref={sidebarRef} ref={sidebarRef}
aria-label={
isSpaceRoute
? t("Space navigation")
: isSettingsRoute
? t("Settings navigation")
: isAiRoute
? t("AI navigation")
: t("Main navigation")
}
> >
{isSpaceRoute && ( {isSpaceRoute && (
<div className={classes.resizeHandle} onMouseDown={startResizing} /> <div className={classes.resizeHandle} onMouseDown={startResizing} />
@@ -129,39 +114,19 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />} {isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}> <AppShell.Main>
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900} pb={80}> <Container size={900}>{children}</Container>
{children}
</Container>
) : ( ) : (
children children
)} )}
</AppShell.Main> </AppShell.Main>
{isPageRoute && ( {isPageRoute && (
<AppShell.Aside <AppShell.Aside className={classes.aside} p="md" withBorder={false}>
id={ASIDE_PANEL_ID}
tabIndex={-1}
className={classes.aside}
p="md"
withBorder={false}
aria-label={
asideTab === "comments"
? t("Comments")
: asideTab === "toc"
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: asideTab === "details"
? t("Details")
: undefined
}
>
<Aside /> <Aside />
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
</>
); );
} }
@@ -31,11 +31,6 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
&[data-active] { &[data-active] {
&, &,
& :hover { & :hover {
@@ -43,16 +38,6 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
} }
} }
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
@mixin hover {
background-color: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
} }
.linkIcon { .linkIcon {
@@ -65,7 +50,7 @@
.sectionHeader { .sectionHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -101,9 +86,4 @@
); );
color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
} }
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core"; import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
import { import {
IconHome, IconHome,
IconClock, IconClock,
@@ -7,7 +7,6 @@ import {
IconLayoutGrid, IconLayoutGrid,
IconSettings, IconSettings,
IconUserPlus, IconUserPlus,
IconTemplate,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css"; import classes from "./global-sidebar.module.css";
@@ -21,9 +20,12 @@ import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form"; import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types"; import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; const mainNavItems = [
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; { label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
export default function GlobalSidebar() { export default function GlobalSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,19 +33,6 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname); const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
{
label: "Templates",
icon: IconTemplate,
path: "/templates",
disabled: !hasTemplates,
},
];
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space"); const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces] const sortedFavoriteSpaces = [...favoriteSpaces]
@@ -69,38 +58,18 @@ export default function GlobalSidebar() {
<div className={classes.navbar}> <div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}> <ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}> <div className={classes.section}>
{mainNavItems.map((item) => {mainNavItems.map((item) => (
item.disabled ? (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<UnstyledButton
className={classes.link}
data-disabled
aria-disabled="true"
tabIndex={-1}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</UnstyledButton>
</Tooltip>
) : (
<Link <Link
key={item.label} key={item.label}
className={classes.link} className={classes.link}
data-active={active === item.path || undefined} data-active={active === item.path || undefined}
aria-current={active === item.path ? "page" : undefined}
to={item.path} to={item.path}
onClick={handleNavClick} onClick={handleNavClick}
> >
<item.icon className={classes.linkIcon} stroke={2} /> <item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span> <span>{t(item.label)}</span>
</Link> </Link>
), ))}
)}
</div> </div>
<Divider my="xs" /> <Divider my="xs" />
@@ -150,17 +119,20 @@ export default function GlobalSidebar() {
</ScrollArea> </ScrollArea>
<div className={classes.bottomSection}> <div className={classes.bottomSection}>
<UnstyledButton <a
className={classes.link} className={classes.link}
onClick={openInvite} onClick={(e) => {
e.preventDefault();
openInvite();
}}
href="#"
> >
<IconUserPlus className={classes.linkIcon} stroke={2} /> <IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span> <span>{t("Invite People")}</span>
</UnstyledButton> </a>
<Link <Link
className={classes.link} className={classes.link}
data-active={active.startsWith("/settings") || undefined} data-active={active.startsWith("/settings") || undefined}
aria-current={active.startsWith("/settings") ? "page" : undefined}
to="/settings/account/profile" to="/settings/account/profile"
onClick={handleNavClick} onClick={handleNavClick}
> >
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false); export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = { type AsideStateType = {
tab: string; tab: string;
isAsideOpen: boolean; isAsideOpen: boolean;
@@ -13,7 +13,6 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service"; import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service"; import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" }; const params: QueryParams = { limit: 100, query: "" };
@@ -99,10 +98,3 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params), queryFn: () => getVerificationList(params),
}); });
}; };
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,7 +31,6 @@ import {
prefetchBilling, prefetchBilling,
prefetchGroups, prefetchGroups,
prefetchLicense, prefetchLicense,
prefetchScimTokens,
prefetchShares, prefetchShares,
prefetchSpaces, prefetchSpaces,
prefetchSsoProviders, prefetchSsoProviders,
@@ -205,10 +204,7 @@ export default function SettingsSidebar() {
} }
break; break;
case "Security & SSO": case "Security & SSO":
prefetchHandler = () => { prefetchHandler = prefetchSsoProviders;
prefetchSsoProviders();
prefetchScimTokens();
};
break; break;
case "Public sharing": case "Public sharing":
prefetchHandler = prefetchShares; prefetchHandler = prefetchShares;
@@ -230,6 +226,32 @@ export default function SettingsSidebar() {
} }
const isDisabled = isItemDisabled(item); const isDisabled = isItemDisabled(item);
const linkElement = (
<Link
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
data-disabled={isDisabled || undefined}
key={item.label}
to={isDisabled ? "#" : item.path}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
style={{
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "not-allowed" : "pointer",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
if (isDisabled) { if (isDisabled) {
return ( return (
@@ -239,41 +261,12 @@ export default function SettingsSidebar() {
position="right" position="right"
withArrow withArrow
> >
<span {linkElement}
className={classes.link}
data-disabled
role="link"
aria-disabled="true"
tabIndex={0}
style={{
opacity: 0.5,
cursor: "not-allowed",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</span>
</Tooltip> </Tooltip>
); );
} }
return ( return linkElement;
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
})} })}
</div> </div>
); );
@@ -291,7 +284,7 @@ export default function SettingsSidebar() {
}} }}
variant="transparent" variant="transparent"
c="gray" c="gray"
aria-label={t("Back")} aria-label="Back"
> >
<IconArrowLeft stroke={2} /> <IconArrowLeft stroke={2} />
</ActionIcon> </ActionIcon>
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) { export default function SettingsTitle({ title }: { title: string }) {
return ( return (
<> <>
<Title order={1} size="h3"> <Title order={3}>
{title} {title}
</Title> </Title>
<Divider my="md" /> <Divider my="md" />
@@ -1,29 +0,0 @@
/*
* Focus styling for list-style tables (recent changes, favorites, all
* spaces, groups, verified pages, shares).
*
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
* data tables should not be made fully clickable. Only the title cell is the
* link, and that link is what receives Tab focus.
*
* - `.row` adds a subtle background tint when the row contains the focused
* element, so keyboard users can see which row they're inspecting.
* - `.link` adds a visible :focus-visible outline on the title link itself.
*
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
* inside table cells cause column reflow on focus in Chromium.
*/
.row:focus-within {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
.link:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Avatar, MantineColor } from "@mantine/core"; import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts"; import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
@@ -16,57 +16,19 @@ interface CustomAvatarProps {
mt?: string | number; mt?: string | number;
} }
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
// - filled: white text on the shade as bg
// - light: shade as text on the color's light-bg (10% color.6 over white)
// Avoids lime/yellow/green/orange — even their dark shades have weak
// contrast. grape and indigo were bumped from .7 to darker shades because
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
// indigo.7 was 4.98/4.39 (light fails by a hair).
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
"grape.9",
"indigo.8",
"pink.8",
"red.8",
"violet.7",
];
function hashName(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function pickInitialsColor(name: string) {
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
}
function sanitizeInitialsSource(name: string) {
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
return sanitized || name;
}
export const CustomAvatar = React.forwardRef< export const CustomAvatar = React.forwardRef<
HTMLInputElement, HTMLInputElement,
CustomAvatarProps CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => { >(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type); const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return ( return (
<Avatar <Avatar
ref={ref} ref={ref}
src={avatarLink} src={avatarLink}
name={initialsSource} name={name}
alt={name} alt={name}
color={resolvedColor} color="initials"
{...props} {...props}
/> />
); );
@@ -16,8 +16,6 @@ export function DestinationPickerModal({
loading, loading,
excludePageId, excludePageId,
pageLimit, pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) { }: DestinationPickerModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null); const [selection, setSelection] = useState<DestinationSelection | null>(null);
@@ -41,15 +39,13 @@ export function DestinationPickerModal({
<Modal.Content> <Modal.Content>
<Modal.Header py={0}> <Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title> <Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<DestinationPicker <DestinationPicker
onSelectionChange={setSelection} onSelectionChange={setSelection}
excludePageId={excludePageId} excludePageId={excludePageId}
pageLimit={pageLimit} pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
@@ -13,7 +13,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease; transition: background-color 150ms ease;
user-select: none; user-select: none;
@@ -23,11 +22,6 @@
var(--mantine-color-dark-6) var(--mantine-color-dark-6)
); );
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
} }
.selected { .selected {
@@ -63,7 +57,7 @@
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
flex-shrink: 0; flex-shrink: 0;
transition: transform 150ms ease; transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
@mixin hover { @mixin hover {
background-color: light-dark( background-color: light-dark(
@@ -117,7 +111,7 @@
} }
.spaceName { .spaceName {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback } from "react";
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core"; import { TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFileDescription } from "@tabler/icons-react"; import { IconSearch, IconFile } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
@@ -15,29 +15,23 @@ type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void; onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string; excludePageId?: string;
pageLimit?: number; pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
}; };
export function DestinationPicker({ export function DestinationPicker({
onSelectionChange, onSelectionChange,
excludePageId, excludePageId,
pageLimit = 15, pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) { }: DestinationPickerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null); const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300); const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({ const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100, limit: 100,
}); });
const searchEnabled = const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } = const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({ useSearchSuggestionsQuery({
@@ -48,18 +42,6 @@ export function DestinationPicker({
const isSearching = !!searchEnabled; const isSearching = !!searchEnabled;
const filteredSpaces = useMemo(() => {
const items = spacesData?.items ?? [];
if (!searchSpacesOnly || !debouncedQuery) return items;
const fold = (s: string) =>
s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLocaleLowerCase();
const term = fold(debouncedQuery);
return items.filter((s) => fold(s.name).includes(term));
}, [spacesData, searchSpacesOnly, debouncedQuery]);
const selectedId = const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null; selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
@@ -105,48 +87,18 @@ export function DestinationPicker({
[updateSelection], [updateSelection],
); );
// Pre-select space when initialSpaceId is set and spaces have loaded.
// Only runs once: skip if user has already made a selection.
useEffect(() => {
if (!initialSpaceId || selection) return;
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (match) {
updateSelection({ type: "space", spaceId: match.id, space: match });
requestAnimationFrame(() => {
const el = viewportRef.current?.querySelector<HTMLElement>(
`[data-space-id="${match.id}"]`,
);
el?.scrollIntoView({ block: "nearest" });
});
}
}, [initialSpaceId, selection, spacesData, updateSelection]);
return ( return (
<> <>
<TextInput <TextInput
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
placeholder={ placeholder={t("Search pages and spaces...")}
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
aria-label={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
variant="filled" variant="filled"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)} onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput} className={classes.searchInput}
/> />
<ScrollArea <ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
{isSearching ? ( {isSearching ? (
searchLoading ? ( searchLoading ? (
<div className={classes.emptyState}> <div className={classes.emptyState}>
@@ -159,28 +111,16 @@ export function DestinationPicker({
<div <div
key={page.id} key={page.id}
className={classes.searchResult} className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)} onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
> >
<div className={classes.iconWrapper}> <div className={classes.iconWrapper}>
{page.icon ? ( {page.icon ? (
page.icon page.icon
) : ( ) : (
<ActionIcon <IconFile
component="div" size={16}
variant="transparent" color="var(--mantine-color-gray-5)"
c="gray" />
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)} )}
</div> </div>
<div className={classes.pageTitle}> <div className={classes.pageTitle}>
@@ -201,14 +141,8 @@ export function DestinationPicker({
<div className={classes.emptyState}> <div className={classes.emptyState}>
<Loader size="xs" /> <Loader size="xs" />
</div> </div>
) : filteredSpaces.length === 0 ? (
<div className={classes.emptyState}>
{searchSpacesOnly && debouncedQuery
? t("No spaces found")
: t("No results found")}
</div>
) : ( ) : (
filteredSpaces.map((space) => ( spacesData?.items?.map((space) => (
<SpaceRow <SpaceRow
key={space.id} key={space.id}
space={space} space={space}
@@ -20,6 +20,4 @@ export type DestinationPickerModalProps = {
loading?: boolean; loading?: boolean;
excludePageId?: string; excludePageId?: string;
pageLimit?: number; pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
}; };
@@ -74,18 +74,7 @@ export function PageChildren({
/> />
))} ))}
{hasNextPage && ( {hasNextPage && (
<div <div className={classes.loadMore} onClick={() => fetchNextPage()}>
className={classes.loadMore}
onClick={() => fetchNextPage()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fetchNextPage();
}
}}
role="button"
tabIndex={0}
>
{t("Load more")} {t("Load more")}
</div> </div>
)} )}
@@ -1,6 +1,5 @@
import { KeyboardEvent, useState } from "react"; import { useState } from "react";
import { ActionIcon } from "@mantine/core"; import { IconChevronRight, IconFile } from "@tabler/icons-react";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types"; import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children"; import { PageChildren } from "./page-children";
@@ -37,44 +36,23 @@ export function PageRow({
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
const handleSelect = () => {
if (!isExcluded) onSelect(page);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
return ( return (
<> <>
<div <div
className={rowClasses} className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }} style={{ paddingLeft: depth * 20 + 12 }}
role="button" onClick={() => !isExcluded && onSelect(page)}
tabIndex={isExcluded ? -1 : 0}
aria-disabled={isExcluded || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
> >
{page.hasChildren ? ( {page.hasChildren ? (
<ActionIcon <div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`} className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpanded(!expanded); setExpanded(!expanded);
}} }}
> >
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</ActionIcon> </div>
) : ( ) : (
<div style={{ width: 20, flexShrink: 0 }} /> <div style={{ width: 20, flexShrink: 0 }} />
)} )}
@@ -83,14 +61,10 @@ export function PageRow({
{page.icon ? ( {page.icon ? (
page.icon page.icon
) : ( ) : (
<ActionIcon <IconFile
component="div" size={16}
variant="transparent" color="var(--mantine-color-gray-5)"
c="gray" />
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
import { KeyboardEvent, useState } from "react"; import { useState } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react"; import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types"; import { ISpace } from "@/features/space/types/space.types";
@@ -42,43 +42,21 @@ export function SpaceRow({
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
const handleSelect = () => {
if (writable) onSelectSpace(space);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
const rowContent = ( const rowContent = (
<div <div
className={rowClasses} className={rowClasses}
data-space-id={space.id} onClick={() => writable && onSelectSpace(space)}
role="button"
tabIndex={writable ? 0 : -1}
aria-disabled={!writable || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
> >
{writable ? ( {writable ? (
<ActionIcon <div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`} className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpanded(!expanded); setExpanded(!expanded);
}} }}
> >
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</ActionIcon> </div>
) : ( ) : (
<div style={{ width: 20, flexShrink: 0 }} /> <div style={{ width: 20, flexShrink: 0 }} />
)} )}
+3 -54
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from "react"; import React, { ReactNode, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Popover, Popover,
@@ -7,24 +7,9 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks"; import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react"; import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),
]);
const PickerComp = pickerModule.default;
const data = dataModule.default;
return {
default: (props: any) => <PickerComp {...props} data={data} />,
};
});
export interface EmojiPickerInterface { export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void; onEmojiSelect: (emoji: any) => void;
icon: ReactNode; icon: ReactNode;
@@ -34,7 +19,6 @@ export interface EmojiPickerInterface {
size?: string; size?: string;
variant?: string; variant?: string;
c?: string; c?: string;
tabIndex?: number;
}; };
} }
@@ -66,38 +50,6 @@ function EmojiPicker({
} }
}); });
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
// makes the browser scroll every scrollable ancestor of the search input to
// bring it on screen — including the page editor's scroll container, so the
// page jumps to the top whenever the picker is opened from a scrolled-down
// position. The search input lives inside the <em-emoji-picker> custom
// element's shadow root, so we poll for it after the dropdown mounts and
// focus it ourselves with preventScroll.
useEffect(() => {
if (!opened || !dropdown) return;
let cancelled = false;
let rafId = 0;
const tryFocus = (attempts: number) => {
if (cancelled) return;
const pickerEl = dropdown.querySelector("em-emoji-picker");
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (input) {
input.focus({ preventScroll: true });
return;
}
if (attempts < 60) {
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
}
};
rafId = requestAnimationFrame(() => tryFocus(0));
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [opened, dropdown]);
const handleEmojiSelect = (emoji) => { const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji); onEmojiSelect(emoji);
handlers.close(); handlers.close();
@@ -122,11 +74,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"} c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"} variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size} size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle} onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
aria-expanded={opened}
> >
{icon} {icon}
</ActionIcon> </ActionIcon>
@@ -134,6 +82,7 @@ function EmojiPicker({
<Suspense fallback={null}> <Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}> <Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker <Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
perLine={8} perLine={8}
skinTonePosition="search" skinTonePosition="search"
@@ -14,14 +14,7 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>( const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => { ({ opened, size = "sm", ...others }, ref) => {
return ( return (
<ActionIcon <ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
size={size}
aria-expanded={opened}
{...others}
variant="subtle"
color="gray"
ref={ref}
>
{opened ? ( {opened ? (
<IconLayoutSidebarRightExpand /> <IconLayoutSidebarRightExpand />
) : ( ) : (
@@ -1,27 +0,0 @@
.skipLink {
position: absolute;
top: 8px;
left: 8px;
z-index: 9999;
padding: 8px 16px;
background: var(--mantine-color-body);
color: var(--mantine-color-text);
border: 2px solid var(--mantine-color-blue-6);
border-radius: 4px;
text-decoration: none;
font-weight: 500;
font-size: var(--mantine-font-size-sm);
transform: translateY(-200%);
transition: transform 0.15s ease-out;
}
.skipLink:focus {
transform: translateY(0);
outline: none;
}
@media print {
.skipLink {
display: none !important;
}
}
@@ -1,13 +0,0 @@
import { useTranslation } from "react-i18next";
import classes from "./skip-to-main.module.css";
export const MAIN_CONTENT_ID = "main-content";
export function SkipToMain() {
const { t } = useTranslation();
return (
<a href={`#${MAIN_CONTENT_ID}`} className={classes.skipLink}>
{t("Skip to main content")}
</a>
);
}
@@ -132,7 +132,6 @@ export default function AiChatSidebarItem({
size="xs" size="xs"
color="gray" color="gray"
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
> >
<IconDots size={14} /> <IconDots size={14} />
</ActionIcon> </ActionIcon>
@@ -120,7 +120,7 @@ export default function AiChatSidebar() {
return ( return (
<div className={classes.sidebar}> <div className={classes.sidebar}>
<div className={classes.header}> <div className={classes.header}>
<h2 className={classes.title}>{t("AI Chat")}</h2> <span className={classes.title}>{t("AI Chat")}</span>
<Tooltip label={t("New chat")} openDelay={250} withArrow> <Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
component={Link} component={Link}
@@ -137,8 +137,7 @@ export default function AiChatSidebar() {
<TextInput <TextInput
className={classes.searchInput} className={classes.searchInput}
placeholder={t("Search chats...")} placeholder="Search chats..."
aria-label={t("Search chats")}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
size="xs" size="xs"
value={search} value={search}
@@ -176,7 +175,7 @@ export default function AiChatSidebar() {
)) ))
: groupedChats.map((group) => ( : groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}> <div key={group.key} className={classes.chatGroup}>
<h3 className={classes.chatGroupLabel}>{group.label}</h3> <div className={classes.chatGroupLabel}>{group.label}</div>
{group.chats.map((chat) => ( {group.chats.map((chat) => (
<AiChatSidebarItem <AiChatSidebarItem
key={chat.id} key={chat.id}
@@ -178,7 +178,6 @@ export default function AsideChatPanel() {
href="/ai" href="/ai"
variant="subtle" variant="subtle"
color="dark" color="dark"
aria-label={t("New chat")}
onClick={handleNewChat} onClick={handleNewChat}
> >
<IconPlus size={20} stroke={1.75} /> <IconPlus size={20} stroke={1.75} />
@@ -186,23 +185,13 @@ export default function AsideChatPanel() {
</Tooltip> </Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}> <Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon <ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<IconArrowsDiagonal size={18} stroke={1.5} /> <IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Close")} openDelay={250}> <Tooltip label={t("Close")} openDelay={250}>
<ActionIcon <ActionIcon variant="subtle" color="dark" onClick={handleClose}>
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<IconX size={20} stroke={1.75} /> <IconX size={20} stroke={1.75} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -56,22 +56,22 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyState}> <div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} /> <IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div> <div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<h1 className={classes.emptyStateTitle}> <div className={classes.emptyStateTitle}>
{t("What can I help you with?")} {t("What can I help you with?")}
</h1> </div>
<div className={classes.emptyStateInput}> <div className={classes.emptyStateInput}>
<ChatInput <ChatInput
isStreaming={isStreaming} isStreaming={isStreaming}
onSend={onSend} onSend={onSend}
onStop={onStop} onStop={onStop}
placeholder={t("Ask anything... Use @ to mention pages")} placeholder="Ask anything... Use @ to mention pages"
autofocus autofocus
/> />
</div> </div>
<div className={classes.suggestionsSection}> <div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2> <div className={classes.suggestionsLabel}>Get started</div>
<div className={classes.suggestionsGrid}> <div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => ( {SUGGESTIONS.map((s) => (
<button <button
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false, link: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder || t("Ask anything... Use @ to mention pages"), placeholder: placeholder || "Ask anything... Use @ to mention pages",
}), }),
CharacterCount.configure({ CharacterCount.configure({
limit: 50000, limit: 50000,
@@ -225,11 +225,6 @@ export default function ChatInput({
}), }),
], ],
editorProps: { editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ( if (
@@ -280,8 +275,6 @@ export default function ChatInput({
type="file" type="file"
accept={ACCEPTED_FILE_TYPES} accept={ACCEPTED_FILE_TYPES}
multiple multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
/> />
@@ -336,15 +329,7 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} /> <EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}> <div className={classes.actions}>
<Popover <Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
opened={plusMenuOpen}
onChange={setPlusMenuOpen}
position="top-start"
width={220}
shadow="md"
trapFocus
returnFocus
>
<Popover.Target> <Popover.Target>
<button <button
type="button" type="button"
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react"; import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types"; import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message"; import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css"; import classes from "../styles/ai-chat.module.css";
@@ -34,7 +33,6 @@ export default function ChatMessageList({
streamingContent, streamingContent,
streamingToolCalls, streamingToolCalls,
}: Props) { }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
@@ -42,38 +40,6 @@ export default function ChatMessageList({
const prevScrollTopRef = useRef(0); const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
// Dedicated status-region announcement for screen readers. Rather than
// putting aria-live on the whole transcript (which re-fires for every
// streamed token), announce "AI is thinking…" when streaming starts and
// the full assistant reply once streaming completes — a single, clean read.
const [statusAnnouncement, setStatusAnnouncement] = useState("");
const wasStreamingRef = useRef(false);
useEffect(() => {
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
if (justStartedStreaming) {
setStatusAnnouncement(t("AI is thinking..."));
} else if (justFinishedStreaming) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant" && lastMessage.content) {
// Strip markdown punctuation so screen readers don't read symbols
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
// version stays in the DOM for visual users.
const plainText = lastMessage.content
.replace(/[#*_`~]/g, "")
.replace(/\s+/g, " ")
.trim();
setStatusAnnouncement(plainText);
} else {
setStatusAnnouncement("");
}
}
wasStreamingRef.current = isStreaming;
}, [isStreaming, messages, t]);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@@ -161,18 +127,7 @@ export default function ChatMessageList({
return ( return (
<div className={classes.messageListWrapper}> <div className={classes.messageListWrapper}>
{/* Single status region for chat announcements. Kept outside the <div ref={containerRef} className={classes.messageList}>
scrolling transcript so changes here trigger one polite read per
state change instead of re-announcing every streamed token. */}
<VisuallyHidden role="status" aria-live="polite">
{statusAnnouncement}
</VisuallyHidden>
<div
ref={containerRef}
className={classes.messageList}
aria-label={t("Chat transcript")}
>
{messages.map((msg) => ( {messages.map((msg) => (
<ErrorBoundary <ErrorBoundary
key={msg.id} key={msg.id}
@@ -207,7 +162,7 @@ export default function ChatMessageList({
{showScrollButton && ( {showScrollButton && (
<button <button
type="button" type="button"
aria-label={t("Scroll to bottom")} aria-label="Scroll to bottom"
className={classes.scrollToBottomButton} className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")} onClick={() => scrollToBottom("smooth")}
> >
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { import {
@@ -44,7 +43,6 @@ export default function ChatMessage({
streamingToolCalls, streamingToolCalls,
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback( const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
@@ -80,11 +78,7 @@ export default function ChatMessage({
}[]) || []; }[]) || [];
return ( return (
<div <div className={classes.userMessage}>
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userBubble}> <div className={classes.userBubble}>
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className={classes.messageAttachments}> <div className={classes.messageAttachments}>
@@ -106,16 +100,8 @@ export default function ChatMessage({
); );
} }
// Only label the article when there's something meaningful to announce.
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
const hasAnnouncableContent = Boolean(content);
return ( return (
<div <div className={classes.assistantMessage}>
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.messageContent}> <div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && ( {toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} /> <ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
@@ -145,10 +131,7 @@ export default function ChatMessage({
</div> </div>
{!isStreaming && message.content && ( {!isStreaming && message.content && (
<div className={classes.messageActions}> <div className={classes.messageActions}>
<CopyTextButton <CopyTextButton text={message?.content} />
text={message?.content}
label={t("Copy assistant response")}
/>
</div> </div>
)} )}
</div> </div>
@@ -31,16 +31,7 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}> <div className={classes.toolGroup}>
<div <div
className={classes.toolGroupHeader} className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)} onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
> >
{activeLabel ? ( {activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} /> <IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs); margin-bottom: var(--mantine-spacing-xs);
} }
@@ -106,7 +106,6 @@
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl); margin-bottom: var(--mantine-spacing-xl);
text-align: center; text-align: center;
} }
@@ -126,10 +125,9 @@
.suggestionsLabel { .suggestionsLabel {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 500; font-weight: 500;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
} }
@@ -43,7 +43,7 @@
margin-top: 6px; margin-top: 6px;
text-align: center; text-align: center;
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
} }
.attachmentChips { .attachmentChips {
@@ -114,7 +114,7 @@
} }
:global(.ProseMirror p.is-editor-empty:first-child::before) { :global(.ProseMirror p.is-editor-empty:first-child::before) {
color: var(--mantine-color-placeholder); color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;
height: 0; height: 0;
@@ -183,7 +183,7 @@
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none; background: none;
cursor: pointer; cursor: pointer;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3)); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms; transition: color 150ms, background-color 150ms;
@mixin hover { @mixin hover {
@@ -15,7 +15,6 @@
} }
.title { .title {
margin: 0;
font-weight: 600; font-weight: 600;
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
} }
@@ -34,11 +33,10 @@
} }
.chatGroupLabel { .chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs); padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none; user-select: none;
} }
@@ -106,7 +104,7 @@
.chatItemDate { .chatItemDate {
font-size: var(--mantine-font-size-xs); font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed); color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap; white-space: nowrap;
transition: opacity 150ms; transition: opacity 150ms;
} }
@@ -120,8 +118,7 @@
color: inherit; color: inherit;
} }
.chatItem:hover .chatItemDate, .chatItem:hover .chatItemDate {
.chatItem:focus-within .chatItemDate {
opacity: 0; opacity: 0;
} }
@@ -136,12 +133,6 @@
position: relative; position: relative;
} }
.chatItem:hover .chatItemActions, .chatItem:hover .chatItemActions {
.chatItem:focus-within .chatItemActions {
opacity: 1; opacity: 1;
} }
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@@ -31,9 +31,8 @@ export function ApiKeyCreatedModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })} title={t("API key created")}
size="lg" size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Alert <Alert
@@ -42,8 +41,7 @@ export function ApiKeyCreatedModal({
color="red" color="red"
> >
{t( {t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!", "Make sure to copy your API key now. You won't be able to see it again!",
{ credential: t("API key") },
)} )}
</Alert> </Alert>
@@ -66,7 +64,7 @@ export function ApiKeyCreatedModal({
</div> </div>
<Button fullWidth onClick={onClose} mt="md"> <Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("API key") })} {t("I've saved my API key")}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
@@ -44,7 +44,7 @@ export function ApiKeyTable({
<Table.Th>{t("Last used")}</Table.Th> <Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th> <Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th> <Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} /> <Table.Th></Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -106,11 +106,7 @@ export function ApiKeyTable({
<Table.Td> <Table.Td>
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="subtle" color="gray">
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -105,9 +105,8 @@ export function CreateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={handleClose} onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })} title={t("Create API Key")}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -30,15 +30,12 @@ export function RevokeApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })} title={t("Revoke API key")}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<Stack gap="md"> <Stack gap="md">
<Text> <Text>
{t("Are you sure you want to revoke this {{credential}}", { {t("Are you sure you want to revoke this API key")}{" "}
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>? <strong>{apiKey?.name}</strong>?
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -53,9 +53,8 @@ export function UpdateApiKeyModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })} title={t("Update API key")}
size="md" size="md"
closeButtonProps={{ "aria-label": t("Close") }}
> >
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md"> <Stack gap="md">
@@ -63,11 +63,7 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({ return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data), mutationFn: (data) => createApiKey(data),
onSuccess: () => { onSuccess: () => {
notifications.show({ notifications.show({ message: t("API key created successfully") });
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string), ["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,10 +33,6 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key", "api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key", "api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space", "space.created": "Created space",
"space.updated": "Updated space", "space.updated": "Updated space",
"space.deleted": "Deleted space", "space.deleted": "Deleted space",
@@ -178,14 +174,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" }, { value: "api_key.deleted", label: "Deleted API key" },
], ],
}, },
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{ {
group: "License", group: "License",
items: [ items: [
@@ -111,11 +111,6 @@ export function LdapLoginModal({
placeholder={t("Enter your LDAP password")} placeholder={t("Enter your LDAP password")}
variant="filled" variant="filled"
disabled={isLoading} disabled={isLoading}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
+5 -64
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react"; import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,37 +7,15 @@ import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() { export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery(); const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false); const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null); const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
const autoRedirectedRef = useRef(false);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => { const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) { if (provider.type === SSO_PROVIDER.LDAP) {
@@ -50,47 +28,10 @@ export default function SsoLogin() {
providerId: provider.id, providerId: provider.id,
type: provider.type, type: provider.type,
workspaceId: data.id, workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
}); });
} }
}; };
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => { const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) { if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />; return <GoogleIcon size={16} />;
-1
View File
@@ -8,7 +8,6 @@ export const Feature = {
AI: 'ai', AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence', CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx', DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing', ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings', SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp', MCP: 'mcp',
@@ -130,11 +130,6 @@ export function MfaBackupCodesModal({
label={t("Confirm password")} label={t("Confirm password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
variant="filled" variant="filled"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
data-autofocus data-autofocus
@@ -107,11 +107,6 @@ export function MfaDisableModal({
<PasswordInput <PasswordInput
label={t("Password")} label={t("Password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
data-autofocus data-autofocus
@@ -140,7 +140,7 @@ export function PagePermissionList({
)} )}
</Group> </Group>
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}> <ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => ( {sortedMembers.map((member) => (
<PagePermissionItem <PagePermissionItem
key={`${member.type}-${member.id}`} key={`${member.type}-${member.id}`}
@@ -158,7 +158,7 @@ export function PagePermissionList({
<Loader size="xs" /> <Loader size="xs" />
</Center> </Center>
)} )}
</ScrollArea.Autosize> </ScrollArea>
</> </>
); );
} }
@@ -79,13 +79,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
{t("Share")} {t("Share")}
</Button> </Button>
<Modal <Modal opened={opened} onClose={close} title={t("Share")} size={600}>
opened={opened}
onClose={close}
title={t("Share")}
size={600}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}> <Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md"> <Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab> <Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
@@ -1,12 +1,4 @@
import { import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
ActionIcon,
Group,
Menu,
Modal,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconRosetteDiscountCheckFilled, IconRosetteDiscountCheckFilled,
@@ -46,7 +38,6 @@ export function PageVerificationModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
aria-label={status === "none" ? t("Set up verification") : t("Verify page")}
title={ title={
<Group gap="xs"> <Group gap="xs">
<IconShieldCheck <IconShieldCheck
@@ -100,18 +91,13 @@ export function PageVerificationBadge({
if (!pageId) return null; if (!pageId) return null;
if (!hasVerificationFeature) { if (!hasVerificationFeature) {
if (readOnly) return null; if (readOnly) return null;
const lockedLabel = `${t("Add verification")}${upgradeLabel}`;
// Use ActionIcon (a real <button>) instead of a ThemeIcon so the tooltip
// is reachable on keyboard focus, and screen readers announce the upgrade
// hint via the accessible name. Click is a no-op since the feature is
// gated; the tooltip explains why.
return ( return (
<Tooltip label={lockedLabel} withArrow openDelay={250}> <Tooltip
<ActionIcon label={`${t("Add verification")}${upgradeLabel}`}
variant="subtle" withArrow
color="gray" openDelay={250}
aria-label={lockedLabel}
> >
<ActionIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -123,48 +109,28 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null; if (status === "none" && readOnly) return null;
const tooltipLabel =
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
{ month: "long", day: "numeric", year: "numeric" },
),
})
: getStatusLabel(status, t);
return ( return (
<> <>
{status !== "none" ? ( {status !== "none" ? (
<Tooltip label={tooltipLabel} withArrow openDelay={250}> <Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<UnstyledButton <Group
gap={4}
onClick={open} onClick={open}
aria-label={tooltipLabel} style={{ cursor: "pointer" }}
style={{ wrap="nowrap"
display: "inline-flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
}}
> >
<IconRosetteDiscountCheckFilled <IconRosetteDiscountCheckFilled
size={18} size={18}
color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`} color={`var(--mantine-color-${getStatusColor(status).replace(".", "-")})`}
aria-hidden="true"
/> />
<Text size="sm" c={getStatusColor(status)}> <Text size="sm" c={getStatusColor(status)}>
{getStatusLabel(status, t)} {getStatusLabel(status, t)}
</Text> </Text>
</UnstyledButton> </Group>
</Tooltip> </Tooltip>
) : !readOnly ? ( ) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}> <Tooltip label={t("Set up verification")} withArrow openDelay={250}>
<ActionIcon <ActionIcon variant="subtle" color="gray" onClick={open}>
variant="subtle"
color="gray"
aria-label={t("Set up verification")}
onClick={open}
>
<IconShieldCheck size={20} stroke={1.5} /> <IconShieldCheck size={20} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -18,7 +18,6 @@ import { CustomAvatar } from "@/components/ui/custom-avatar";
import { buildPageUrl } from "@/features/page/page.utils"; import { buildPageUrl } from "@/features/page/page.utils";
import { format } from "date-fns"; import { format } from "date-fns";
import NoTableResults from "@/components/common/no-table-results"; import NoTableResults from "@/components/common/no-table-results";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
const MAX_VISIBLE_VERIFIERS = 5; const MAX_VISIBLE_VERIFIERS = 5;
@@ -125,13 +124,12 @@ export default function VerificationListTable({
); );
return ( return (
<Table.Tr key={item.id} className={rowClasses.row}> <Table.Tr key={item.id}>
<Table.Td> <Table.Td>
<Anchor <Anchor
size="sm" size="sm"
underline="never" underline="never"
style={{ color: "var(--mantine-color-text)" }} style={{ color: "var(--mantine-color-text)" }}
className={rowClasses.link}
component={Link} component={Link}
to={pageUrl} to={pageUrl}
> >
@@ -1,79 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,55 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -1,62 +0,0 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,70 +0,0 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -1,134 +0,0 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Token actions")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,30 +0,0 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -1,78 +0,0 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -1,96 +0,0 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -1,34 +0,0 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -1,27 +0,0 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.templates?.allowMemberTemplates === true, workspace?.settings?.templates?.allowMemberTemplates === true,
); );
const hasTemplates = useHasFeature(Feature.TEMPLATES); const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -54,11 +54,15 @@ function AllowMemberTemplatesToggle() {
}; };
return ( return (
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef"> <Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasTemplates} disabled={!hasSecuritySettings}
aria-label={t("Toggle allow members to create templates")} aria-label={t("Toggle allow members to create templates")}
/> />
</Tooltip> </Tooltip>
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return ( return (
<> <>
<Card shadow="sm" radius="sm"> <Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600} maxHeight={400}> <Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm" stickyHeader> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Name")}</Table.Th> <Table.Th>{t("Name")}</Table.Th>
@@ -141,7 +141,6 @@ export default function SsoProviderList() {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={t("Edit {{name}}", { name: provider.name })}
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
@@ -153,13 +152,7 @@ export default function SsoProviderList() {
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="subtle" color="gray">
variant="subtle"
color="gray"
aria-label={t("More actions for {{name}}", {
name: provider.name,
})}
>
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -32,7 +32,6 @@ export default function SsoProviderModal({
ssoProviderType: provider.type.toUpperCase(), ssoProviderType: provider.type.toUpperCase(),
})} })}
onClose={onClose} onClose={onClose}
closeButtonProps={{ "aria-label": t("Close") }}
> >
{provider.type === SSO_PROVIDER.SAML && ( {provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} /> <SsoSamlForm provider={provider} onClose={onClose} />
+6 -136
View File
@@ -1,18 +1,8 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { import { Divider, Title } from "@mantine/core";
Alert, import React from "react";
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx"; import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"; import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -22,41 +12,16 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasScim = useHasFeature(Feature.SCIM); const hasRetention = useHasFeature(Feature.RETENTION);
const [workspace] = useAtom(workspaceAtom); const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -80,7 +45,7 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
<Title order={4} my="lg"> <Title order={4} my="lg">
{t("Single sign-on (SSO)")} Single sign-on (SSO)
</Title> </Title>
<EnforceSso /> <EnforceSso />
@@ -101,101 +66,6 @@ export default function Security() {
)} )}
<SsoProviderList /> <SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</> </>
); );
} }
+3 -10
View File
@@ -18,21 +18,14 @@ export function buildSsoLoginUrl(opts: {
providerId: string; providerId: string;
type: SSO_PROVIDER; type: SSO_PROVIDER;
workspaceId?: string; workspaceId?: string;
redirect?: string;
}): string { }): string {
const { providerId, type, workspaceId, redirect } = opts; const { providerId, type, workspaceId } = opts;
const domain = getAppUrl(); const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) { if (type === SSO_PROVIDER.GOOGLE) {
if (workspaceId) params.set("workspaceId", workspaceId); return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
} }
const query = params.toString(); return `${domain}/api/sso/${type}/${providerId}/login`;
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
} }
export function getGoogleSignupUrl(): string { export function getGoogleSignupUrl(): string {
@@ -8,11 +8,6 @@
@mixin hover { @mixin hover {
transform: scale(1.02); transform: scale(1.02);
} }
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
} }
.cardBody { .cardBody {
@@ -55,27 +50,18 @@
.footer { .footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: space-between;
gap: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-sm); padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg); margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
} }
.scopeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.menuTarget { .menuTarget {
opacity: 0; opacity: 0;
transition: opacity 100ms ease; transition: opacity 100ms ease;
.card:hover &, .card:hover & {
.card:focus-within & {
opacity: 1; opacity: 1;
} }
} }
@@ -1,4 +1,4 @@
import { Button, Card, Text, ActionIcon, Menu, Group } from "@mantine/core"; import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import { import {
IconDots, IconDots,
IconEdit, IconEdit,
@@ -12,7 +12,6 @@ import classes from "./template-card.module.css";
type TemplateCardProps = { type TemplateCardProps = {
template: ITemplate; template: ITemplate;
spaceName?: string; spaceName?: string;
onPreview: (template: ITemplate) => void;
onUse: (template: ITemplate) => void; onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void; onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void; onDelete?: (template: ITemplate) => void;
@@ -22,7 +21,6 @@ type TemplateCardProps = {
export default function TemplateCard({ export default function TemplateCard({
template, template,
spaceName, spaceName,
onPreview,
onUse, onUse,
onEdit, onEdit,
onDelete, onDelete,
@@ -36,17 +34,7 @@ export default function TemplateCard({
padding="lg" padding="lg"
className={classes.card} className={classes.card}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
role="button" onClick={() => onUse(template)}
tabIndex={0}
aria-label={t("Preview template: {{title}}", { title: template.title })}
onClick={() => onPreview(template)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPreview(template);
}
}}
> >
<div className={classes.cardBody}> <div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md"> <Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
@@ -59,17 +47,6 @@ export default function TemplateCard({
)} )}
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button
size="compact-xs"
variant="filled"
className={classes.menuTarget}
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
{t("Use")}
</Button>
{canManage && ( {canManage && (
<Menu width={150} shadow="md" withArrow> <Menu width={150} shadow="md" withArrow>
<Menu.Target> <Menu.Target>
@@ -79,7 +56,6 @@ export default function TemplateCard({
color="gray" color="gray"
className={classes.menuTarget} className={classes.menuTarget}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={t("Template menu")}
> >
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
@@ -114,7 +90,6 @@ export default function TemplateCard({
<div className={classes.title}>{template.title}</div> <div className={classes.title}>{template.title}</div>
<div className={classes.footer}> <div className={classes.footer}>
<span className={classes.scopeDot} aria-hidden="true" />
<Text size="sm" fw={500} c="dimmed"> <Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")} {template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text> </Text>
@@ -1,70 +0,0 @@
.row {
position: relative;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
width: 100%;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
text-align: left;
}
.scope {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within & {
opacity: 0;
}
}
.useButton {
position: absolute;
top: 50%;
right: var(--mantine-spacing-sm);
transform: translateY(-50%);
opacity: 0;
transition: opacity 100ms ease;
.row:hover &,
.row:focus-within &,
&:focus-visible {
opacity: 1;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
@@ -1,259 +0,0 @@
import { useMemo, useState } from "react";
import {
Button,
Modal,
TextInput,
ScrollArea,
Loader,
Text,
UnstyledButton,
Group,
SegmentedControl,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconArrowRight,
IconSearch,
IconFileText,
} from "@tabler/icons-react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useGetTemplatesQuery,
useUseTemplateMutation,
} from "@/ee/template/queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { ITemplate } from "@/ee/template/types/template.types";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import { buildPageUrl } from "@/features/page/page.utils";
import classes from "./template-picker-modal.module.css";
type TemplatePickerModalProps = {
opened: boolean;
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
};
type ScopeFilter = "current" | "all";
export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 200);
const [scope, setScope] = useState<ScopeFilter>(
initialSpaceId ? "current" : "all",
);
// Two-stage selection: previewing first, then destination-picker.
// `previewTemplate` is set when the user clicks a row in the picker.
// `destinationTemplate` is set when they click "Use template" in the preview.
const [previewTemplate, setPreviewTemplate] = useState<ITemplate | null>(
null,
);
const [destinationTemplate, setDestinationTemplate] =
useState<ITemplate | null>(null);
const { data, isPending } = useGetTemplatesQuery({
spaceId: scope === "current" ? initialSpaceId : undefined,
});
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaceNamesById = useMemo(() => {
const map = new Map<string, string>();
spacesData?.items?.forEach((s) => map.set(s.id, s.name));
return map;
}, [spacesData]);
const filtered = useMemo(() => {
const all = data?.pages.flatMap((p) => p.items) ?? [];
const term = debouncedQuery.trim().toLowerCase();
if (!term) return all;
return all.filter((tpl) => tpl.title.toLowerCase().includes(term));
}, [data, debouncedQuery]);
const createInInitialSpace = async (tpl: ITemplate) => {
if (!initialSpaceId) return;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: tpl.id,
spaceId: initialSpaceId,
});
setPreviewTemplate(null);
onClose();
const space = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (page?.slugId && space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
} catch {
// error notification handled by mutation's onError
}
};
const handlePick = (tpl: ITemplate) => {
setPreviewTemplate(tpl);
};
const handleQuickUse = (tpl: ITemplate) => {
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
}
setDestinationTemplate(tpl);
};
const handlePreviewClose = () => {
// Closing preview returns to the picker list (no full unmount).
setPreviewTemplate(null);
};
const handlePreviewUse = () => {
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;
}
// Move from preview into destination-picker stage.
setDestinationTemplate(previewTemplate);
setPreviewTemplate(null);
};
const handleDestinationClose = () => {
setDestinationTemplate(null);
onClose();
};
const handleClose = () => {
setQuery("");
setScope(initialSpaceId ? "current" : "all");
setPreviewTemplate(null);
setDestinationTemplate(null);
onClose();
};
return (
<>
<Modal
opened={opened && !previewTemplate && !destinationTemplate}
onClose={handleClose}
size={550}
padding="lg"
yOffset="10vh"
title={<Text fw={500}>{t("Use a template")}</Text>}
>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search templates...")}
variant="filled"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
mb="xs"
autoFocus
/>
{initialSpaceId && (
<SegmentedControl
fullWidth
size="xs"
mb="sm"
value={scope}
onChange={(v) => setScope(v as ScopeFilter)}
data={[
{ label: t("This space"), value: "current" },
{ label: t("All templates"), value: "all" },
]}
/>
)}
<ScrollArea h="50vh" offsetScrollbars>
{isPending ? (
<div className={classes.empty}>
<Loader size="xs" />
</div>
) : filtered.length === 0 ? (
<div className={classes.empty}>
<Text size="sm" c="dimmed">
{t("No templates found")}
</Text>
</div>
) : (
filtered.map((tpl) => (
<UnstyledButton
key={tpl.id}
className={classes.row}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
<Button
size="compact-xs"
variant="filled"
className={classes.useButton}
loading={useTemplateMutation.isPending}
disabled={useTemplateMutation.isPending}
onClick={(e) => {
e.stopPropagation();
handleQuickUse(tpl);
}}
>
{t("Use")}
</Button>
</UnstyledButton>
))
)}
</ScrollArea>
<Group justify="flex-end" mt="md">
<Button
component={Link}
to="/templates"
variant="subtle"
size="sm"
rightSection={<IconArrowRight size={16} />}
onClick={handleClose}
>
{t("Browse all templates")}
</Button>
</Group>
</Modal>
{previewTemplate && (
<TemplatePreviewModal
templateId={previewTemplate.id}
opened={true}
onClose={handlePreviewClose}
onUse={handlePreviewUse}
useLoading={useTemplateMutation.isPending}
/>
)}
{destinationTemplate && (
<UseTemplateModal
template={destinationTemplate}
opened={true}
onClose={handleDestinationClose}
initialSpaceId={initialSpaceId}
/>
)}
</>
);
}
@@ -9,7 +9,6 @@ type TemplatePreviewModalProps = {
onClose: () => void; onClose: () => void;
onUse: () => void; onUse: () => void;
onEdit?: () => void; onEdit?: () => void;
useLoading?: boolean;
}; };
export default function TemplatePreviewModal({ export default function TemplatePreviewModal({
@@ -18,7 +17,6 @@ export default function TemplatePreviewModal({
onClose, onClose,
onUse, onUse,
onEdit, onEdit,
useLoading,
}: TemplatePreviewModalProps) { }: TemplatePreviewModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId); const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
@@ -26,7 +24,7 @@ export default function TemplatePreviewModal({
const title = template?.title || t("Untitled"); const title = template?.title || t("Untitled");
return ( return (
<Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={title}> <Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header> <Modal.Header>
@@ -39,20 +37,15 @@ export default function TemplatePreviewModal({
</Group> </Group>
</Modal.Title> </Modal.Title>
<Group gap="sm"> <Group gap="sm">
<Button
size="xs"
onClick={onUse}
loading={useLoading}
disabled={useLoading}
>
{t("Use template")}
</Button>
{onEdit && ( {onEdit && (
<Button size="xs" variant="default" onClick={onEdit}> <Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")} {t("Edit")}
</Button> </Button>
)} )}
<Modal.CloseButton aria-label={t("Close")} /> <Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group> </Group>
</Modal.Header> </Modal.Header>
<Modal.Body p={0}> <Modal.Body p={0}>
@@ -10,14 +10,12 @@ type UseTemplateModalProps = {
template: ITemplate; template: ITemplate;
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
initialSpaceId?: string;
}; };
export default function UseTemplateModal({ export default function UseTemplateModal({
template, template,
opened, opened,
onClose, onClose,
initialSpaceId,
}: UseTemplateModalProps) { }: UseTemplateModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,8 +54,6 @@ export default function UseTemplateModal({
actionLabel={t("Create page")} actionLabel={t("Create page")}
onSelect={handleSelect} onSelect={handleSelect}
loading={useTemplateMutation.isPending} loading={useTemplateMutation.isPending}
initialSpaceId={initialSpaceId ?? template.spaceId}
searchSpacesOnly
/> />
); );
} }
@@ -75,18 +75,6 @@ export default function TemplateEditor() {
const editor = useEditor({ const editor = useEditor({
extensions: templateExtensions, extensions: templateExtensions,
content: "", content: "",
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
},
onUpdate() { onUpdate() {
if (loadedRef.current) { if (loadedRef.current) {
markDirty(); markDirty();
@@ -283,7 +271,6 @@ export default function TemplateEditor() {
variant="subtle" variant="subtle"
color="gray" color="gray"
size="md" size="md"
aria-label={t("Template settings")}
onClick={() => { onClick={() => {
setDraftSpaceId(spaceId); setDraftSpaceId(spaceId);
openSettings(); openSettings();
@@ -160,8 +160,7 @@ export default function TemplateList() {
? spaceNameMap.get(template.spaceId) ? spaceNameMap.get(template.spaceId)
: undefined : undefined
} }
onPreview={handlePreview} onUse={handlePreview}
onUse={handleUse}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
canManage={isWorkspaceAdmin} canManage={isWorkspaceAdmin}
@@ -6,7 +6,6 @@ import {
UseQueryResult, UseQueryResult,
InfiniteData, InfiniteData,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useAtom, useStore } from "jotai";
import { import {
getTemplates, getTemplates,
getTemplateById, getTemplateById,
@@ -19,12 +18,6 @@ import { ITemplate } from "@/ee/template/types/template.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useGetTemplatesQuery(params?: { spaceId?: string }) { export function useGetTemplatesQuery(params?: { spaceId?: string }) {
const { spaceId } = params ?? {}; const { spaceId } = params ?? {};
@@ -156,64 +149,13 @@ export function useDeleteTemplateMutation() {
export function useUseTemplateMutation() { export function useUseTemplateMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
const [, setTreeData] = useAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit();
return useMutation< return useMutation({
IPage, mutationFn: (data: {
Error, templateId: string;
{ templateId: string; spaceId: string; parentPageId?: string } spaceId: string;
>({ parentPageId?: string;
mutationFn: (data) => useTemplate(data), }) => useTemplate(data),
onSuccess: (page) => {
// React Query sidebar-pages cache update (same path useCreatePageMutation takes).
invalidateOnCreatePage(page);
const parentId = page.parentPageId ?? null;
const newNode: SpaceTreeNode = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
hasChildren: false,
children: [],
};
// Only mutate the tree atom and broadcast if it currently represents
// this space. Cross-space template-use (e.g., from the gallery picking
// a different space) lets the target space's clients pick up the new
// page on their next React Query refetch (focus, navigation, etc.).
// Without this guard we'd both pollute the local tree and send a wrong
// `index` to remote clients in the target space.
const current = store.get(treeDataAtom);
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
if (!treeIsForThisSpace) return;
const lastIndex =
parentId === null
? current.length
: (treeModel.find(current, parentId)?.children?.length ?? 0);
setTreeData((prev) =>
treeModel.insert(prev, parentId, newNode, lastIndex),
);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: page.spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
},
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ notifications.show({
@@ -1,6 +1,5 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { ITemplate } from "@/ee/template/types/template.types"; import { ITemplate } from "@/ee/template/types/template.types";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
export async function getTemplates(params?: { export async function getTemplates(params?: {
@@ -41,7 +40,7 @@ export async function useTemplate(data: {
templateId: string; templateId: string;
spaceId: string; spaceId: string;
parentPageId?: string; parentPageId?: string;
}): Promise<IPage> { }): Promise<any> {
const req = await api.post<IPage>("/templates/use", data); const req = await api.post("/templates/use", data);
return req.data; return req.data;
} }
@@ -20,7 +20,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
Docmost Docmost
</Text> </Text>
</Group> </Group>
<main>{children}</main> {children}
</> </>
); );
} }
@@ -103,11 +103,6 @@ export function InviteSignUpForm() {
placeholder={t("Your password")} placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -54,13 +54,6 @@ export function LoginForm() {
await signIn(data); await signIn(data);
} }
function handleValidationFailure(errors: Record<string, unknown>) {
const firstInvalidId = Object.keys(errors)[0];
if (firstInvalidId) {
document.getElementById(firstInvalidId)?.focus();
}
}
if (isDataLoading) { if (isDataLoading) {
return null; return null;
} }
@@ -73,7 +66,7 @@ export function LoginForm() {
<AuthLayout> <AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={1} size="h2" ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
{t("Login")} {t("Login")}
</Title> </Title>
@@ -81,31 +74,21 @@ export function LoginForm() {
{!data?.enforceSso && ( {!data?.enforceSso && (
<> <>
<form onSubmit={form.onSubmit(onSubmit, handleValidationFailure)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
label={t("Email")} label={t("Email")}
placeholder="email@example.com" placeholder="email@example.com"
variant="filled" variant="filled"
autoComplete="email"
errorProps={{ role: "alert" }}
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
id="password"
label={t("Password")} label={t("Password")}
placeholder={t("Your password")} placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
autoComplete="current-password"
errorProps={{ role: "alert" }}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
@@ -52,11 +52,6 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
placeholder={t("Your new password")} placeholder={t("Your new password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("newPassword")} {...form.getInputProps("newPassword")}
/> />
@@ -98,11 +98,6 @@ export function SetupWorkspaceForm() {
placeholder={t("Enter a strong password")} placeholder={t("Enter a strong password")}
variant="filled" variant="filled"
mt="md" mt="md"
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
@@ -166,7 +166,7 @@ export default function useAuth() {
const handleLogout = async () => { const handleLogout = async () => {
setCurrentUser(RESET); setCurrentUser(RESET);
await logout(); await logout();
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`); window.location.replace(APP_ROUTE.AUTH.LOGIN);
}; };
const handleForgotPassword = async (data: IForgotPassword) => { const handleForgotPassword = async (data: IForgotPassword) => {
@@ -0,0 +1,15 @@
import { atom } from "jotai";
import { EditingCell } from "@/features/base/types/base.types";
export const activeViewIdAtom = atom<string | null>(null);
export const editingCellAtom = atom<EditingCell>(null);
export const activePropertyMenuAtom = atom<string | null>(null);
export const propertyMenuDirtyAtom = atom<boolean>(false);
export const propertyMenuCloseRequestAtom = atom<number>(0);
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
export const lastToggledRowIndexAtom = atom<number | null>(null);
@@ -0,0 +1,84 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const COLUMN_COUNT = 6;
const ROW_COUNT = 10;
// Deterministic per-cell widths so the skeleton doesn't flicker between
// renders. Values are rough normal distribution around 55-85 % of cell.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
export function BaseTableSkeleton() {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { arrayMove } from "@dnd-kit/sortable";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
import {
useBaseRowsQuery,
flattenRows,
} from "@/features/base/queries/base-row-query";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import classes from "@/features/base/styles/grid.module.css";
type BaseTableProps = {
baseId: string;
};
export function BaseTable({ baseId }: BaseTableProps) {
const { t } = useTranslation();
// Subscribe to the base's realtime room so other clients' edits,
// schema changes, and async-job completions reconcile into our cache.
useBaseSocket(baseId);
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
const views = base?.views ?? [];
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const activeFilter = activeView?.config?.filter;
const activeSorts = activeView?.config?.sorts;
// Hold the rows query until `base` has loaded. Otherwise the query
// fires once with `activeFilter` / `activeSorts` still undefined
// (a "bland" list request), then fires a second time as soon as the
// active view's config resolves — doubling network traffic on every
// base open for any view that has sort or filter.
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBaseRowsQuery(base ? baseId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
const { clear: clearSelection } = useRowSelection();
useEffect(() => {
clearSelection();
}, [baseId, activeView?.id, clearSelection]);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const { table, persistViewConfig } = useBaseTable(base, rows, activeView);
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
baseId,
cells: { [propertyId]: value },
});
},
[baseId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ baseId });
}, [baseId, createRowMutation]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
baseId,
name: t("New view"),
type: "table",
});
}, [baseId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(activeId: string, overId: string) => {
const currentOrder = table.getState().columnOrder;
const oldIndex = currentOrder.indexOf(activeId);
const newIndex = currentOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
table.setColumnOrder(newOrder);
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos = targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos = targetIndex < remainingRows.length - 1 ? remainingRows[targetIndex + 1]?.position : null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRowMutation.mutate({
rowId,
baseId,
position: newPosition,
});
} catch {
// Position computation failed — skip silently
}
},
[rows, baseId, reorderRowMutation],
);
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<BaseToolbar
base={base}
activeView={activeView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
/>
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
baseId={baseId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
/>
</div>
);
}
@@ -0,0 +1,310 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/features/base/types/base.types";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table";
import { exportBaseToCsv } from "@/features/base/services/base-service";
import { ViewTabs } from "@/features/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config";
import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type BaseToolbarProps = {
base: IBase;
activeView: IBaseView | undefined;
views: IBaseView[];
table: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
onPersistViewConfig: () => void;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
onPersistViewConfig,
}: BaseToolbarProps) {
const { t } = useTranslation();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [fieldsOpened, setFieldsOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const toolbarRightRef = useRef<HTMLDivElement>(null);
// Mantine `<Popover>`'s built-in dismiss handlers don't fire reliably
// for the toolbar popovers (same issue that drove the property menu to
// use custom listeners in `grid-container.tsx`). Close any open toolbar
// popover on outside mousedown AND on ESC.
useEffect(() => {
if (!sortOpened && !filterOpened && !fieldsOpened) return;
const closeAll = () => {
setSortOpened(false);
setFilterOpened(false);
setFieldsOpened(false);
};
const mouseHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (toolbarRightRef.current?.contains(target)) return;
// Ignore clicks that land inside any Mantine popover dropdown
// (role=dialog), any Select/Combobox dropdown (role=listbox, the
// container; option elements have role=option), or anything
// rendered into Mantine's shared portal node. Without these, a
// nested Select inside the popover would close the parent.
if (target.closest('[role="dialog"]')) return;
if (target.closest('[role="listbox"]')) return;
if (target.closest('[role="option"]')) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
closeAll();
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") closeAll();
};
const id = setTimeout(() => {
document.addEventListener("mousedown", mouseHandler);
}, 0);
document.addEventListener("keydown", keyHandler);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", mouseHandler);
document.removeEventListener("keydown", keyHandler);
};
}, [sortOpened, filterOpened, fieldsOpened]);
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
const openToolbar = useCallback((panel: "sort" | "filter" | "fields") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setFieldsOpened(panel === "fields" ? (v) => !v : false);
}, []);
const updateViewMutation = useUpdateViewMutation();
const sorts = activeView?.config?.sorts ?? [];
// Stored view config uses the engine's filter tree. The popover edits
// an AND-only flat list; we unwrap the top-level group's children when
// reading and rewrap on save.
const conditions = useMemo<FilterCondition[]>(() => {
const filter = activeView?.config?.filter;
if (!filter || filter.op !== "and") return [];
return filter.children.filter(
(c): c is FilterCondition => !("children" in c),
);
}, [activeView?.config?.filter]);
const hiddenFieldCount = useMemo(() => {
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table.getState().columnVisibility]);
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
if (!activeView) return;
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: newSorts,
});
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config,
});
},
[activeView, base.id, table, updateViewMutation],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
if (!activeView) return;
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
// `filter: undefined` in overrides removes the filter key; the helper's
// spread-then-overrides order means `undefined` wins over any base filter.
const config = buildViewConfigFromTable(table, activeView.config, {
filter,
});
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config,
});
},
[activeView, base.id, table, updateViewMutation],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
baseId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
/>
<div className={classes.toolbarRight} ref={toolbarRightRef}>
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
<ViewFieldVisibility
opened={fieldsOpened}
onClose={() => setFieldsOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide fields")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenFieldCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("fields")}
>
<IconEye size={16} />
{hiddenFieldCount > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{hiddenFieldCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFieldVisibility>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({
value,
onCommit,
}: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
onCommit(!checked);
}, [checked, onCommit]);
return (
<div className={cellClasses.checkboxCell} onClick={handleChange}>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{ input: { cursor: "pointer", pointerEvents: "none" } }}
/>
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,141 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -0,0 +1,90 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellEmail({
value,
isEditing,
onCommit,
onCancel,
}: CellEmailProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}

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