Compare commits

..

20 Commits

Author SHA1 Message Date
Philipinho 6433cddb98 fix callout in columns 2026-02-24 15:22:11 +00:00
Philipinho 038d87c08a quote 2026-02-24 14:33:20 +00:00
Philipinho 22ade69f97 fix blockquote 2026-02-24 14:30:14 +00:00
Philipinho aef806a262 selective placeholder 2026-02-24 14:25:51 +00:00
Philipinho 6205741d26 fix columns 2026-02-24 14:14:38 +00:00
Philipinho 6f66577d61 fix print 2026-02-24 13:50:34 +00:00
Philipinho 6f0fb9beff hide columns menu when some nodes are focused 2026-02-24 13:49:22 +00:00
Philipinho c5639feff5 fix print 2026-02-24 13:45:32 +00:00
Philipinho b164ff2e2f capture tab key in column 2026-02-24 13:36:31 +00:00
Philipinho 59f111e730 focus on first column 2026-02-24 13:34:45 +00:00
Philipinho 9f2be72173 notes callout 2026-02-24 13:21:49 +00:00
Philipinho 993d771282 feat: columns 2026-02-24 13:01:40 +00:00
Philipinho a925dc0782 fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup 2026-02-24 10:35:09 +00:00
Philipinho 0cc3c6c68a fix color scheme 2026-02-24 10:14:19 +00:00
Philipinho 052b2042ff refresh table menus 2026-02-24 09:58:32 +00:00
Philipinho 22182418ed callout menu refresh 2026-02-24 09:52:54 +00:00
Philipinho e71584dfd4 video resize 2026-02-24 09:46:00 +00:00
Philipinho a1b6e7dbbd support image resize undo 2026-02-24 09:38:05 +00:00
Philipinho 8c380db8c3 refactor excalidraw and drawio menu 2026-02-23 23:48:14 +00:00
Philipinho 4c5b684ed4 feat: new image menu
* switch to resizable side handles
* use pixels
2026-02-23 22:46:58 +00:00
1013 changed files with 12837 additions and 99049 deletions
File diff suppressed because it is too large Load Diff
@@ -1,309 +0,0 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -1,479 +0,0 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 510MB per-origin cap, but practically negligible. No cleanup logic needed.
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
1. "Save for everyone" (current behavior)
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
## Future extension
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
+1 -16
View File
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379
# options: local | s3 | azure
# options: local | s3
STORAGE_DRIVER=local
# S3 driver config
@@ -21,11 +21,6 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE=
# Azure Blob Storage driver config
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
AZURE_STORAGE_CONTAINER=
# default: 50mb
FILE_UPLOAD_SIZE_LIMIT=
@@ -48,18 +43,8 @@ POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
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)
DEBUG_MODE=false
-154
View File
@@ -1,154 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g. v0.25.3)'
required: true
permissions:
contents: write
env:
VERSION: ${{ inputs.version || github.ref_name }}
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
suffix: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
suffix: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.BUILD_APP_ID }}
private-key: ${{ secrets.BUILD_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=docmost/docmost,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.suffix }}
path: /tmp/digests/*
if-no-files-found: error
- name: Strip v prefix
id: strip-v
run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Export Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: false
tags: |
docmost/docmost:latest
docmost/docmost:${{ steps.strip-v.outputs.version }}
outputs: type=docker,dest=docmost-${{ matrix.suffix }}.docker.tar
cache-from: type=gha,scope=${{ matrix.suffix }}
- name: Compress image
run: gzip docmost-${{ matrix.suffix }}.docker.tar
- name: Upload image archive
uses: actions/upload-artifact@v4
with:
name: docker-image-${{ matrix.suffix }}
path: docmost-${{ matrix.suffix }}.docker.tar.gz
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digest-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for tags
id: meta
uses: docker/metadata-action@v5
with:
images: docmost/docmost
tags: |
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }},enable=${{ !contains(env.VERSION, '-') }}
type=raw,value=latest,enable=${{ !contains(env.VERSION, '-') }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'docmost/docmost@sha256:%s ' *)
- name: Download image archives
uses: actions/download-artifact@v4
with:
pattern: docker-image-*
path: /tmp/images
merge-multiple: true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: |
/tmp/images/docmost-amd64.docker.tar.gz
/tmp/images/docmost-arm64.docker.tar.gz
draft: true
+67 -79
View File
@@ -1,97 +1,85 @@
{
"name": "client",
"private": true,
"version": "0.90.0",
"version": "0.25.3",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/base-formula": "workspace:*",
"@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"react-clear-modal": "^2.0.18",
"@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.14",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.5",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.16.8",
"i18next-http-backend": "^2.7.3",
"jotai": "^2.16.2",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.27",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "1.345.5",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
"react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.12.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^7.2.4"
}
}
File diff suppressed because it is too large Load Diff
+14 -491
View File
@@ -7,7 +7,6 @@
"Add members": "Add members",
"Add to groups": "Add to groups",
"Add space members": "Add space members",
"Add to favorites": "Add to favorites",
"Admin": "Admin",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
@@ -71,14 +70,10 @@
"Export": "Export",
"Failed to create page": "Failed to create 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 import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Favorite spaces": "Favorite spaces",
"Favorite spaces appear here": "Favorite spaces appear here",
"Favorites": "Favorites",
"Full access": "Full access",
"Full page width": "Full page width",
"Full width": "Full width",
@@ -97,7 +92,6 @@
"Invite by email": "Invite by email",
"Invite members": "Invite members",
"Invite new members": "Invite new members",
"Invite People": "Invite People",
"Invited members who are yet to accept their invitation will appear here.": "Invited members who are yet to accept their invitation will appear here.",
"Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access",
"Join the workspace": "Join the workspace",
@@ -122,7 +116,6 @@
"No group found": "No group found",
"No page history saved yet.": "No page history saved yet.",
"No pages yet": "No pages yet",
"No shared pages": "No shared pages",
"No results found...": "No results found...",
"No user found": "No user found",
"Overview": "Overview",
@@ -137,7 +130,6 @@
"pages": "pages",
"Password": "Password",
"Password changed successfully": "Password changed successfully",
"People": "People",
"Pending": "Pending",
"Please confirm your action": "Please confirm your action",
"Preferences": "Preferences",
@@ -145,7 +137,6 @@
"Profile": "Profile",
"Recently updated": "Recently updated",
"Remove": "Remove",
"Remove from favorites": "Remove from favorites",
"Remove group member": "Remove group member",
"Remove space member": "Remove space member",
"Restore": "Restore",
@@ -182,7 +173,6 @@
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
"System settings": "System settings",
"Templates": "Templates",
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
@@ -217,14 +207,9 @@
"Reply...": "Reply...",
"Error loading comments.": "Error loading comments.",
"No comments yet.": "No comments yet.",
"No open comments.": "No open comments.",
"No resolved comments.": "No resolved comments.",
"Add a comment...": "Add a comment...",
"Edit comment": "Edit comment",
"Delete comment": "Delete comment",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Delete chat": "Delete chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
"Comment created successfully": "Comment created successfully",
"Error creating comment": "Error creating comment",
"Comment updated successfully": "Comment updated successfully",
@@ -243,6 +228,7 @@
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved",
"No active comments.": "No active comments.",
"No resolved comments.": "No resolved comments.",
"Revoke invitation": "Revoke invitation",
"Revoke": "Revoke",
"Don't": "Don't",
@@ -277,9 +263,6 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
"Alt text": "Alt text",
"Describe this for accessibility.": "Describe this for accessibility.",
"Add a description": "Add a description",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
@@ -290,19 +273,6 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"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",
"Note": "Note",
"Success": "Success",
@@ -315,11 +285,6 @@
"Save & Exit": "Save & Exit",
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
"Paste link": "Paste link",
"Paste link or search pages": "Paste link or search pages",
"Link to web page": "Link to web page",
"Recents": "Recents",
"Page or URL": "Page or URL",
"Link title": "Link title",
"Edit link": "Edit link",
"Remove link": "Remove link",
"Add link": "Add link",
@@ -365,11 +330,8 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"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 video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
@@ -380,12 +342,6 @@
"Divider": "Divider",
"Quote": "Quote",
"Image": "Image",
"Audio": "Audio",
"Embed PDF": "Embed PDF",
"Upload and embed a PDF file.": "Upload and embed a PDF file.",
"Embed as PDF": "Embed as PDF",
"Failed to load PDF": "Failed to load PDF",
"Convert to attachment": "Convert to attachment",
"File attachment": "File attachment",
"Toggle block": "Toggle block",
"Callout": "Callout",
@@ -411,10 +367,6 @@
"Write...": "Write...",
"Column count": "Column count",
"{{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",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -439,18 +391,10 @@
"{{latestVersion}} is available": "{{latestVersion}} is available",
"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 {{format}} file": "Choose {{format}} file",
"Reading": "Reading",
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
"Deactivate member": "Deactivate member",
"Activate member": "Activate member",
"Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.",
"Are you sure you want to activate this workspace member?": "Are you sure you want to activate this workspace member?",
"Deactivate": "Deactivate",
"Activate": "Activate",
"Deactivated": "Deactivated",
"Move": "Move",
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
@@ -482,13 +426,9 @@
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
"Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.",
"Requires an enterprise license": "Requires an enterprise license",
"Enable public sharing": "Enable public sharing",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
@@ -511,7 +451,6 @@
"Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all",
"View all": "View all",
"View all spaces": "View all spaces",
"Error": "Error",
"Failed to disable MFA": "Failed to disable MFA",
@@ -580,7 +519,7 @@
"Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.",
"Verify": "Verify",
"Trash": "Trash",
"Pages in trash will be permanently deleted after {{count}} days.": "Pages in trash will be permanently deleted after {{count}} days.",
"Pages in trash will be permanently deleted after 30 days.": "Pages in trash will be permanently deleted after 30 days.",
"Deleted": "Deleted",
"No pages in trash": "No pages in trash",
"Permanently delete page?": "Permanently delete page?",
@@ -589,8 +528,6 @@
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"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 restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
@@ -634,32 +571,31 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"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",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update": "Update",
"Update {{credential}}": "Update {{credential}}",
"Update API key": "Update API key",
"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",
"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.",
"Toggle restrict API keys to admins": "Toggle restrict API keys to admins",
"API key creation is restricted to admins by your workspace administrator.": "API key creation is restricted to admins by your workspace administrator.",
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Thinking": "Thinking",
"Ask a question...": "Ask a question...",
"AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
@@ -668,25 +604,6 @@
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP",
"AI": "AI",
"MCP": "MCP",
"Model Context Protocol (MCP)": "Model Context Protocol (MCP)",
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",
@@ -701,406 +618,12 @@
"Mark all as read": "Mark all as read",
"Mark as read": "Mark as read",
"More options": "More options",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mentioned you in a comment",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> commented on a page",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> updated a page",
"Watch page": "Watch page",
"Stop watching": "Stop watching",
"Watch space": "Watch space",
"Stop watching space": "Stop watching space",
"Email notifications": "Email notifications",
"Page updates": "Page updates",
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
"Page mentions": "Page mentions",
"Get notified when someone mentions you on a page.": "Receive notifications when someone mentions you on a page.",
"Comment mentions": "Comment mentions",
"Get notified when someone mentions you in a comment.": "Receive notifications when someone mentions you in a comment.",
"New comments": "New comments",
"Get notified about new comments on threads you participate in.": "Receive notifications about new comments in threads you are participating in.",
"Resolved comments": "Resolved comments",
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
"You are now watching this page": "Youre now watching this page",
"You are no longer watching this page": "Youre no longer watching this page",
"You are now watching this space": "Youre now watching this space",
"You are no longer watching this space": "Youre no longer watching this space",
"Direct": "Direct",
"Updates": "Updates",
"mentioned you in a comment": "mentioned you in a comment",
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
"Older": "Older",
"Restricted page": "Restricted page",
"Restricted pages cannot be shared publicly.": "Restricted pages cannot be shared publicly.",
"Restricted by parent": "Restricted by parent",
"Restricted": "Restricted",
"Open": "Open",
"Inherits restrictions from ancestor page": "Inherits restrictions from ancestor page",
"Only people listed below can access this page": "Only people listed below can access this page",
"Everyone in this space can access": "Everyone in this space can access",
"No additional restrictions on this page": "No additional restrictions on this page",
"Only specific people can access": "Only specific people can access",
"Use only inherited restrictions": "Use only inherited restrictions",
"Add restrictions on top of inherited": "Add restrictions on top of inherited",
"Inherited restriction": "Inherited restriction",
"Access limited by": "Access limited by",
"Restrict access to control who can view and edit this page": "Restrict access to control who can view and edit this page",
"Add additional restrictions specific to this page": "Add additional restrictions specific to this page",
"Access": "Access",
"People with access": "People with access",
"Remove all": "Remove all",
"Remove access": "Remove access",
"Remove all access": "Remove all access",
"Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?",
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
"Trash retention": "Trash retention",
"Pages in trash will be permanently deleted after this period.": "Pages in trash will be permanently deleted after this period.",
"Trash retention updated": "Trash retention updated",
"Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission",
"Removed page permission": "Removed page permission",
"day": "day",
"days": "days",
"week": "week",
"weeks": "weeks",
"month": "month",
"months": "months",
"year": "year",
"years": "years",
"Period": "Period",
"Fixed date": "Fixed date",
"Indefinitely": "Indefinitely",
"Days": "Days",
"Weeks": "Weeks",
"Months": "Months",
"Years": "Years",
"Pick a date": "Pick a date",
"Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} for this unit",
"Never expires. Verifiers can re-verify at any time.": "Never expires. Verifiers can re-verify at any time.",
"Verified": "Verified",
"Review needed": "Review needed",
"Verification expired": "Verification expired",
"Draft": "Draft",
"In Approval": "In Approval",
"In approval": "In approval",
"Approved": "Approved",
"Obsolete": "Obsolete",
"Expiring": "Expiring",
"Set up verification": "Set up verification",
"Verify page": "Verify page",
"Page verification": "Page verification",
"Add verification": "Add verification",
"Edit verification": "Edit verification",
"Search by title": "Search by title",
"Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
"Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )",
"Page stays editable at all times": "Page stays editable at all times",
"Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation",
"Approval workflow": "Approval workflow",
"Formal document lifecycle with named approvers.": "Formal document lifecycle with named approvers.",
"Draft → In approval → Approved → Obsolete": "Draft → In approval → Approved → Obsolete",
"Locked once approved, with full history": "Locked once approved, with full history",
"Designed for ISO 9001, ISO 13485, and FDA": "Designed for ISO 9001, ISO 13485, and FDA",
"Best for SOPs and controlled documents": "Best for SOPs and controlled documents",
"Back": "Back",
"Quality management": "Quality management",
"Recurring": "Recurring",
"Pages move through draft, approval, and approved stages.": "Pages move through draft, approval, and approved stages.",
"Verifiers": "Verifiers",
"Add verifier": "Add verifier",
"I've reviewed this page for accuracy": "I've reviewed this page for accuracy",
"Set up": "Set up",
"Remove verification": "Remove verification",
"Are you sure you want to remove verification from this page?": "Are you sure you want to remove verification from this page?",
"Assigned verifiers must periodically re-verify this page.": "Assigned verifiers must periodically re-verify this page.",
"Last verified by {{name}} {{time}} (expired)": "Last verified by {{name}} {{time}} (expired)",
"The fixed expiration date has passed.": "The fixed expiration date has passed.",
"Verified by {{name}} {{time}}": "Verified by {{name}} {{time}}",
"Expires {{date}}": "Expires {{date}}",
"Expired {{date}}": "Expired {{date}}",
"Mark as obsolete": "Mark as obsolete",
"Mark obsolete": "Mark obsolete",
"Returned by {{name}} {{time}}": "Returned by {{name}} {{time}}",
"No approval has been requested yet.": "No approval has been requested yet.",
"Submitted by {{name}} {{time}}": "Submitted by {{name}} {{time}}",
"Someone": "Someone",
"Approved by {{name}} {{time}}": "Approved by {{name}} {{time}}",
"This document has been marked as obsolete.": "This document has been marked as obsolete.",
"Rejection comment": "Rejection comment",
"Reason for returning this document...": "Reason for returning this document...",
"Confirm rejection": "Confirm rejection",
"Submit for approval": "Submit for approval",
"Reject": "Reject",
"Approve": "Approve",
"Re-submit for approval": "Re-submit for approval",
"Verified until": "Verified until",
"QMS": "QMS",
"Verified pages": "Verified pages",
"Search pages...": "Search pages...",
"Filter by space": "Filter by space",
"Filter by type": "Filter by type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verified a page",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> submitted a page for your approval",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
"Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
"Load more": "Load more",
"Log out of all devices": "Log out of all devices",
"Log out of all sessions except this device": "Log out of all sessions except this device",
"This Device": "This Device",
"Unknown device": "Unknown device",
"No active sessions": "No active sessions",
"Session revoked": "Session revoked",
"All other sessions revoked": "All other sessions revoked",
"Last used": "Last used",
"Created": "Created",
"Rename": "Rename",
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...",
"Assistant said:": "Assistant said:",
"Chat history": "Chat history",
"Chat name": "Chat name",
"Chat transcript": "Chat transcript",
"Close": "Close",
"Copy assistant response": "Copy assistant response",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.",
"How can I help you today?": "How can I help you today?",
"New chat": "New chat",
"No chat history": "No chat history",
"No chats found": "No chats found",
"No conversations yet": "No conversations yet",
"Open full page": "Open full page",
"Scroll to bottom": "Scroll to bottom",
"You said:": "You said:",
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"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.",
"Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat",
"Translate this page": "Translate this page",
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"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}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Older": "Older"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+88 -575
View File
@@ -7,7 +7,6 @@
"Add members": "Aggiungi membri",
"Add to groups": "Aggiungi ai gruppi",
"Add space members": "Aggiungi membri allo spazio",
"Add to favorites": "Aggiungi ai preferiti",
"Admin": "Amministratore",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Sei sicuro di voler eliminare questo gruppo? I membri perderanno l'accesso alle risorse accessibili da questo gruppo.",
"Are you sure you want to delete this page?": "Sei sicuro di voler eliminare questa pagina?",
@@ -48,19 +47,19 @@
"e.g ACME": "es. ACME",
"e.g ACME Inc": "es. ACME Inc",
"e.g Developers": "es. Sviluppatori",
"e.g Group for developers": "es. Gruppo per sviluppatori",
"e.g Group for developers": "es. Gruppo per gli sviluppatori",
"e.g product": "es. prodotto",
"e.g Product Team": "es. Team di prodotto",
"e.g Product Team": "es. Team di Prodotto",
"e.g Sales": "es. Vendite",
"e.g Space for product team": "es. Spazio per il team di prodotto",
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team vendite",
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
"Edit": "Modifica",
"Read": "Leggi",
"Edit group": "Modifica gruppo",
"Email": "Email",
"Enter a strong password": "Inserisci una password sicura",
"Enter valid email addresses separated by comma or space max_50": "Inserisci degli indirizzi email validi separati da virgola o spazio [max: 50]",
"enter valid emails addresses": "inserisci indirizzi email validi",
"enter valid emails addresses": "inserisci degli indirizzi email validi",
"Enter your current password": "Inserisci la tua password attuale",
"enter your full name": "inserisci il tuo nome completo",
"Enter your new password": "Inserisci la tua nuova password",
@@ -71,14 +70,10 @@
"Export": "Esporta",
"Failed to create page": "Impossibile creare la pagina",
"Failed to delete page": "Impossibile eliminare la pagina",
"Failed to restore page": "Impossibile ripristinare la pagina",
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
"Failed to import pages": "Impossibile importare le pagine",
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
"Failed to update data": "Impossibile aggiornare i dati",
"Favorite spaces": "Spazi preferiti",
"Favorite spaces appear here": "Gli spazi preferiti appariranno qui",
"Favorites": "Preferiti",
"Full access": "Accesso completo",
"Full page width": "Pagina a larghezza intera",
"Full width": "Larghezza intera",
@@ -97,7 +92,6 @@
"Invite by email": "Invita tramite email",
"Invite members": "Invita membri",
"Invite new members": "Invita nuovi membri",
"Invite People": "Invita persone",
"Invited members who are yet to accept their invitation will appear here.": "I membri invitati che non hanno ancora accettato il loro invito appariranno qui.",
"Invited members will be granted access to spaces the groups can access": "I membri invitati avranno accesso agli spazi a cui i gruppi possono accedere",
"Join the workspace": "Unisciti all'area di lavoro",
@@ -122,7 +116,6 @@
"No group found": "Nessun gruppo trovato",
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
"No pages yet": "Nessuna pagina per ora",
"No shared pages": "Nessuna pagina condivisa.",
"No results found...": "Nessun risultato trovato...",
"No user found": "Nessun utente trovato",
"Overview": "Panoramica",
@@ -137,7 +130,6 @@
"pages": "pagine",
"Password": "Password",
"Password changed successfully": "Password cambiata con successo",
"People": "Persone",
"Pending": "In sospeso",
"Please confirm your action": "Si prega di confermare la propria azione",
"Preferences": "Preferenze",
@@ -145,7 +137,6 @@
"Profile": "Profilo",
"Recently updated": "Aggiornato di recente",
"Remove": "Rimuovi",
"Remove from favorites": "Rimuovi dai preferiti",
"Remove group member": "Rimuovi membro dal gruppo",
"Remove space member": "Rimuovi membro dallo spazio",
"Restore": "Ripristina",
@@ -156,56 +147,55 @@
"Search for users": "Cerca un utente",
"Search for users and groups": "Cerca un utente o un gruppo",
"Search...": "Cerca...",
"Select language": "Seleziona lingua",
"Select role": "Seleziona ruolo",
"Select language": "Seleziona una lingua",
"Select role": "Seleziona un ruolo",
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
"Select theme": "Seleziona tema",
"Select theme": "Seleziona un tema",
"Send invitation": "Invia invito",
"Invitation sent": "Invito inviato",
"Settings": "Impostazioni",
"Setup workspace": "Configura workspace",
"Setup workspace": "Configura l'area di lavoro",
"Sign In": "Accedi",
"Sign Up": "Registrati",
"Slug": "Slug",
"Space": "Spazio",
"Space description": "Descrizione dello spazio",
"Space menu": "Menu dello spazio",
"Space menu": "Menu spazio",
"Space name": "Nome dello spazio",
"Space settings": "Impostazioni dello spazio",
"Space slug": "Slug dello spazio",
"Spaces": "Spazi",
"Spaces you belong to": "Spazi di cui fai parte",
"Spaces you belong to": "Spazi a cui appartieni",
"No space found": "Nessuno spazio trovato",
"Search for spaces": "Cerca spazi",
"Search for spaces": "Cerca uno spazio",
"Start typing to search...": "Inizia a digitare per cercare...",
"Status": "Stato",
"Successfully imported": "Importato con successo",
"Successfully restored": "Ripristinato con successo",
"System settings": "Impostazioni di sistema",
"Templates": "Modelli",
"Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
"Toggle full page width": "Attiva/disattiva larghezza completa della pagina",
"Toggle full page width": "Attiva/disattiva pagina a larghezza intera",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"untitled": "senza titolo",
"Untitled": "Senza titolo",
"Updated successfully": "Aggiornato con successo",
"User": "Utente",
"Workspace": "Workspace",
"Workspace Name": "Nome del workspace",
"Workspace settings": "Impostazioni del workspace",
"Workspace": "Area di lavoro",
"Workspace Name": "Nome dell'area di lavoro",
"Workspace settings": "Impostazioni dell'area di lavoro",
"You can change your password here.": "Qui puoi cambiare la tua password.",
"Your Email": "La tua email",
"Your import is complete.": "La tua importazione è completata.",
"Your name": "Il tuo nome",
"Your Name": "Il tuo nome",
"Your Name": "Il Tuo Nome",
"Your password": "La tua password",
"Your password must be a minimum of 8 characters.": "La tua password deve contenere almeno 8 caratteri.",
"Sidebar toggle": "Attiva/disattiva barra laterale",
"Comments": "Commenti",
"404 page not found": "404 pagina non trovata",
"Sorry, we can't find the page you are looking for.": "Siamo spiacenti, non riusciamo a trovare la pagina che stai cercando.",
"Take me back to homepage": "Torna alla homepage",
"Take me back to homepage": "Torna all'homepage",
"Forgot password": "Password dimenticata",
"Forgot your password?": "Hai dimenticato la password?",
"A password reset link has been sent to your email. Please check your inbox.": "Un link per il reset della password è stato inviato al tuo indirizzo email. Per favore, controlla la tua casella di posta.",
@@ -217,14 +207,9 @@
"Reply...": "Rispondi...",
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
"No comments yet.": "Nessun commento per ora.",
"No open comments.": "Nessun commento aperto.",
"No resolved comments.": "Nessun commento risolto.",
"Add a comment...": "Aggiungi un commento...",
"Edit comment": "Modifica commento",
"Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
"Delete chat": "Elimina chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
"Comment created successfully": "Commento creato con successo",
"Error creating comment": "Si è verificato un errore durante la creazione del commento",
"Comment updated successfully": "Commento aggiornato con successo",
@@ -233,16 +218,17 @@
"Failed to delete comment": "Impossibile eliminare il commento",
"Comment resolved successfully": "Commento risolto con successo",
"Comment re-opened successfully": "Commento riaperto con successo",
"Comment unresolved successfully": "Commento contrassegnato come non risolto con successo",
"Comment unresolved successfully": "Commento non risolto con successo",
"Failed to resolve comment": "Impossibile risolvere il commento",
"Resolve comment": "Risolvi commento",
"Unresolve comment": "Segna commento come non risolto",
"Resolve Comment Thread": "Risolvi discussione del commento",
"Unresolve Comment Thread": "Segna discussione del commento come non risolta",
"Unresolve comment": "Annulla risoluzione commento",
"Resolve Comment Thread": "Risolvi discussione commenti",
"Unresolve Comment Thread": "Annulla risoluzione discussione commenti",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sei sicuro di voler risolvere questa discussione di commenti? Questo la contrassegnerà come completata.",
"Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?",
"Resolved": "Risolto",
"No active comments.": "Nessun commento attivo.",
"No resolved comments.": "Nessun commento risolto.",
"Revoke invitation": "Revoca invito",
"Revoke": "Revoca",
"Don't": "Non",
@@ -261,7 +247,7 @@
"Are you sure you want to delete this space?": "Sei sicuro di voler eliminare questo spazio?",
"Delete this space with all its pages and data.": "Elimina questo spazio con tutte le sue pagine e i suoi dati.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi di questo spazio verranno eliminati irreversibilmente.",
"Confirm space name": "Conferma nome dello spazio",
"Confirm space name": "Conferma nome spazio",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Digita il nome dello spazio <b>{{spaceName}}</b> per confermare la tua azione.",
"Format": "Formato",
"Include subpages": "Includi sottopagine",
@@ -277,9 +263,6 @@
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
"Align center": "Allinea al centro",
"Alt text": "Testo alternativo",
"Describe this for accessibility.": "Descrivi questo contenuto per l'accessibilità.",
"Add a description": "Aggiungi una descrizione",
"Justify": "Giustifica",
"Merge cells": "Unisci celle",
"Split cell": "Dividi cella",
@@ -290,21 +273,7 @@
"Add row above": "Aggiungi riga sopra",
"Add row below": "Aggiungi riga sotto",
"Delete table": "Elimina tabella",
"Add column left": "Aggiungi colonna a sinistra",
"Add column right": "Aggiungi colonna a destra",
"Clear cell": "Cancella cella",
"Clear cells": "Cancella celle",
"Toggle header cell": "Attiva/disattiva cella di intestazione",
"Toggle header column": "Attiva/disattiva colonna di intestazione",
"Toggle header row": "Attiva/disattiva riga di intestazione",
"Move column left": "Sposta colonna a sinistra",
"Move column right": "Sposta colonna a destra",
"Move row down": "Sposta riga in basso",
"Move row up": "Sposta riga in alto",
"Sort A → Z": "Ordina A → Z",
"Sort Z → A": "Ordina Z → A",
"Info": "Informazioni",
"Note": "Nota",
"Success": "Successo",
"Warning": "Avviso",
"Danger": "Pericolo",
@@ -315,11 +284,6 @@
"Save & Exit": "Salva ed esci",
"Double-click to edit Excalidraw diagram": "Fai doppio clic per modificare il diagramma di Excalidraw",
"Paste link": "Incolla link",
"Paste link or search pages": "Incolla il link o cerca le pagine",
"Link to web page": "Collega a una pagina web",
"Recents": "Recenti",
"Page or URL": "Pagina o URL",
"Link title": "Titolo del link",
"Edit link": "Modifica link",
"Remove link": "Rimuovi link",
"Add link": "Aggiungi link",
@@ -338,7 +302,7 @@
"Pink": "Rosa",
"Gray": "Grigio",
"Embed link": "Incorpora collegamento",
"Invalid {{provider}} embed link": "Link incorporato {{provider}} non valido",
"Invalid {{provider}} embed link": "Link di incorporamento {{provider}} non valido",
"Embed {{provider}}": "Incorpora {{provider}}",
"Enter {{provider}} link to embed": "Inserisci il link {{provider}} per incorporare",
"Bold": "Grassetto",
@@ -365,11 +329,8 @@
"Create block quote.": "Crea blocco citazione.",
"Insert code snippet.": "Inserisci frammento di codice.",
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Page break": "Interruzione di pagina",
"Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
@@ -377,50 +338,31 @@
"Insert a table.": "Inserisci una tabella.",
"Insert collapsible block.": "Inserisci blocco comprimibile.",
"Video": "Video",
"Divider": "Separatore",
"Quote": "Citazione",
"Divider": "Divisore",
"Quote": "Preventivo",
"Image": "Immagine",
"Audio": "Audio",
"Embed PDF": "Incorpora PDF",
"Upload and embed a PDF file.": "Carica e incorpora un file PDF.",
"Embed as PDF": "Incorpora come PDF",
"Failed to load PDF": "Caricamento del PDF non riuscito",
"Convert to attachment": "Converti in allegato",
"File attachment": "Allegato file",
"Toggle block": "Blocco a comparsa",
"Callout": "Riquadro evidenziato",
"Toggle block": "Attiva blocco",
"Callout": "Avviso",
"Insert callout notice.": "Inserisci avviso di richiamo.",
"Math inline": "Formula matematica in linea",
"Math inline": "Matematica in linea",
"Insert inline math equation.": "Inserisci equazione matematica in linea.",
"Math block": "Blocco matematico",
"Insert math equation": "Inserisci equazione matematica",
"Mermaid diagram": "Diagramma Mermaid",
"Insert mermaid diagram": "Inserisci diagramma Mermaid",
"Mermaid diagram": "Diagramma di Mermaid",
"Insert mermaid diagram": "Inserisci un diagramma di Mermaid",
"Insert and design Drawio diagrams": "Inserisci e progetta diagrammi Drawio",
"Insert current date": "Inserisci data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e abbozza diagrammi Excalidraw",
"Insert current date": "Inserisci la data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
"Multiple": "Multiplo",
"Turn into": "Trasforma in",
"Text align": "Allinea testo",
"This page may have been deleted, moved, or you may not have access.": "Questa pagina potrebbe essere stata eliminata o spostata, oppure potresti non avere accesso.",
"Go to homepage": "Vai alla pagina principale",
"Pages you create will show up here.": "Le pagine che crei appariranno qui.",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Intestazione {{level}}",
"Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualsiasi cosa. Digita \"/\" per i comandi",
"Write...": "Scrivi...",
"Column count": "Numero di colonne",
"{{count}} Columns": "{{count}} colonne",
"{{count}} command available_one": "1 comando disponibile",
"{{count}} command available_other": "{{count}} comandi disponibili",
"{{count}} result available_one": "1 risultato disponibile",
"{{count}} result available_other": "{{count}} risultati disponibili",
"Equal columns": "Colonne uguali",
"Left sidebar": "Barra laterale sinistra",
"Right sidebar": "Barra laterale destra",
"Wide center": "Centro ampio",
"Left wide": "Ampia a sinistra",
"Right wide": "Ampia a destra",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
"Names do not match": "I nomi non corrispondono",
"Today, {{time}}": "Oggi, {{time}}",
"Yesterday, {{time}}": "Ieri, {{time}}",
@@ -432,63 +374,51 @@
"Member role updated successfully": "Ruolo del membro aggiornato con successo",
"Created by: <b>{{creatorName}}</b>": "Creato da: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Creato il: {{time}}",
"Edited by {{name}} {{time}}": "Modificato da {{name}} {{time}}",
"Edited by {{name}} {{time}}": "Modificato da {{name}} il {{time}}",
"Word count: {{wordCount}}": "Conteggio parole: {{wordCount}}",
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
"New update": "Nuovo aggiornamento",
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
"Default page edit mode": "Modalità di modifica predefinita della pagina",
"Default page edit mode": "Modalità di modifica pagina predefinita",
"Choose your preferred page edit mode. Avoid accidental edits.": "Scegli la tua modalità di modifica della pagina preferita. Evita modifiche accidentali.",
"Choose {{format}} file": "Scegli file {{format}}",
"Reading": "Lettura",
"Delete member": "Elimina membro",
"Member deleted successfully": "Membro eliminato con successo",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
"Deactivate member": "Disattiva membro",
"Activate member": "Attiva membro",
"Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Sei sicuro di voler disattivare questo membro dello spazio di lavoro? Non potrà più accedere a questo spazio di lavoro.",
"Are you sure you want to activate this workspace member?": "Sei sicuro di voler attivare questo membro dello spazio di lavoro?",
"Deactivate": "Disattiva",
"Activate": "Attiva",
"Deactivated": "Disattivato",
"Move": "Sposta",
"Move page": "Sposta pagina",
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
"Table of contents": "Indice",
"Table of contents": "Indice dei contenuti",
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
"Share": "Condividi",
"Public sharing": "Condivisione pubblica",
"Shared by": "Condiviso da",
"Shared at": "Condiviso il",
"Inherits public sharing from": "Eredita la condivisione pubblica da",
"Share to web": "Condividi sul web",
"Shared to web": "Condiviso sul web",
"Share to web": "Condividi su web",
"Shared to web": "Condiviso su web",
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
"Include sub-pages": "Includi sottopagine",
"Make sub-pages public too": "Rendi pubbliche anche le sottopagine",
"Allow search engines to index page": "Consenti ai motori di ricerca di indicizzare la pagina",
"Include sub-pages": "Includi sotto-pagine",
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
"Open page": "Apri pagina",
"Page": "Pagina",
"Delete public share link": "Elimina link di condivisione pubblica",
"Delete public share link": "Elimina il link di condivisione pubblica",
"Delete share": "Elimina condivisione",
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente degli spazi di cui fai parte appariranno qui",
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
"Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata",
"Failed to share page": "Condivisione della pagina non riuscita",
"Failed to share page": "Condivisione della pagina fallita",
"Disable public sharing": "Disabilita la condivisione pubblica",
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Allow viewers to comment": "Consenti agli utenti di commentare",
"Allow viewers to add comments on pages in this space.": "Consenti agli utenti di aggiungere commenti alle pagine in questo spazio.",
"Toggle viewer comments": "Attiva/disattiva i commenti degli utenti",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.",
"Control who can view and edit individual pages. Available with an enterprise license.": "Controlla chi può visualizzare e modificare le singole pagine. Disponibile con una licenza Enterprise.",
"Requires an enterprise license": "Richiede una licenza enterprise",
"Enable public sharing": "Abilita la condivisione pubblica",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.",
@@ -503,32 +433,31 @@
"Page duplicated successfully": "Pagina duplicata con successo",
"Find": "Trova",
"Not found": "Non trovato",
"Previous Match (Shift+Enter)": "Corrispondenza precedente (Maiusc+Invio)",
"Previous Match (Shift+Enter)": "Corrispondenza precedente (Shift+Invio)",
"Next match (Enter)": "Corrispondenza successiva (Invio)",
"Match case (Alt+C)": "Distingui maiuscole/minuscole (Alt+C)",
"Match case (Alt+C)": "Maiuscole/minuscole (Alt+C)",
"Replace": "Sostituisci",
"Close (Escape)": "Chiudi (Esc)",
"Replace (Enter)": "Sostituisci (Invio)",
"Replace all (Ctrl+Alt+Enter)": "Sostituisci tutto (Ctrl+Alt+Invio)",
"Replace all": "Sostituisci tutto",
"View all": "Visualizza tutto",
"View all spaces": "Visualizza tutti gli spazi",
"Error": "Errore",
"Failed to disable MFA": "Disattivazione MFA non riuscita",
"Disable two-factor authentication": "Disattiva autenticazione a due fattori",
"Failed to disable MFA": "Disabilitazione MFA non riuscita",
"Disable two-factor authentication": "Disabilita autenticazione a due fattori",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabilitare l'autenticazione a due fattori renderà il tuo account meno sicuro. Avrai bisogno solo della tua password per accedere.",
"Please enter your password to disable two-factor authentication:": "Inserisci la tua password per disabilitare l'autenticazione a due fattori:",
"Two-factor authentication has been enabled": "L'autenticazione a due fattori è stata abilitata",
"Two-factor authentication has been disabled": "L'autenticazione a due fattori è stata disabilitata",
"Two-factor authentication has been enabled": "Autenticazione a due fattori abilitata",
"Two-factor authentication has been disabled": "Autenticazione a due fattori disabilitata",
"2-step verification": "Verifica in 2 passaggi",
"Protect your account with an additional verification layer when signing in.": "Proteggi il tuo account con un ulteriore livello di verifica durante l'accesso.",
"Two-factor authentication is active on your account.": "L'autenticazione a due fattori è attiva sul tuo account.",
"Add 2FA method": "Aggiungi metodo 2FA",
"Backup codes": "Codici di backup",
"Disable": "Disattiva",
"Disable": "Disabilita",
"Invalid verification code": "Codice di verifica non valido",
"New backup codes have been generated": "Sono stati generati nuovi codici di backup",
"Failed to regenerate backup codes": "Rigenerazione dei codici di backup non riuscita",
"New backup codes have been generated": "Nuovi codici di backup generati",
"Failed to regenerate backup codes": "Rigenerazione codici di backup non riuscita",
"About backup codes": "Informazioni sui codici di backup",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "I codici di backup possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puoi rigenerare nuovi codici di backup in qualsiasi momento. Questo invaliderà tutti i codici esistenti.",
@@ -538,9 +467,9 @@
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assicurati di salvare questi codici in un luogo sicuro. I tuoi vecchi codici di backup non sono più validi.",
"Your new backup codes": "I tuoi nuovi codici di backup",
"I've saved my backup codes": "Ho salvato i miei codici di backup",
"Failed to setup MFA": "Configurazione MFA non riuscita",
"Setup & Verify": "Configura e verifica",
"Add to authenticator": "Aggiungi all'autenticatore",
"Failed to setup MFA": "Impostazione MFA non riuscita",
"Setup & Verify": "Imposta e Verifica",
"Add to authenticator": "Aggiungi ad authenticator",
"1. Scan this QR code with your authenticator app": "1. Scansiona questo codice QR con la tua app di autenticazione",
"Can't scan the code?": "Non riesci a scansionare il codice?",
"Enter this code manually in your authenticator app:": "Inserisci questo codice manualmente nella tua app di autenticazione:",
@@ -554,17 +483,17 @@
"Print": "Stampa",
"Two-factor authentication has been set up. Please log in again.": "L'autenticazione a due fattori è stata impostata. Effettua nuovamente l'accesso, per favore.",
"Two-Factor authentication required": "Autenticazione a due fattori richiesta",
"Your workspace requires two-factor authentication for all users": "Il tuo workspace richiede l'autenticazione a due fattori per tutti gli utenti",
"Your workspace requires two-factor authentication for all users": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori per tutti gli utenti",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Per continuare ad accedere al tuo spazio di lavoro, devi impostare l'autenticazione a due fattori. Questo aggiunge un ulteriore livello di sicurezza al tuo account.",
"Set up two-factor authentication": "Configura l'autenticazione a due fattori",
"Cancel and logout": "Annulla e disconnettiti",
"Set up two-factor authentication": "Imposta l'autenticazione a due fattori",
"Cancel and logout": "Annulla e disconnetti",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori. Impostala per continuare.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Questo aggiunge un ulteriore livello di sicurezza al tuo account richiedendo un codice di verifica dalla tua app di autenticazione.",
"Password is required": "La password è obbligatoria",
"Password must be at least 8 characters": "La password deve contenere almeno 8 caratteri",
"Please enter a 6-digit code": "Inserisci un codice di 6 cifre",
"Password is required": "La password è richiesta",
"Password must be at least 8 characters": "La password deve essere di almeno 8 caratteri",
"Please enter a 6-digit code": "Inserisci un codice a 6 cifre",
"Code must be exactly 6 digits": "Il codice deve essere esattamente di 6 cifre",
"Enter the 6-digit code found in your authenticator app": "Inserisci il codice di 6 cifre presente nella tua app di autenticazione",
"Enter the 6-digit code found in your authenticator app": "Inserisci il codice a 6 cifre trovato nella tua app di autenticazione",
"Need help authenticating?": "Hai bisogno di aiuto per autenticarti?",
"MFA QR Code": "Codice QR MFA",
"Account created successfully. Please log in to set up two-factor authentication.": "Account creato con successo. Effettua l'accesso per impostare l'autenticazione a due fattori.",
@@ -572,7 +501,7 @@
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password per impostare l'autenticazione a due fattori.",
"Password reset was successful. Please log in with your new password.": "Reimpostazione della password riuscita. Accedi con la tua nuova password.",
"Two-factor authentication": "Autenticazione a due fattori",
"Use authenticator app instead": "Usa invece l'app di autenticazione",
"Use authenticator app instead": "Usa l'app di autenticazione invece",
"Verify backup code": "Verifica codice di backup",
"Use backup code": "Usa codice di backup",
"Enter one of your backup codes": "Inserisci uno dei tuoi codici di backup",
@@ -580,7 +509,7 @@
"Enter one of your backup codes. Each backup code can only be used once.": "Inserisci uno dei tuoi codici di backup. Ogni codice di backup può essere utilizzato solo una volta.",
"Verify": "Verifica",
"Trash": "Cestino",
"Pages in trash will be permanently deleted after {{count}} days.": "Le pagine nel cestino verranno eliminate definitivamente dopo {{count}} giorni.",
"Pages in trash will be permanently deleted after 30 days.": "Le pagine nel cestino verranno eliminate definitivamente dopo 30 giorni.",
"Deleted": "Eliminato",
"No pages in trash": "Nessuna pagina nel cestino",
"Permanently delete page?": "Eliminare definitivamente la pagina?",
@@ -589,8 +518,6 @@
"Move to trash": "Sposta nel cestino",
"Move this page to trash?": "Spostare questa pagina nel cestino?",
"Restore page": "Ripristina pagina",
"Permanently delete": "Elimina definitivamente",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> ha spostato questa pagina nel Cestino {{time}}.",
"Page moved to trash": "Pagina spostata nel cestino",
"Page restored successfully": "Pagina ripristinata con successo",
"Deleted by": "Eliminato da",
@@ -607,20 +534,20 @@
"Find a space": "Trova uno spazio",
"Search in all your spaces": "Cerca in tutti i tuoi spazi",
"Type": "Tipo",
"Enterprise": "Enterprise",
"Enterprise": "Impresa",
"Download attachment": "Scarica allegato",
"Allowed email domains": "Domini email consentiti",
"Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email di questi domini possono registrarsi tramite SSO.",
"Enter valid domain names separated by comma or space": "Inserisci nomi di dominio validi separati da virgola o spazio",
"Enforce two-factor authentication": "Rendi obbligatoria l'autenticazione a due fattori",
"Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email provenienti da questi domini possono registrarsi tramite SSO.",
"Enter valid domain names separated by comma or space": "Inserisci nomi di dominio validi separati da virgole o spazi",
"Enforce two-factor authentication": "Imponi l'autenticazione a due fattori",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Una volta impostata, tutti i membri devono abilitare l'autenticazione a due fattori per accedere all'area di lavoro.",
"Toggle MFA enforcement": "Attiva/disattiva obbligatorietà MFA",
"Toggle MFA enforcement": "Attiva disattiva l'applicazione MFA",
"Display name": "Nome visualizzato",
"Allow signup": "Consenti registrazione",
"Allow signup": "Consenti iscrizione",
"Enabled": "Abilitato",
"Advanced Settings": "Impostazioni avanzate",
"Enable TLS/SSL": "Abilita TLS/SSL",
"Use secure connection to LDAP server": "Usa una connessione sicura al server LDAP",
"Use secure connection to LDAP server": "Usa connessione sicura al server LDAP",
"Group sync": "Sincronizzazione gruppi",
"No SSO providers found.": "Nessun provider SSO trovato.",
"Delete SSO provider": "Elimina provider SSO",
@@ -634,32 +561,31 @@
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update": "Aggiorna",
"Update {{credential}}": "Aggiorna {{credential}}",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"Restrict API key creation to admins": "Limita la creazione delle chiavi API agli amministratori",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Solo gli amministratori e i proprietari possono creare nuove chiavi API. Le chiavi dei membri esistenti continueranno a funzionare.",
"Toggle restrict API keys to admins": "Attiva/disattiva la limitazione delle chiavi API agli amministratori",
"API key creation is restricted to admins by your workspace administrator.": "La creazione delle chiavi API è limitata agli amministratori dal tuo amministratore dello spazio di lavoro.",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Thinking": "Sto pensando",
"Ask a question...": "Fai una domanda...",
"AI Answers": "Risposte AI",
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
@@ -668,25 +594,6 @@
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
"Model Context Protocol (MCP)": "Model Context Protocol (MCP)",
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Abilita il server MCP per consentire ad assistenti e strumenti IA di interagire con i contenuti del tuo spazio di lavoro.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"MCP Server URL": "URL del server MCP",
"Use your API key for authentication. You can manage API keys in your account settings.": "Usa la tua chiave API per l'autenticazione. Puoi gestire le chiavi API nelle impostazioni del tuo account.",
"Supported tools": "Strumenti supportati",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Il tuo spazio di lavoro ha MCP abilitato. Usa la tua chiave API per collegare gli assistenti IA.",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gestisci le API key per tutti gli utenti nello spazio di lavoro. Consulta la <anchor>documentazione API</anchor> per i dettagli sull'utilizzo.",
"View the <anchor>API documentation</anchor> for usage details.": "Consulta la <anchor>documentazione API</anchor> per i dettagli sull'utilizzo.",
"View the <anchor>MCP documentation</anchor>.": "Consulta la <anchor>documentazione MCP</anchor>.",
"Sources": "Fonti",
"AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
"No answer available": "Nessuna risposta disponibile",
@@ -701,406 +608,12 @@
"Mark all as read": "Segna tutto come letto",
"Mark as read": "Segna come letto",
"More options": "Altre opzioni",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> ti ha menzionato in un commento",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> ha commentato una pagina",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> ha risolto un commento",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> ti ha menzionato in una pagina",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> ti ha dato accesso in modifica a una pagina",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> ti ha dato accesso in visualizzazione a una pagina",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> ha aggiornato una pagina",
"Watch page": "Segui pagina",
"Stop watching": "Smetti di seguire",
"Watch space": "Segui spazio",
"Stop watching space": "Smetti di seguire lo spazio",
"Email notifications": "Notifiche email",
"Page updates": "Aggiornamenti pagina",
"Get notified when pages you watch are updated.": "Ricevi una notifica quando le pagine che segui vengono aggiornate.",
"Page mentions": "Menzioni nella pagina",
"Get notified when someone mentions you on a page.": "Ricevi una notifica quando qualcuno ti menziona su una pagina.",
"Comment mentions": "Menzioni nei commenti",
"Get notified when someone mentions you in a comment.": "Ricevi una notifica quando qualcuno ti menziona in un commento.",
"New comments": "Nuovi commenti",
"Get notified about new comments on threads you participate in.": "Ricevi una notifica sui nuovi commenti nelle discussioni a cui partecipi.",
"Resolved comments": "Commenti risolti",
"Get notified when your comment is resolved.": "Ricevi una notifica quando il tuo commento viene risolto.",
"You are now watching this page": "Ora stai seguendo questa pagina",
"You are no longer watching this page": "Non stai più seguendo questa pagina",
"You are now watching this space": "Ora stai seguendo questo spazio",
"You are no longer watching this space": "Non stai più seguendo questo spazio",
"Direct": "Diretto",
"Updates": "Aggiornamenti",
"mentioned you in a comment": "ti ha menzionato in un commento",
"commented on a page": "ha commentato una pagina",
"resolved a comment": "ha risolto un commento",
"mentioned you on a page": "ti ha menzionato in una pagina",
"Today": "Oggi",
"Yesterday": "Ieri",
"This week": "Questa settimana",
"Older": "Più vecchie",
"Restricted page": "Pagina con accesso ristretto",
"Restricted pages cannot be shared publicly.": "Le pagine con accesso ristretto non possono essere condivise pubblicamente.",
"Restricted by parent": "Limitata dalla pagina genitore",
"Restricted": "Limitata",
"Open": "Aperta",
"Inherits restrictions from ancestor page": "Eredita le restrizioni dalla pagina genitore",
"Only people listed below can access this page": "Solo le persone elencate di seguito possono accedere a questa pagina",
"Everyone in this space can access": "Chiunque in questo spazio può accedere",
"No additional restrictions on this page": "Nessuna restrizione aggiuntiva su questa pagina",
"Only specific people can access": "Solo persone specifiche possono accedere",
"Use only inherited restrictions": "Usa solo le restrizioni ereditate",
"Add restrictions on top of inherited": "Aggiungi restrizioni oltre a quelle ereditate",
"Inherited restriction": "Restrizione ereditata",
"Access limited by": "Accesso limitato da",
"Restrict access to control who can view and edit this page": "Limita l'accesso per controllare chi può visualizzare e modificare questa pagina",
"Add additional restrictions specific to this page": "Aggiungi restrizioni aggiuntive specifiche per questa pagina",
"Access": "Accesso",
"People with access": "Persone con accesso",
"Remove all": "Rimuovi tutto",
"Remove access": "Rimuovi accesso",
"Remove all access": "Rimuovi tutti gli accessi",
"Are you sure you want to remove this member's access to the page?": "Sei sicuro di voler rimuovere l'accesso di questo membro alla pagina?",
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Sei sicuro di voler rimuovere tutti gli accessi specifici? Questo renderà la pagina accessibile a tutti nello spazio.",
"Trash retention": "Conservazione del cestino",
"Pages in trash will be permanently deleted after this period.": "Le pagine nel cestino verranno eliminate definitivamente dopo questo periodo.",
"Trash retention updated": "Conservazione del cestino aggiornata",
"Failed to update trash retention": "Impossibile aggiornare la conservazione del cestino",
"Removed page restriction": "Restrizione della pagina rimossa",
"Added page permission": "Permesso sulla pagina aggiunto",
"Removed page permission": "Permesso sulla pagina rimosso",
"day": "giorno",
"days": "giorni",
"week": "settimana",
"weeks": "settimane",
"month": "mese",
"months": "mesi",
"year": "anno",
"years": "anni",
"Period": "Periodo",
"Fixed date": "Data fissa",
"Indefinitely": "A tempo indeterminato",
"Days": "Giorni",
"Weeks": "Settimane",
"Months": "Mesi",
"Years": "Anni",
"Pick a date": "Scegli una data",
"Maximum is {{max}} {{unit}} for this unit": "Il massimo consentito è {{max}} {{unit}} per questa unità",
"Never expires. Verifiers can re-verify at any time.": "Non scade mai. I verificatori possono verificare nuovamente in qualsiasi momento.",
"Verified": "Verificato",
"Review needed": "Revisione necessaria",
"Verification expired": "Verifica scaduta",
"Draft": "Bozza",
"In Approval": "In approvazione",
"In approval": "In approvazione",
"Approved": "Approvato",
"Obsolete": "Obsoleto",
"Expiring": "In scadenza",
"Set up verification": "Configura la verifica",
"Verify page": "Verifica la pagina",
"Page verification": "Verifica della pagina",
"Add verification": "Aggiungi verifica",
"Edit verification": "Modifica verifica",
"Search by title": "Cerca per titolo",
"Choose how this page should stay accurate.": "Scegli come mantenere accurata questa pagina.",
"Recurring verification": "Verifica ricorrente",
"Verifiers re-confirm this page on a schedule.": "I verificatori riconfermano questa pagina secondo una pianificazione.",
"Re-verify on a schedule (e.g every 30 days )": "Verifica nuovamente secondo una pianificazione (ad es. ogni 30 giorni)",
"Page stays editable at all times": "La pagina resta sempre modificabile",
"Best for runbooks, FAQs, living documentation": "Ideale per runbook, FAQ e documentazione dinamica",
"Approval workflow": "Flusso di approvazione",
"Formal document lifecycle with named approvers.": "Ciclo di vita formale del documento con approvatori nominati.",
"Draft → In approval → Approved → Obsolete": "Bozza → In approvazione → Approvato → Obsoleto",
"Locked once approved, with full history": "Bloccato una volta approvato, con cronologia completa",
"Designed for ISO 9001, ISO 13485, and FDA": "Progettato per ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideale per SOP e documenti controllati",
"Back": "Indietro",
"Quality management": "Gestione della qualità",
"Recurring": "Ricorrente",
"Pages move through draft, approval, and approved stages.": "Le pagine passano attraverso le fasi di bozza, approvazione e approvato.",
"Verifiers": "Verificatori",
"Add verifier": "Aggiungi verificatore",
"I've reviewed this page for accuracy": "Ho controllato l'accuratezza di questa pagina",
"Set up": "Configura",
"Remove verification": "Rimuovi verifica",
"Are you sure you want to remove verification from this page?": "Sei sicuro di voler rimuovere la verifica da questa pagina?",
"Assigned verifiers must periodically re-verify this page.": "I verificatori assegnati devono verificare nuovamente questa pagina periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Ultima verifica effettuata da {{name}} {{time}} (scaduta)",
"The fixed expiration date has passed.": "La data di scadenza fissa è trascorsa.",
"Verified by {{name}} {{time}}": "Verificato da {{name}} {{time}}",
"Expires {{date}}": "Scade il {{date}}",
"Expired {{date}}": "Scaduto il {{date}}",
"Mark as obsolete": "Contrassegna come obsoleto",
"Mark obsolete": "Contrassegna come obsoleto",
"Returned by {{name}} {{time}}": "Restituito da {{name}} {{time}}",
"No approval has been requested yet.": "Non è stata ancora richiesta alcuna approvazione.",
"Submitted by {{name}} {{time}}": "Inviato da {{name}} {{time}}",
"Someone": "Qualcuno",
"Approved by {{name}} {{time}}": "Approvato da {{name}} {{time}}",
"This document has been marked as obsolete.": "Questo documento è stato contrassegnato come obsoleto.",
"Rejection comment": "Commento di rifiuto",
"Reason for returning this document...": "Motivo della restituzione di questo documento...",
"Confirm rejection": "Conferma rifiuto",
"Submit for approval": "Invia per approvazione",
"Reject": "Rifiuta",
"Approve": "Approva",
"Re-submit for approval": "Invia nuovamente per approvazione",
"Verified until": "Verificato fino al",
"QMS": "QMS",
"Verified pages": "Pagine verificate",
"Search pages...": "Cerca pagine...",
"Filter by space": "Filtra per spazio",
"Filter by type": "Filtra per tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> ha verificato una pagina",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> ha inviato una pagina per la tua approvazione",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> ha restituito una pagina per la revisione",
"Page verification expires soon": "La verifica della pagina scadrà presto",
"Page verification has expired": "La verifica della pagina è scaduta",
"Verifying your email": "Verifica della tua email in corso",
"Please wait...": "Attendere...",
"Verification failed. The link may have expired.": "Verifica non riuscita. Il link potrebbe essere scaduto.",
"Check your email": "Controlla la tua email",
"We sent a verification link to {{email}}.": "Abbiamo inviato un link di verifica a {{email}}.",
"We sent a verification link to your email.": "Abbiamo inviato un link di verifica alla tua email.",
"Click the link to verify your email and access your workspace.": "Clicca sul link per verificare la tua email e accedere al tuo workspace.",
"Resend verification email": "Invia nuovamente email di verifica",
"Verification email sent. Please check your inbox.": "Email di verifica inviata. Controlla la tua casella di posta.",
"Failed to resend verification email. Please try again.": "Invio dell'email di verifica non riuscito. Si prega di riprovare.",
"We've sent you an email with your associated workspaces.": "Ti abbiamo inviato un'email con i workspace associati.",
"Load more": "Carica altro",
"Log out of all devices": "Disconnetti da tutti i dispositivi",
"Log out of all sessions except this device": "Disconnetti da tutte le sessioni tranne questo dispositivo",
"This Device": "Questo dispositivo",
"Unknown device": "Dispositivo sconosciuto",
"No active sessions": "Nessuna sessione attiva",
"Session revoked": "Sessione revocata",
"All other sessions revoked": "Tutte le altre sessioni sono state revocate",
"Last used": "Ultimo utilizzo",
"Created": "Creato",
"Rename": "Rinomina",
"Publish": "Pubblica",
"Security": "Sicurezza",
"Enforce SSO": "Rendi obbligatorio SSO",
"Once enforced, members will not be able to login with email and password.": "Una volta reso obbligatorio, i membri non potranno accedere con email e password.",
"AI-generated content may not be accurate.": "I contenuti generati dall'IA potrebbero non essere accurati.",
"AI Chat": "Chat IA",
"Analyze for insights": "Analizza per ottenere approfondimenti",
"Ask anything...": "Chiedi qualsiasi cosa...",
"Assistant said:": "L'assistente ha detto:",
"Chat history": "Cronologia chat",
"Chat name": "Nome chat",
"Chat transcript": "Trascrizione della chat",
"Close": "Chiudi",
"Copy assistant response": "Copia risposta dell'assistente",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Caricamento della chat non riuscito. Si è verificato un errore.",
"Failed to render this message.": "Impossibile visualizzare questo messaggio.",
"How can I help you today?": "Come posso aiutarti oggi?",
"New chat": "Nuova chat",
"No chat history": "Nessuna cronologia chat",
"No chats found": "Nessuna chat trovata",
"No conversations yet": "Nessuna conversazione al momento",
"Open full page": "Apri pagina completa",
"Scroll to bottom": "Scorri in basso",
"You said:": "Hai detto:",
"Previous 7 days": "Ultimi 7 giorni",
"Previous 30 days": "Ultimi 30 giorni",
"Search chats...": "Cerca nelle chat...",
"Search chats": "Cerca nelle chat",
"Ask anything... Use @ to mention pages": "Chiedi qualsiasi cosa... Usa @ per menzionare le pagine",
"Ask anything or search your workspace": "Chiedi qualsiasi cosa o cerca nel tuo spazio di lavoro",
"Welcome to {{name}}": "Benvenuto in {{name}}",
"Add files": "Aggiungi file",
"Mention a page": "Menziona una pagina",
"Start a new chat to see it here.": "Avvia una nuova chat per vederla qui.",
"Summarize this page": "Riassumi questa pagina",
"Toggle AI Chat": "Attiva/disattiva Chat IA",
"Translate this page": "Traduci questa pagina",
"Try a different search term.": "Prova un termine di ricerca diverso.",
"Try again": "Riprova",
"Untitled chat": "Chat senza titolo",
"What can I help you with?": "Con cosa posso aiutarti?",
"Are you sure you want to revoke this {{credential}}": "Sei sicuro di voler revocare questa {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Esegui automaticamente il provisioning di utenti e gruppi dal tuo provider di identità tramite SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configura il tuo provider di identità con questo URL per eseguire il provisioning di utenti e gruppi.",
"Create {{credential}}": "Crea {{credential}}",
"{{credential}} created": "{{credential}} creata",
"{{credential}} created successfully": "{{credential}} creata con successo",
"Created by": "Creata da",
"Custom": "Personalizzato",
"Enable SCIM": "Abilita SCIM",
"Enter a descriptive name": "Inserisci un nome descrittivo",
"I've saved my {{credential}}": "Ho salvato la mia {{credential}}",
"Important": "Importante",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Assicurati di copiare subito la tua {{credential}}. Non potrai più visualizzarla!",
"Never": "Mai",
"Revoke {{credential}}": "Revoca {{credential}}",
"SCIM endpoint URL": "URL dell'endpoint SCIM",
"SCIM provisioning": "Provisioning SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM ha la precedenza sulla sincronizzazione dei gruppi SSO quando è abilitato.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Hai raggiunto il numero massimo di {{max}} token SCIM. Elimina un token esistente per crearne uno nuovo.",
"SCIM token": "Token SCIM",
"SCIM tokens": "Token SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Questa azione non può essere annullata. Il tuo provider di identità smetterà di sincronizzarsi immediatamente.",
"Toggle SCIM provisioning": "Attiva/disattiva il provisioning SCIM",
"Token": "Token",
"Page menu": "Menu della pagina",
"Expand": "Espandi",
"Collapse": "Comprimi",
"Comment menu": "Menu dei commenti",
"Group menu": "Menu del gruppo",
"Show hidden breadcrumbs": "Mostra breadcrumb nascosti",
"Breadcrumbs": "Breadcrumb",
"Page actions": "Azioni della pagina",
"Pick emoji": "Scegli emoji",
"Template menu": "Menu del modello",
"Use": "Usa",
"Use template": "Usa modello",
"Preview template: {{title}}": "Anteprima modello: {{title}}",
"Use a template": "Usa un modello",
"Search templates...": "Cerca modelli...",
"Search spaces...": "Cerca spazi...",
"No templates found": "Nessun modello trovato",
"No spaces found": "Nessuno spazio trovato",
"Browse all templates": "Sfoglia tutti i modelli",
"This space": "Questo spazio",
"All templates": "Tutti i modelli",
"Global": "Globale",
"New template": "Nuovo modello",
"Edit template": "Modifica modello",
"Are you sure you want to delete this template?": "Sei sicuro di voler eliminare questo modello?",
"Template scope updated": "Ambito del modello aggiornato",
"Choose which space this template belongs to": "Scegli a quale spazio appartiene questo modello",
"Scope": "Ambito",
"Select scope": "Seleziona ambito",
"Title": "Titolo",
"Saving...": "Salvataggio...",
"Saved": "Salvato",
"Save failed. Retry": "Salvataggio non riuscito. Riprova",
"By {{name}}": "Di {{name}}",
"Updated {{time}}": "Aggiornato {{time}}",
"Choose destination": "Scegli destinazione",
"Search pages and spaces...": "Cerca pagine e spazi...",
"No results found": "Nessun risultato trovato",
"You don't have permission to create pages here": "Non hai l'autorizzazione per creare pagine qui",
"Chat menu": "Menu della chat",
"API key menu": "Menu della chiave API",
"Jump to comment selection": "Vai alla selezione dei commenti",
"Slash commands": "Comandi slash",
"Mention suggestions": "Suggerimenti di menzione",
"Link suggestions": "Suggerimenti di link",
"Diagram editor": "Editor di diagrammi",
"Add comment": "Aggiungi commento",
"Find and replace": "Trova e sostituisci",
"Main navigation": "Navigazione principale",
"Space navigation": "Navigazione dello spazio",
"Settings navigation": "Navigazione delle impostazioni",
"AI navigation": "Navigazione AI",
"Breadcrumb": "Percorso di navigazione",
"Synced block": "Blocco sincronizzato",
"Create a block that stays in sync across pages.": "Crea un blocco che rimanga sincronizzato tra le pagine.",
"Editing original": "Modifica originale",
"Copy synced block": "Copia blocco sincronizzato",
"Unsync": "Annulla sincronizzazione",
"Delete synced block": "Elimina blocco sincronizzato",
"Synced to {{count}} other page_one": "Sincronizzato con {{count}} altra pagina",
"Synced to {{count}} other page_other": "Sincronizzato con {{count}} altre pagine",
"ORIGINAL": "ORIGINALE",
"THIS PAGE": "QUESTA PAGINA",
"No pages": "Nessuna pagina",
"The original synced block no longer exists": "Il blocco sincronizzato originale non esiste più",
"You don't have access to this synced block": "Non hai accesso a questo blocco sincronizzato",
"Failed to load this synced block": "Impossibile caricare questo blocco sincronizzato",
"Fixed editor toolbar": "Barra degli strumenti dell'editor fissa",
"Show a formatting toolbar above the editor with quick access to common actions.": "Mostra una barra degli strumenti di formattazione sopra l'editor con accesso rapido alle azioni comuni.",
"Toggle fixed editor toolbar": "Attiva/disattiva barra degli strumenti dell'editor fissa",
"Normal text": "Testo normale",
"More inline formatting": "Altra formattazione in linea",
"Subscript": "Pedice",
"Superscript": "Apice",
"Inline code": "Codice in linea",
"Insert media": "Inserisci contenuti multimediali",
"Mention": "Menzione",
"Emoji": "Emoji",
"Columns": "Colonne",
"More inserts": "Altri inserimenti",
"Embeds": "Incorporamenti",
"Diagrams": "Diagrammi",
"Advanced": "Avanzate",
"Utility": "Utilità",
"Decrease indent": "Riduci rientro",
"Increase indent": "Aumenta rientro",
"Clear formatting": "Cancella formattazione",
"Code block": "Blocco di codice",
"Experimental": "Sperimentale",
"Strikethrough": "Barrato",
"Undo": "Annulla",
"Redo": "Ripeti",
"Backlinks": "Backlink",
"Last updated by": "Ultimo aggiornamento di",
"Last updated": "Ultimo aggiornamento",
"Stats": "Statistiche",
"Word count": "Conteggio parole",
"Characters": "Caratteri",
"Incoming links": "Link in entrata",
"Outgoing links": "Link in uscita",
"Incoming links ({{count}})": "Link in entrata ({{count}})",
"Outgoing links ({{count}})": "Link in uscita ({{count}})",
"No pages link here yet.": "Nessuna pagina rimanda ancora qui.",
"This page doesn't link to other pages yet.": "Questa pagina non rimanda ancora ad altre pagine.",
"Verified until {{date}}": "Verificato fino al {{date}}",
"Labels": "Etichette",
"Add label": "Aggiungi etichetta",
"No labels yet": "Nessuna etichetta per ora",
"Already added": "Già aggiunto",
"Invalid label name": "Nome etichetta non valido",
"No matches": "Nessuna corrispondenza",
"Search or create…": "Cerca o crea…",
"Remove label {{name}}": "Rimuovi etichetta {{name}}",
"Failed to add label": "Impossibile aggiungere l'etichetta",
"Failed to remove label": "Impossibile rimuovere l'etichetta",
"No pages with this label": "Nessuna pagina con questa etichetta",
"Pages tagged with this label will appear here.": "Le pagine contrassegnate con questa etichetta appariranno qui.",
"No pages match your search.": "Nessuna pagina corrisponde alla tua ricerca.",
"Updated {{date}}": "Aggiornato il {{date}}",
"Cell actions": "Azioni cella",
"Column actions": "Azioni colonna",
"Row actions": "Azioni riga",
"Filter": "Filtro",
"Page title": "Titolo pagina",
"Page content": "Contenuto della pagina",
"Member actions": "Azioni membro",
"Toggle password visibility": "Attiva/disattiva visibilità password",
"Send comment": "Invia commento",
"Token actions": "Azioni token",
"Template settings": "Impostazioni modello",
"Edit diagram": "Modifica diagramma",
"Edit embed": "Modifica incorporamento",
"Edit drawing": "Modifica disegno",
"Delete equation": "Elimina equazione",
"Invite actions": "Azioni invito",
"Get started": "Inizia",
"* indicates required fields": "* indica i campi obbligatori",
"List of spaces in this workspace": "Elenco degli spazi in questo spazio di lavoro",
"Active sessions": "Sessioni attive",
"Add {{name}} to favorites": "Aggiungi {{name}} ai preferiti",
"Remove {{name}} from favorites": "Rimuovi {{name}} dai preferiti",
"Added to favorites": "Aggiunto ai preferiti",
"Removed from favorites": "Rimosso dai preferiti",
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
"Page menu for {{name}}": "Menu della pagina per {{name}}",
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Older": "Più vecchie"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+94 -581
View File
@@ -7,7 +7,6 @@
"Add members": "Adicionar membros",
"Add to groups": "Adicionar aos grupos",
"Add space members": "Adicionar membros do espaço",
"Add to favorites": "Adicionar aos favoritos",
"Admin": "Administrador",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Tem certeza de que deseja excluir este grupo? Os membros perderão acesso aos recursos que este grupo possui.",
"Are you sure you want to delete this page?": "Tem certeza de que deseja excluir esta página?",
@@ -60,7 +59,7 @@
"Email": "Email",
"Enter a strong password": "Insira uma senha forte",
"Enter valid email addresses separated by comma or space max_50": "Insira endereços de email válidos separados por vírgula ou espaço [máx: 50]",
"enter valid emails addresses": "insira endereços de e-mail válidos",
"enter valid emails addresses": "insira endereços de email válidos",
"Enter your current password": "Insira sua senha atual",
"enter your full name": "insira seu nome completo",
"Enter your new password": "Insira sua nova senha",
@@ -71,14 +70,10 @@
"Export": "Exportar",
"Failed to create page": "Falha ao criar página",
"Failed to delete page": "Falha ao excluir página",
"Failed to restore page": "Falha ao restaurar página",
"Failed to fetch recent pages": "Falha ao buscar páginas recentes",
"Failed to import pages": "Falha ao importar páginas",
"Failed to load page. An error occurred.": "Falha ao carregar página. Ocorreu um erro.",
"Failed to update data": "Falha ao atualizar dados",
"Favorite spaces": "Espaços favoritos",
"Favorite spaces appear here": "Os espaços favoritos aparecem aqui",
"Favorites": "Favoritos",
"Full access": "Acesso total",
"Full page width": "Usar largura total da página",
"Full width": "Largura total",
@@ -97,7 +92,6 @@
"Invite by email": "Convidar por email",
"Invite members": "Convidar membros",
"Invite new members": "Convidar novos membros",
"Invite People": "Convidar pessoas",
"Invited members who are yet to accept their invitation will appear here.": "Membros convidados que ainda não aceitaram o convite aparecerão aqui.",
"Invited members will be granted access to spaces the groups can access": "Os membros convidados terão acesso aos espaços que os grupos podem acessar",
"Join the workspace": "Entrar no workspace",
@@ -122,7 +116,6 @@
"No group found": "Nenhum grupo encontrado",
"No page history saved yet.": "Nenhum histórico de página salvo ainda.",
"No pages yet": "Nenhuma página ainda",
"No shared pages": "Sem páginas compartilhadas",
"No results found...": "Nenhum resultado encontrado...",
"No user found": "Nenhum usuário encontrado",
"Overview": "Visão geral",
@@ -137,7 +130,6 @@
"pages": "páginas",
"Password": "Senha",
"Password changed successfully": "Senha alterada com sucesso",
"People": "Pessoas",
"Pending": "Pendente",
"Please confirm your action": "Por favor, confirme sua ação",
"Preferences": "Preferências",
@@ -145,7 +137,6 @@
"Profile": "Perfil",
"Recently updated": "Atualizado recentemente",
"Remove": "Remover",
"Remove from favorites": "Remover dos favoritos",
"Remove group member": "Remover membro do grupo",
"Remove space member": "Remover membro do espaço",
"Restore": "Restaurar",
@@ -156,16 +147,16 @@
"Search for users": "Buscar usuários",
"Search for users and groups": "Buscar usuários e grupos",
"Search...": "Buscar...",
"Select language": "Selecione o idioma",
"Select role": "Selecione a função",
"Select role to assign to all invited members": "Selecione a função a ser atribuída a todos os membros convidados",
"Select theme": "Selecione o tema",
"Select language": "Selecionar idioma",
"Select role": "Selecionar função",
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
"Select theme": "Selecionar tema",
"Send invitation": "Enviar convite",
"Invitation sent": "Convite enviado",
"Settings": "Configurações",
"Setup workspace": "Configurar workspace",
"Sign In": "Entrar",
"Sign Up": "Cadastrar-se",
"Sign Up": "Registrar-se",
"Slug": "Slug",
"Space": "Espaço",
"Space description": "Descrição do espaço",
@@ -178,35 +169,34 @@
"No space found": "Nenhum espaço encontrado",
"Search for spaces": "Pesquisar espaços",
"Start typing to search...": "Comece a digitar para buscar...",
"Status": "Status",
"Status": "Estado",
"Successfully imported": "Importado com sucesso",
"Successfully restored": "Restaurado com sucesso",
"System settings": "Configurações do sistema",
"Templates": "Modelos",
"Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Para alterar seu email, você precisa inserir sua senha e o novo email.",
"Toggle full page width": "Alternar largura total da página",
"Toggle full page width": "Alternar para largura total da página",
"Unable to import pages. Please try again.": "Não foi possível importar as páginas. Por favor, tente novamente.",
"untitled": "sem título",
"Untitled": "Sem título",
"Updated successfully": "Atualizado com sucesso",
"User": "Usuário",
"Workspace": "Workspace",
"Workspace Name": "Nome do workspace",
"Workspace": "Espaço de Trabalho",
"Workspace Name": "Nome do Workspace",
"Workspace settings": "Configurações do workspace",
"You can change your password here.": "Você pode alterar sua senha aqui.",
"Your Email": "Seu e-mail",
"Your Email": "Seu email",
"Your import is complete.": "Sua importação está concluída.",
"Your name": "Seu nome",
"Your Name": "Seu Nome",
"Your password": "Sua senha",
"Your password must be a minimum of 8 characters.": "Sua senha deve ter no mínimo 8 caracteres.",
"Sidebar toggle": "Alternar barra lateral",
"Sidebar toggle": "Interruptor do painel lateral",
"Comments": "Comentários",
"404 page not found": "404 página não encontrada",
"404 page not found": "Erro 404: Página não encontrada",
"Sorry, we can't find the page you are looking for.": "Desculpe, não conseguimos encontrar a página que você está procurando.",
"Take me back to homepage": "Voltar para a página inicial",
"Forgot password": "Esqueceu a senha",
"Take me back to homepage": "Leve-me de volta para a página inicial",
"Forgot password": "Esqueci a senha",
"Forgot your password?": "Esqueceu sua senha?",
"A password reset link has been sent to your email. Please check your inbox.": "Um link de redefinição de senha foi enviado para o seu email. Por favor, verifique sua caixa de entrada.",
"Send reset link": "Enviar link de recuperação",
@@ -217,14 +207,9 @@
"Reply...": "Responder...",
"Error loading comments.": "Erro ao carregar comentários.",
"No comments yet.": "Ainda sem comentários.",
"No open comments.": "Sem comentários em aberto.",
"No resolved comments.": "Sem comentários resolvidos.",
"Add a comment...": "Adicione um comentário...",
"Edit comment": "Editar comentário",
"Delete comment": "Excluir comentário",
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
"Delete chat": "Excluir chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
"Comment created successfully": "Comentário criado com sucesso",
"Error creating comment": "Erro ao criar comentário",
"Comment updated successfully": "Comentário atualizado com sucesso",
@@ -233,16 +218,17 @@
"Failed to delete comment": "Falha ao excluir comentário",
"Comment resolved successfully": "Comentário resolvido com sucesso",
"Comment re-opened successfully": "Comentário reaberto com sucesso",
"Comment unresolved successfully": "Comentário marcado como não resolvido com sucesso",
"Comment unresolved successfully": "Comentário não resolvido com sucesso",
"Failed to resolve comment": "Falha ao resolver comentário",
"Resolve comment": "Resolver comentário",
"Unresolve comment": "Marcar comentário como não resolvido",
"Resolve Comment Thread": "Resolver tópico de comentários",
"Unresolve Comment Thread": "Marcar tópico de comentários como não resolvido",
"Unresolve comment": "Não resolver comentário",
"Resolve Comment Thread": "Resolver Fio de Comentários",
"Unresolve Comment Thread": "Não resolver Fio de Comentários",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Tem certeza de que deseja resolver este fio de comentários? Isso o marcará como concluído.",
"Are you sure you want to unresolve this comment thread?": "Tem certeza de que deseja não resolver este fio de comentários?",
"Resolved": "Resolvido",
"No active comments.": "Sem comentários ativos.",
"No resolved comments.": "Sem comentários resolvidos.",
"Revoke invitation": "Cancelar o convite",
"Revoke": "Anular",
"Don't": "Não",
@@ -261,7 +247,7 @@
"Are you sure you want to delete this space?": "Tem certeza de que deseja excluir este espaço?",
"Delete this space with all its pages and data.": "Excluir este espaço com todas as suas páginas e dados.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Todas as páginas, comentários, anexos e permissões neste espaço serão excluídos de forma irreversível.",
"Confirm space name": "Confirmar nome do espaço",
"Confirm space name": "Confirme o nome do espaço",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Digite o nome do espaço <b>{{spaceName}}</b> para confirmar sua ação.",
"Format": "Formato",
"Include subpages": "Incluir subpáginas",
@@ -277,9 +263,6 @@
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
"Alt text": "Texto alternativo",
"Describe this for accessibility.": "Descreva isto para acessibilidade.",
"Add a description": "Adicionar uma descrição",
"Justify": "Justificar",
"Merge cells": "Mesclar células",
"Split cell": "Dividir célula",
@@ -290,21 +273,7 @@
"Add row above": "Adicionar linha acima",
"Add row below": "Adicionar linha abaixo",
"Delete table": "Excluir tabela",
"Add column left": "Adicionar coluna à esquerda",
"Add column right": "Adicionar coluna à direita",
"Clear cell": "Limpar célula",
"Clear cells": "Limpar células",
"Toggle header cell": "Alternar célula de cabeçalho",
"Toggle header column": "Alternar coluna de cabeçalho",
"Toggle header row": "Alternar linha de cabeçalho",
"Move column left": "Mover coluna para a esquerda",
"Move column right": "Mover coluna para a direita",
"Move row down": "Mover linha para baixo",
"Move row up": "Mover linha para cima",
"Sort A → Z": "Ordenar A → Z",
"Sort Z → A": "Ordenar Z → A",
"Info": "Informação",
"Note": "Observação",
"Success": "Sucesso",
"Warning": "Aviso",
"Danger": "Perigo",
@@ -315,11 +284,6 @@
"Save & Exit": "Salvar e Sair",
"Double-click to edit Excalidraw diagram": "Clique duas vezes para editar o diagrama Excalidraw",
"Paste link": "Colar link",
"Paste link or search pages": "Cole o link ou pesquise páginas",
"Link to web page": "Link para página da web",
"Recents": "Recentes",
"Page or URL": "Página ou URL",
"Link title": "Título do link",
"Edit link": "Editar link",
"Remove link": "Remover link",
"Add link": "Adicionar link",
@@ -338,7 +302,7 @@
"Pink": "Rosa",
"Gray": "Cinza",
"Embed link": "Link embutido",
"Invalid {{provider}} embed link": "Link de incorporação do {{provider}} inválido",
"Invalid {{provider}} embed link": "Link de incorporação {{provider}} inválido",
"Embed {{provider}}": "Incorporar {{provider}}",
"Enter {{provider}} link to embed": "Digite o link do {{provider}} para incorporar",
"Bold": "Negrito",
@@ -365,11 +329,8 @@
"Create block quote.": "Crie uma citação em bloco.",
"Insert code snippet.": "Insira um trecho de código.",
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Page break": "Quebra de página",
"Insert a page break for printing.": "Insira uma quebra de página para impressão.",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
@@ -380,48 +341,29 @@
"Divider": "Divisor",
"Quote": "Citação",
"Image": "Imagem",
"Audio": "Áudio",
"Embed PDF": "Incorporar PDF",
"Upload and embed a PDF file.": "Envie e incorpore um arquivo PDF.",
"Embed as PDF": "Incorporar como PDF",
"Failed to load PDF": "Falha ao carregar PDF",
"Convert to attachment": "Converter em anexo",
"File attachment": "Anexo de arquivo",
"Toggle block": "Bloco recolvel",
"Callout": "Chamada",
"Toggle block": "Bloco colapsável",
"Callout": "Aviso",
"Insert callout notice.": "Insira um aviso.",
"Math inline": "Matemática em linha",
"Math inline": "Matemática inline",
"Insert inline math equation.": "Insira uma equação matemática inline.",
"Math block": "Bloco matemático",
"Insert math equation": "Inserir equação matemática",
"Math block": "Bloco de matemática",
"Insert math equation": "Insira uma equação matemática",
"Mermaid diagram": "Diagrama Mermaid",
"Insert mermaid diagram": "Inserir diagrama Mermaid",
"Insert and design Drawio diagrams": "Inserir e criar diagramas Drawio",
"Insert current date": "Inserir data atual",
"Draw and sketch excalidraw diagrams": "Desenhar e esboçar diagramas Excalidraw",
"Insert mermaid diagram": "Insira um diagrama Mermaid",
"Insert and design Drawio diagrams": "Insira e projete diagramas Drawio",
"Insert current date": "Insira a data atual",
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
"Multiple": "Múltiplo",
"Turn into": "Transformar em",
"Text align": "Alinhar texto",
"This page may have been deleted, moved, or you may not have access.": "Esta página pode ter sido excluída, movida ou você pode não ter acesso a ela.",
"Go to homepage": "Ir para a página inicial",
"Pages you create will show up here.": "As páginas que você criar aparecerão aqui.",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Título {{level}}",
"Toggle title": "Título do bloco recolhível",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para ver os comandos",
"Write...": "Escreva...",
"Column count": "Número de colunas",
"{{count}} Columns": "{{count}} colunas",
"{{count}} command available_one": "1 comando disponível",
"{{count}} command available_other": "{{count}} comandos disponíveis",
"{{count}} result available_one": "1 resultado disponível",
"{{count}} result available_other": "{{count}} resultados disponíveis",
"Equal columns": "Colunas iguais",
"Left sidebar": "Barra lateral esquerda",
"Right sidebar": "Barra lateral direita",
"Wide center": "Centro largo",
"Left wide": "Largo à esquerda",
"Right wide": "Largo à direita",
"Names do not match": "Os nomes não correspondem",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
"Names do not match": "Os nomes não coincidem",
"Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}",
"Space created successfully": "Espaço criado com sucesso",
@@ -437,58 +379,46 @@
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
"New update": "Nova atualização",
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
"Default page edit mode": "Modo padrão de edição da página",
"Default page edit mode": "Modo de edição de página padrão",
"Choose your preferred page edit mode. Avoid accidental edits.": "Escolha o modo de edição de página preferido. Evite edições acidentais.",
"Choose {{format}} file": "Escolher arquivo {{format}}",
"Reading": "Leitura",
"Delete member": "Excluir membro",
"Member deleted successfully": "Membro excluído com sucesso",
"Member deleted successfully": "Membro removido com sucesso",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
"Deactivate member": "Desativar membro",
"Activate member": "Ativar membro",
"Are you sure you want to deactivate this workspace member? They will no longer be able to access this workspace.": "Tem certeza de que deseja desativar este membro do espaço de trabalho? Ele não poderá mais acessar este espaço de trabalho.",
"Are you sure you want to activate this workspace member?": "Tem certeza de que deseja ativar este membro do espaço de trabalho?",
"Deactivate": "Desativar",
"Activate": "Ativar",
"Deactivated": "Desativado",
"Move": "Mover",
"Move page": "Mover página",
"Move page to a different space.": "Mover página para um espaço diferente.",
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
"Table of contents": "Sumário",
"Table of contents": "Tabela de conteúdos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
"Share": "Compartilhar",
"Public sharing": "Compartilhamento público",
"Shared by": "Compartilhado por",
"Shared at": "Compartilhado em",
"Inherits public sharing from": "Herda o compartilhamento público de",
"Inherits public sharing from": "Herdado do compartilhamento público de",
"Share to web": "Compartilhar na web",
"Shared to web": "Compartilhado na web",
"Anyone with the link can view this page": "Qualquer pessoa com o link pode visualizar esta página",
"Make this page publicly accessible": "Tornar esta página acessível publicamente",
"Include sub-pages": "Incluir subpáginas",
"Make sub-pages public too": "Tornar as subpáginas públicas também",
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
"Include sub-pages": "Incluir sub-páginas",
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
"Open page": "Abrir página",
"Page": "Página",
"Delete public share link": "Excluir link de compartilhamento público",
"Delete public share link": "Excluir o link público compartilhado",
"Delete share": "Excluir compartilhamento",
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente dos espaços dos quais você é membro aparecerão aqui",
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
"Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar a página",
"Failed to share page": "Falha ao compartilhar página",
"Disable public sharing": "Desativar compartilhamento público",
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Allow viewers to comment": "Permitir que os visualizadores comentem",
"Allow viewers to add comments on pages in this space.": "Permitir que os visualizadores adicionem comentários em páginas deste espaço.",
"Toggle viewer comments": "Ativar/desativar comentários de visualizadores",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Page permissions": "Permissões da página},{",
"Control who can view and edit individual pages. Available with an enterprise license.": "Controle quem pode visualizar e editar páginas individuais. Disponível com licença empresarial.",
"Requires an enterprise license": "Requer uma licença empresarial",
"Enable public sharing": "Ativar compartilhamento público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.",
@@ -501,7 +431,7 @@
"Copy page to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso",
"Page duplicated successfully": "Página duplicada com sucesso",
"Find": "Localizar",
"Find": "Encontrar",
"Not found": "Não encontrado",
"Previous Match (Shift+Enter)": "Correspondência anterior (Shift+Enter)",
"Next match (Enter)": "Próxima correspondência (Enter)",
@@ -511,16 +441,15 @@
"Replace (Enter)": "Substituir (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Substituir tudo (Ctrl+Alt+Enter)",
"Replace all": "Substituir tudo",
"View all": "Ver tudo",
"View all spaces": "Ver todos os espaços",
"Error": "Erro",
"Failed to disable MFA": "Falha ao desativar a MFA",
"Disable two-factor authentication": "Desativar autenticação de dois fatores",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desativar a autenticação de dois fatores tornará sua conta menos segura. Você só precisará de sua senha para entrar.",
"Please enter your password to disable two-factor authentication:": "Por favor, insira sua senha para desativar a autenticação de dois fatores:",
"Two-factor authentication has been enabled": "A autenticação de dois fatores foi ativada",
"Two-factor authentication has been disabled": "A autenticação de dois fatores foi desativada",
"2-step verification": "Verificação em 2 etapas",
"Two-factor authentication has been enabled": "Autenticação de dois fatores foi ativada",
"Two-factor authentication has been disabled": "Autenticação de dois fatores foi desativada",
"2-step verification": "Verificação em duas etapas",
"Protect your account with an additional verification layer when signing in.": "Proteja sua conta com uma camada adicional de verificação ao entrar.",
"Two-factor authentication is active on your account.": "Autenticação de dois fatores está ativa na sua conta.",
"Add 2FA method": "Adicionar método de 2FA",
@@ -528,43 +457,43 @@
"Disable": "Desativar",
"Invalid verification code": "Código de verificação inválido",
"New backup codes have been generated": "Novos códigos de backup foram gerados",
"Failed to regenerate backup codes": "Falha ao regenerar os códigos de backup",
"About backup codes": "Sobre os códigos de backup",
"Failed to regenerate backup codes": "Falha ao regenerar códigos de backup",
"About backup codes": "Sobre códigos de backup",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Códigos de backup podem ser usados para acessar sua conta se perder acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Você pode regenerar novos códigos de backup a qualquer momento. Isso invalidará todos os códigos existentes.",
"Confirm password": "Confirmar senha",
"Generate new backup codes": "Gerar novos códigos de backup",
"Save your new backup codes": "Salve seus novos códigos de backup",
"Save your new backup codes": "Salvar seus novos códigos de backup",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Certifique-se de salvar esses códigos em um local seguro. Seus códigos de backup antigos não são mais válidos.",
"Your new backup codes": "Seus novos códigos de backup",
"I've saved my backup codes": "Salvei meus códigos de backup",
"I've saved my backup codes": "Eu salvei meus códigos de backup",
"Failed to setup MFA": "Falha ao configurar a MFA",
"Setup & Verify": "Configurar e verificar",
"Setup & Verify": "Configurar & Verificar",
"Add to authenticator": "Adicionar ao autenticador",
"1. Scan this QR code with your authenticator app": "1. Escaneie este código QR com seu aplicativo autenticador",
"Can't scan the code?": "Não consegue escanear o código?",
"Enter this code manually in your authenticator app:": "Digite este código manualmente em seu aplicativo autenticador:",
"2. Enter the 6-digit code from your authenticator": "2. Insira o código de 6 dígitos do seu autenticador",
"2. Enter the 6-digit code from your authenticator": "2. Digite o código de 6 dígitos do seu autenticador",
"Verify and enable": "Verificar e ativar",
"Failed to generate QR code. Please try again.": "Falha ao gerar código QR. Por favor, tente novamente.",
"Backup": "Backup",
"Save codes": "Salvar códigos",
"Save your backup codes": "Salve seus códigos de backup",
"Save your backup codes": "Salvar seus códigos de backup",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Esses códigos podem ser usados para acessar sua conta se você perder o acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
"Print": "Imprimir",
"Two-factor authentication has been set up. Please log in again.": "A autenticação de dois fatores foi configurada. Por favor, faça login novamente.",
"Two-Factor authentication required": "Autenticação de dois fatores obrigatória",
"Your workspace requires two-factor authentication for all users": "Seu workspace exige autenticação de dois fatores para todos os usuários",
"Two-Factor authentication required": "Autenticação de dois fatores necessária",
"Your workspace requires two-factor authentication for all users": "Seu espaço de trabalho requer autenticação de dois fatores para todos os usuários",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar acessando seu espaço de trabalho, você deve configurar a autenticação de dois fatores. Isso adiciona uma camada extra de segurança à sua conta.",
"Set up two-factor authentication": "Configurar autenticação de dois fatores",
"Cancel and logout": "Cancelar e sair",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Seu espaço de trabalho requer autenticação de dois fatores. Por favor, configure para continuar.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Isso adiciona uma camada extra de segurança à sua conta, exigindo um código de verificação de seu aplicativo autenticador.",
"Password is required": "A senha é obrigatória",
"Password is required": "Senha é necessária",
"Password must be at least 8 characters": "A senha deve ter pelo menos 8 caracteres",
"Please enter a 6-digit code": "Insira um código de 6 dígitos",
"Please enter a 6-digit code": "Por favor, insira um código de 6 dígitos",
"Code must be exactly 6 digits": "O código deve ter exatamente 6 dígitos",
"Enter the 6-digit code found in your authenticator app": "Insira o código de 6 dígitos encontrado no seu aplicativo autenticador",
"Enter the 6-digit code found in your authenticator app": "Insira o código de 6 dígitos encontrado em seu aplicativo autenticador",
"Need help authenticating?": "Precisa de ajuda para autenticar?",
"MFA QR Code": "Código QR de MFA",
"Account created successfully. Please log in to set up two-factor authentication.": "Conta criada com sucesso. Por favor, faça login para configurar a autenticação de dois fatores.",
@@ -572,34 +501,32 @@
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha para configurar a autenticação de dois fatores.",
"Password reset was successful. Please log in with your new password.": "Redefinição de senha foi bem-sucedida. Por favor, faça login com sua nova senha.",
"Two-factor authentication": "Autenticação de dois fatores",
"Use authenticator app instead": "Usar aplicativo autenticador em vez disso",
"Use authenticator app instead": "Use o aplicativo autenticador em vez disso",
"Verify backup code": "Verificar código de backup",
"Use backup code": "Usar código de backup",
"Enter one of your backup codes": "Insira um dos seus códigos de backup",
"Enter one of your backup codes": "Digite um de seus códigos de backup",
"Backup code": "Código de backup",
"Enter one of your backup codes. Each backup code can only be used once.": "Digite um de seus códigos de backup. Cada código de backup só pode ser usado uma vez.",
"Verify": "Verificar",
"Trash": "Lixeira",
"Pages in trash will be permanently deleted after {{count}} days.": "{count, plural, one {A página na lixeira será excluída permanentemente após # dia.} other {As páginas na lixeira serão excluídas permanentemente após # dias.}}",
"Pages in trash will be permanently deleted after 30 days.": "Páginas na lixeira serão excluídas permanentemente após 30 dias.",
"Deleted": "Excluído",
"No pages in trash": "Nenhuma página na lixeira",
"No pages in trash": "Sem páginas na lixeira",
"Permanently delete page?": "Excluir página permanentemente?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir permanentemente '{{title}}'? Esta ação não pode ser desfeita.",
"Restore '{{title}}' and its sub-pages?": "Restaurar '{{title}}' e suas subpáginas?",
"Move to trash": "Mover para a lixeira",
"Move this page to trash?": "Mover esta página para a lixeira?",
"Restore page": "Restaurar página",
"Permanently delete": "Excluir permanentemente",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moveu esta página para a Lixeira {{time}}.",
"Page moved to trash": "Página movida para a lixeira",
"Page restored successfully": "Página restaurada com sucesso",
"Deleted by": "Excluído por",
"Deleted at": "Excluído em",
"Preview": "Visualização",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar as subpáginas",
"No subpages": "Nenhuma subpágina",
"Subpages (Child pages)": "Subpáginas (páginas filhas)",
"Failed to load subpages": "Falha ao carregar subpáginas",
"No subpages": "Sem subpáginas",
"Subpages (Child pages)": "Subpáginas (Páginas filhas)",
"List all subpages of the current page": "Listar todas as subpáginas da página atual",
"Attachments": "Anexos",
"All spaces": "Todos os espaços",
@@ -607,59 +534,58 @@
"Find a space": "Encontrar um espaço",
"Search in all your spaces": "Pesquisar em todos os seus espaços",
"Type": "Tipo",
"Enterprise": "Enterprise",
"Enterprise": "Empresa",
"Download attachment": "Baixar anexo",
"Allowed email domains": "Domínios de e-mail permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Somente usuários com endereços de e-mail desses domínios podem se cadastrar via SSO.",
"Allowed email domains": "Domínios de email permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Apenas usuários com endereços de email desses domínios podem se inscrever via SSO.",
"Enter valid domain names separated by comma or space": "Insira nomes de domínio válidos separados por vírgula ou espaço",
"Enforce two-factor authentication": "Exigir autenticação de dois fatores",
"Enforce two-factor authentication": "Impor autenticação de dois fatores",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Uma vez imposto, todos os membros devem habilitar a autenticação de dois fatores para acessar o espaço de trabalho.",
"Toggle MFA enforcement": "Alternar exigência de MFA",
"Toggle MFA enforcement": "Alternar imposição de MFA",
"Display name": "Nome de exibição",
"Allow signup": "Permitir cadastro",
"Enabled": "Ativado",
"Advanced Settings": "Configurações avançadas",
"Enable TLS/SSL": "Ativar TLS/SSL",
"Allow signup": "Permitir inscrição",
"Enabled": "Habilitado",
"Advanced Settings": "Configurações Avançadas",
"Enable TLS/SSL": "Habilitar TLS/SSL",
"Use secure connection to LDAP server": "Usar conexão segura com o servidor LDAP",
"Group sync": "Sincronização de grupos",
"Group sync": "Sincronização de grupo",
"No SSO providers found.": "Nenhum provedor de SSO encontrado.",
"Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}",
"Icon": "Ícone",
"Upload image": "Enviar imagem",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update": "Atualizar",
"Update {{credential}}": "Atualizar {{credential}}",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"Restrict API key creation to admins": "Restringir a criação de chave de API aos administradores",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Somente administradores e proprietários podem criar novas chaves de API. As chaves de membros já existentes continuarão funcionando.",
"Toggle restrict API keys to admins": "Alternar restrição de chaves de API para administradores",
"API key creation is restricted to admins by your workspace administrator.": "A criação de chaves de API foi restringida aos administradores pelo administrador do seu workspace.",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Thinking": "Pensando",
"Ask a question...": "Faça uma pergunta...",
"AI Answers": "Respostas de IA",
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
@@ -668,25 +594,6 @@
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"AI & MCP": "IA e MCP",
"AI": "IA",
"MCP": "MCP",
"Model Context Protocol (MCP)": "Protocolo de Contexto de Modelo (MCP)",
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Ative o servidor MCP para permitir que assistentes de IA e ferramentas interajam com o conteúdo do seu espaço de trabalho.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"MCP Server URL": "URL do servidor MCP",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use sua chave de API para autenticação. Você pode gerenciar chaves de API nas configurações da sua conta.",
"Supported tools": "Ferramentas compatíveis",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Seu espaço de trabalho tem MCP habilitado. Use sua chave de API para conectar assistentes de IA.",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Gerencie as chaves de API de todos os usuários do workspace. Veja a <anchor>documentação da API</anchor> para detalhes de uso.",
"View the <anchor>API documentation</anchor> for usage details.": "Veja a <anchor>documentação da API</anchor> para detalhes de uso.",
"View the <anchor>MCP documentation</anchor>.": "Veja a <anchor>documentação MCP</anchor>.",
"Sources": "Fontes",
"AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
"No answer available": "Nenhuma resposta disponível",
@@ -701,406 +608,12 @@
"Mark all as read": "Marcar todas como lidas",
"Mark as read": "Marcar como lida",
"More options": "Mais opções",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mencionou você em um comentário",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> comentou em uma página",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolveu um comentário",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mencionou você em uma página",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> concedeu a você acesso de edição a uma página",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> concedeu a você acesso de visualização a uma página",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> atualizou uma página",
"Watch page": "Acompanhar página",
"Stop watching": "Parar de acompanhar",
"Watch space": "Acompanhar espaço",
"Stop watching space": "Parar de acompanhar espaço",
"Email notifications": "Notificações por e-mail",
"Page updates": "Atualizações da página",
"Get notified when pages you watch are updated.": "Receba notificações quando as páginas que você observa forem atualizadas.",
"Page mentions": "Menções na página",
"Get notified when someone mentions you on a page.": "Receba notificações quando alguém mencionar você em uma página.",
"Comment mentions": "Menções em comentários",
"Get notified when someone mentions you in a comment.": "Receba notificações quando alguém mencionar você em um comentário.",
"New comments": "Novos comentários",
"Get notified about new comments on threads you participate in.": "Receba notificações sobre novos comentários nas discussões em que você participa.",
"Resolved comments": "Comentários resolvidos",
"Get notified when your comment is resolved.": "Receba notificações quando seu comentário for resolvido.",
"You are now watching this page": "Agora você está observando esta página",
"You are no longer watching this page": "Você não está mais observando esta página",
"You are now watching this space": "Agora você está acompanhando este espaço",
"You are no longer watching this space": "Você não está mais acompanhando este espaço",
"Direct": "Direto",
"Updates": "Atualizações",
"mentioned you in a comment": "mencionou você em um comentário",
"commented on a page": "comentou em uma página",
"resolved a comment": "resolveu um comentário",
"mentioned you on a page": "mencionou você em uma página",
"Today": "Hoje",
"Yesterday": "Ontem",
"This week": "Esta semana",
"Older": "Mais antigo",
"Restricted page": "Página restrita",
"Restricted pages cannot be shared publicly.": "Páginas restritas não podem ser compartilhadas publicamente.",
"Restricted by parent": "Restrita pela página pai",
"Restricted": "Restrito",
"Open": "Aberto",
"Inherits restrictions from ancestor page": "Herda restrições da página ancestral",
"Only people listed below can access this page": "Apenas as pessoas listadas abaixo podem acessar esta página",
"Everyone in this space can access": "Todos neste espaço podem acessar",
"No additional restrictions on this page": "Sem restrições adicionais nesta página",
"Only specific people can access": "Apenas pessoas específicas podem acessar",
"Use only inherited restrictions": "Usar apenas restrições herdadas",
"Add restrictions on top of inherited": "Adicionar restrições além das herdadas",
"Inherited restriction": "Restrição herdada",
"Access limited by": "Acesso limitado por",
"Restrict access to control who can view and edit this page": "Restringir o acesso para controlar quem pode visualizar e editar esta página",
"Add additional restrictions specific to this page": "Adicionar restrições adicionais específicas para esta página",
"Access": "Acesso",
"People with access": "Pessoas com acesso",
"Remove all": "Remover tudo",
"Remove access": "Remover acesso",
"Remove all access": "Remover todo o acesso",
"Are you sure you want to remove this member's access to the page?": "Tem certeza de que deseja remover o acesso deste membro à página?",
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Tem certeza de que deseja remover todo o acesso específico? Isso fará com que a página fique aberta para todos no espaço.",
"Trash retention": "Retenção da lixeira",
"Pages in trash will be permanently deleted after this period.": "As páginas na lixeira serão excluídas permanentemente após este período.",
"Trash retention updated": "Retenção da lixeira atualizada",
"Failed to update trash retention": "Falha ao atualizar a retenção da lixeira",
"Removed page restriction": "Restrição de página removida",
"Added page permission": "Permissão de página adicionada",
"Removed page permission": "Permissão de página removida",
"day": "dia",
"days": "dias",
"week": "semana",
"weeks": "semanas",
"month": "mês",
"months": "meses",
"year": "ano",
"years": "anos",
"Period": "Período",
"Fixed date": "Data fixa",
"Indefinitely": "Indefinidamente",
"Days": "Dias",
"Weeks": "Semanas",
"Months": "Meses",
"Years": "Anos",
"Pick a date": "Escolha uma data",
"Maximum is {{max}} {{unit}} for this unit": "O máximo é {{max}} {{unit}} para esta unidade",
"Never expires. Verifiers can re-verify at any time.": "Nunca expira. Os verificadores podem verificar novamente a qualquer momento.",
"Verified": "Verificado",
"Review needed": "Revisão necessária",
"Verification expired": "A verificação expirou",
"Draft": "Rascunho",
"In Approval": "Em aprovação",
"In approval": "Em aprovação",
"Approved": "Aprovado",
"Obsolete": "Obsoleto",
"Expiring": "Expirando",
"Set up verification": "Configurar verificação",
"Verify page": "Verificar página",
"Page verification": "Verificação da página",
"Add verification": "Adicionar verificação",
"Edit verification": "Editar verificação",
"Search by title": "Pesquisar por título",
"Choose how this page should stay accurate.": "Escolha como esta página deve permanecer precisa.",
"Recurring verification": "Verificação recorrente",
"Verifiers re-confirm this page on a schedule.": "Os verificadores confirmam novamente esta página em uma programação definida.",
"Re-verify on a schedule (e.g every 30 days )": "Verificar novamente em uma programação definida (ex.: a cada 30 dias)",
"Page stays editable at all times": "A página permanece editável o tempo todo",
"Best for runbooks, FAQs, living documentation": "Ideal para runbooks, FAQs e documentação viva",
"Approval workflow": "Fluxo de aprovação",
"Formal document lifecycle with named approvers.": "Ciclo de vida formal do documento com aprovadores nomeados.",
"Draft → In approval → Approved → Obsolete": "Rascunho → Em aprovação → Aprovado → Obsoleto",
"Locked once approved, with full history": "Bloqueado após a aprovação, com histórico completo",
"Designed for ISO 9001, ISO 13485, and FDA": "Desenvolvido para ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideal para POPs e documentos controlados",
"Back": "Voltar",
"Quality management": "Gestão da qualidade",
"Recurring": "Recorrente",
"Pages move through draft, approval, and approved stages.": "As páginas passam pelos estágios de rascunho, aprovação e aprovado.",
"Verifiers": "Verificadores",
"Add verifier": "Adicionar verificador",
"I've reviewed this page for accuracy": "Revisei esta página quanto à precisão",
"Set up": "Configurar",
"Remove verification": "Remover verificação",
"Are you sure you want to remove verification from this page?": "Tem certeza de que deseja remover a verificação desta página?",
"Assigned verifiers must periodically re-verify this page.": "Os verificadores atribuídos devem verificar novamente esta página periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Verificado pela última vez por {{name}} {{time}} (expirado)",
"The fixed expiration date has passed.": "A data fixa de expiração já passou.",
"Verified by {{name}} {{time}}": "Verificado por {{name}} {{time}}",
"Expires {{date}}": "Expira em {{date}}",
"Expired {{date}}": "Expirou em {{date}}",
"Mark as obsolete": "Marcar como obsoleto",
"Mark obsolete": "Marcar como obsoleto",
"Returned by {{name}} {{time}}": "Devolvido por {{name}} {{time}}",
"No approval has been requested yet.": "Nenhuma aprovação foi solicitada ainda.",
"Submitted by {{name}} {{time}}": "Enviado por {{name}} {{time}}",
"Someone": "Alguém",
"Approved by {{name}} {{time}}": "Aprovado por {{name}} {{time}}",
"This document has been marked as obsolete.": "Este documento foi marcado como obsoleto.",
"Rejection comment": "Comentário de rejeição",
"Reason for returning this document...": "Motivo para devolver este documento...",
"Confirm rejection": "Confirmar rejeição",
"Submit for approval": "Enviar para aprovação",
"Reject": "Rejeitar",
"Approve": "Aprovar",
"Re-submit for approval": "Reenviar para aprovação",
"Verified until": "Verificado até",
"QMS": "SGQ",
"Verified pages": "Páginas verificadas",
"Search pages...": "Pesquisar páginas...",
"Filter by space": "Filtrar por espaço",
"Filter by type": "Filtrar por tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verificou uma página",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> enviou uma página para sua aprovação",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolveu uma página para revisão",
"Page verification expires soon": "A verificação da página expirará em breve",
"Page verification has expired": "A verificação da página expirou",
"Verifying your email": "Verificando seu e-mail",
"Please wait...": "Por favor, aguarde...",
"Verification failed. The link may have expired.": "Falha na verificação. O link pode ter expirado.",
"Check your email": "Verifique seu e-mail",
"We sent a verification link to {{email}}.": "Enviamos um link de verificação para {{email}}.",
"We sent a verification link to your email.": "Enviamos um link de verificação para seu e-mail.",
"Click the link to verify your email and access your workspace.": "Clique no link para verificar seu e-mail e acessar seu workspace.",
"Resend verification email": "Reenviar e-mail de verificação",
"Verification email sent. Please check your inbox.": "E-mail de verificação enviado. Por favor, verifique sua caixa de entrada.",
"Failed to resend verification email. Please try again.": "Falha ao reenviar o e-mail de verificação. Por favor, tente novamente.",
"We've sent you an email with your associated workspaces.": "Enviamos um e-mail para você com seus workspaces associados.",
"Load more": "Carregar mais",
"Log out of all devices": "Sair de todos os dispositivos",
"Log out of all sessions except this device": "Sair de todas as sessões, exceto deste dispositivo",
"This Device": "Este dispositivo",
"Unknown device": "Dispositivo desconhecido",
"No active sessions": "Nenhuma sessão ativa",
"Session revoked": "Sessão revogada",
"All other sessions revoked": "Todas as outras sessões foram revogadas",
"Last used": "Último uso",
"Created": "Criado",
"Rename": "Renomear",
"Publish": "Publicar",
"Security": "Segurança",
"Enforce SSO": "Exigir SSO",
"Once enforced, members will not be able to login with email and password.": "Depois de exigido, os membros não poderão fazer login com e-mail e senha.",
"AI-generated content may not be accurate.": "O conteúdo gerado por IA pode não ser preciso.",
"AI Chat": "Chat com IA",
"Analyze for insights": "Analisar para obter insights",
"Ask anything...": "Pergunte qualquer coisa...",
"Assistant said:": "O assistente disse:",
"Chat history": "Histórico de chats",
"Chat name": "Nome do chat",
"Chat transcript": "Transcrição do chat",
"Close": "Fechar",
"Copy assistant response": "Copiar resposta do assistente",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Falha ao carregar o chat. Ocorreu um erro.",
"Failed to render this message.": "Falha ao renderizar esta mensagem.",
"How can I help you today?": "Como posso ajudar você hoje?",
"New chat": "Novo chat",
"No chat history": "Nenhum histórico de chat",
"No chats found": "Nenhum chat encontrado",
"No conversations yet": "Ainda não há conversas",
"Open full page": "Abrir página inteira",
"Scroll to bottom": "Rolar até o fim",
"You said:": "Você disse:",
"Previous 7 days": "Últimos 7 dias",
"Previous 30 days": "Últimos 30 dias",
"Search chats...": "Pesquisar chats...",
"Search chats": "Pesquisar chats",
"Ask anything... Use @ to mention pages": "Pergunte qualquer coisa... Use @ para mencionar páginas",
"Ask anything or search your workspace": "Pergunte qualquer coisa ou pesquise no seu workspace",
"Welcome to {{name}}": "Boas-vindas a {{name}}",
"Add files": "Adicionar arquivos",
"Mention a page": "Mencionar uma página",
"Start a new chat to see it here.": "Inicie um novo chat para vê-lo aqui.",
"Summarize this page": "Resumir esta página",
"Toggle AI Chat": "Alternar chat com IA",
"Translate this page": "Traduzir esta página",
"Try a different search term.": "Tente um termo de pesquisa diferente.",
"Try again": "Tentar novamente",
"Untitled chat": "Chat sem título",
"What can I help you with?": "Com o que posso ajudar você?",
"Are you sure you want to revoke this {{credential}}": "Tem certeza de que deseja revogar esta {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Provisione automaticamente usuários e grupos do seu provedor de identidade via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure seu provedor de identidade com esta URL para provisionar usuários e grupos.",
"Create {{credential}}": "Criar {{credential}}",
"{{credential}} created": "{{credential}} criada",
"{{credential}} created successfully": "{{credential}} criada com sucesso",
"Created by": "Criado por",
"Custom": "Personalizado",
"Enable SCIM": "Ativar SCIM",
"Enter a descriptive name": "Insira um nome descritivo",
"I've saved my {{credential}}": "Salvei minha {{credential}}",
"Important": "Importante",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Copie sua {{credential}} agora. Você não poderá vê-la novamente!",
"Never": "Nunca",
"Revoke {{credential}}": "Revogar {{credential}}",
"SCIM endpoint URL": "URL do endpoint SCIM",
"SCIM provisioning": "Provisionamento SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "O SCIM tem precedência sobre a sincronização de grupos por SSO enquanto estiver ativado.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Você atingiu o máximo de {{max}} tokens SCIM. Exclua um token existente para criar um novo.",
"SCIM token": "Token SCIM",
"SCIM tokens": "Tokens SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Esta ação não pode ser desfeita. Seu provedor de identidade deixará de sincronizar imediatamente.",
"Toggle SCIM provisioning": "Alternar provisionamento SCIM",
"Token": "Token",
"Page menu": "Menu da página",
"Expand": "Expandir",
"Collapse": "Recolher",
"Comment menu": "Menu de comentários",
"Group menu": "Menu do grupo",
"Show hidden breadcrumbs": "Mostrar breadcrumbs ocultos",
"Breadcrumbs": "Trilhas de navegação",
"Page actions": "Ações da página",
"Pick emoji": "Escolher emoji",
"Template menu": "Menu do modelo",
"Use": "Usar",
"Use template": "Usar modelo",
"Preview template: {{title}}": "Visualizar modelo: {{title}}",
"Use a template": "Usar um modelo",
"Search templates...": "Pesquisar modelos...",
"Search spaces...": "Pesquisar espaços...",
"No templates found": "Nenhum modelo encontrado",
"No spaces found": "Nenhum espaço encontrado",
"Browse all templates": "Ver todos os modelos",
"This space": "Este espaço",
"All templates": "Todos os modelos",
"Global": "Global",
"New template": "Novo modelo",
"Edit template": "Editar modelo",
"Are you sure you want to delete this template?": "Tem certeza de que deseja excluir este modelo?",
"Template scope updated": "Escopo do modelo atualizado",
"Choose which space this template belongs to": "Escolha a qual espaço este modelo pertence",
"Scope": "Escopo",
"Select scope": "Selecionar escopo",
"Title": "Título",
"Saving...": "Salvando...",
"Saved": "Salvo",
"Save failed. Retry": "Falha ao salvar. Tentar novamente",
"By {{name}}": "Por {{name}}",
"Updated {{time}}": "Atualizado {{time}}",
"Choose destination": "Escolher destino",
"Search pages and spaces...": "Pesquisar páginas e espaços...",
"No results found": "Nenhum resultado encontrado",
"You don't have permission to create pages here": "Você não tem permissão para criar páginas aqui",
"Chat menu": "Menu do chat",
"API key menu": "Menu da chave de API",
"Jump to comment selection": "Ir para a seleção de comentários",
"Slash commands": "Comandos de barra",
"Mention suggestions": "Sugestões de menção",
"Link suggestions": "Sugestões de links",
"Diagram editor": "Editor de diagramas",
"Add comment": "Adicionar comentário",
"Find and replace": "Localizar e substituir",
"Main navigation": "Navegação principal",
"Space navigation": "Navegação do espaço",
"Settings navigation": "Navegação de configurações",
"AI navigation": "Navegação de IA",
"Breadcrumb": "Trilha de navegação",
"Synced block": "Bloco sincronizado",
"Create a block that stays in sync across pages.": "Crie um bloco que permaneça sincronizado entre páginas.",
"Editing original": "Editando original",
"Copy synced block": "Copiar bloco sincronizado",
"Unsync": "Desfazer sincronização",
"Delete synced block": "Excluir bloco sincronizado",
"Synced to {{count}} other page_one": "Sincronizado com {{count}} outra página",
"Synced to {{count}} other page_other": "Sincronizado com {{count}} outras páginas",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "ESTA PÁGINA",
"No pages": "Nenhuma página",
"The original synced block no longer exists": "O bloco sincronizado original não existe mais",
"You don't have access to this synced block": "Você não tem acesso a este bloco sincronizado",
"Failed to load this synced block": "Falha ao carregar este bloco sincronizado",
"Fixed editor toolbar": "Barra de ferramentas fixa do editor",
"Show a formatting toolbar above the editor with quick access to common actions.": "Mostre uma barra de ferramentas de formatação acima do editor com acesso rápido a ações comuns.",
"Toggle fixed editor toolbar": "Alternar barra de ferramentas fixa do editor",
"Normal text": "Texto normal",
"More inline formatting": "Mais formatação em linha",
"Subscript": "Subscrito",
"Superscript": "Sobrescrito",
"Inline code": "Código em linha",
"Insert media": "Inserir mídia",
"Mention": "Menção",
"Emoji": "Emoji",
"Columns": "Colunas",
"More inserts": "Mais inserções",
"Embeds": "Incorporações",
"Diagrams": "Diagramas",
"Advanced": "Avançado",
"Utility": "Utilitário",
"Decrease indent": "Diminuir recuo",
"Increase indent": "Aumentar recuo",
"Clear formatting": "Limpar formatação",
"Code block": "Bloco de código",
"Experimental": "Experimental",
"Strikethrough": "Tachado",
"Undo": "Desfazer",
"Redo": "Refazer",
"Backlinks": "Links de retorno",
"Last updated by": "Última atualização por",
"Last updated": "Última atualização",
"Stats": "Estatísticas",
"Word count": "Contagem de palavras",
"Characters": "Caracteres",
"Incoming links": "Links recebidos",
"Outgoing links": "Links de saída",
"Incoming links ({{count}})": "Links recebidos ({{count}})",
"Outgoing links ({{count}})": "Links de saída ({{count}})",
"No pages link here yet.": "Nenhuma página tem link para cá ainda.",
"This page doesn't link to other pages yet.": "Esta página ainda não tem links para outras páginas.",
"Verified until {{date}}": "Verificado até {{date}}",
"Labels": "Rótulos",
"Add label": "Adicionar rótulo",
"No labels yet": "Ainda não há rótulos",
"Already added": "Já adicionado",
"Invalid label name": "Nome de rótulo inválido",
"No matches": "Sem correspondências",
"Search or create…": "Pesquisar ou criar…",
"Remove label {{name}}": "Remover rótulo {{name}}",
"Failed to add label": "Falha ao adicionar rótulo",
"Failed to remove label": "Falha ao remover rótulo",
"No pages with this label": "Nenhuma página com este rótulo",
"Pages tagged with this label will appear here.": "As páginas marcadas com este rótulo aparecerão aqui.",
"No pages match your search.": "Nenhuma página corresponde à sua pesquisa.",
"Updated {{date}}": "Atualizado em {{date}}",
"Cell actions": "Ações da célula",
"Column actions": "Ações da coluna",
"Row actions": "Ações da linha",
"Filter": "Filtrar",
"Page title": "Título da página",
"Page content": "Conteúdo da página",
"Member actions": "Ações do membro",
"Toggle password visibility": "Alternar visibilidade da senha",
"Send comment": "Enviar comentário",
"Token actions": "Ações do token",
"Template settings": "Configurações do modelo",
"Edit diagram": "Editar diagrama",
"Edit embed": "Editar incorporação",
"Edit drawing": "Editar desenho",
"Delete equation": "Excluir equação",
"Invite actions": "Ações do convite",
"Get started": "Começar",
"* indicates required fields": "* indica campos obrigatórios",
"List of spaces in this workspace": "Lista de espaços neste workspace",
"Active sessions": "Sessões ativas",
"Add {{name}} to favorites": "Adicionar {{name}} aos favoritos",
"Remove {{name}} from favorites": "Remover {{name}} dos favoritos",
"Added to favorites": "Adicionado aos favoritos",
"Removed from favorites": "Removido dos favoritos",
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
"Page menu for {{name}}": "Menu da página de {{name}}",
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Older": "Mais antigo"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-26
View File
@@ -26,7 +26,6 @@ import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
@@ -38,15 +37,6 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
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 VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -72,7 +62,6 @@ export default function App() {
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</>
)}
@@ -84,22 +73,12 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/ai"} element={<AiChat />} />
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
element={<TemplateEditor />}
/>
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
@@ -107,8 +86,6 @@ export default function App() {
element={<Page />}
/>
<Route path={"/base/:pageId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
@@ -125,9 +102,6 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</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 () => {
if (disabled) return;
@@ -118,8 +104,6 @@ export default function AvatarUploader({
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}
/>
@@ -131,8 +115,6 @@ export default function AvatarUploader({
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
aria-label={ariaLabel}
aria-haspopup="menu"
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
@@ -148,7 +130,7 @@ export default function AvatarUploader({
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 200,
zIndex: 1000,
}}
>
<Loader size="sm" />
+3 -11
View File
@@ -1,4 +1,4 @@
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
@@ -6,21 +6,15 @@ import { useTranslation } from "react-i18next";
interface CopyProps {
text: string;
size?: MantineSize;
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 }: CopyProps) {
const { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : copyLabel}
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
@@ -28,8 +22,6 @@ export default function CopyTextButton({ text, size, label }: CopyProps) {
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
size={size}
aria-label={copied ? t("Copied") : copyLabel}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -81,7 +81,7 @@ export default function ExportModal({
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
@@ -4,8 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
ActionIcon,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
@@ -17,7 +16,6 @@ import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
interface Props {
spaceId?: string;
@@ -25,8 +23,7 @@ interface Props {
export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation();
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
const pages = data?.pages.flatMap((p) => p.items) ?? [];
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
if (isLoading) {
return <PageListSkeleton />;
@@ -36,73 +33,58 @@ export default function RecentChanges({ spaceId }: Props) {
return <Text>{t("Failed to fetch recent pages")}</Text>;
}
return pages.length > 0 ? (
<>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.map((page) => (
<Table.Tr key={page.id} className={rowClasses.row}>
<Table.Td>
<UnstyledButton
className={rowClasses.link}
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
return pages && pages.items.length > 0 ? (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
{!spaceId && (
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
{!spaceId && (
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
>
{formattedDate(page.updatedAt)}
</Text>
{page?.space.name}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{hasNextPage && (
<Button
variant="subtle"
fullWidth
mt="sm"
mb="xl"
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
{t("Load more")}
</Button>
)}
</>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
) : (
<EmptyState
icon={IconFiles}
@@ -6,14 +6,12 @@ import { useTranslation } from "react-i18next";
export interface SearchInputProps {
placeholder?: string;
ariaLabel?: string;
debounceDelay?: number;
onSearch: (value: string) => void;
}
export function SearchInput({
placeholder,
ariaLabel,
debounceDelay = 500,
onSearch,
}: SearchInputProps) {
@@ -30,7 +28,6 @@ export function SearchInput({
<TextInput
size="sm"
placeholder={placeholder || t("Search...")}
aria-label={ariaLabel || placeholder || t("Search")}
leftSection={<IconSearch size={16} />}
value={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 { IconUsersGroup } from "@tabler/icons-react";
export function IconGroupCircle() {
return (
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} />
</ThemeIcon>
</ActionIcon>
);
}
@@ -7,19 +7,6 @@
padding-right: var(--mantine-spacing-md);
}
.brand {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.brandIcon {
display: flex;
align-items: center;
}
.link {
display: block;
line-height: 1;
@@ -29,9 +16,6 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
user-select: none;
white-space: nowrap;
flex-shrink: 0;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
@@ -1,18 +1,8 @@
import {
ActionIcon,
Badge,
Box,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { Badge, Group, Text, Tooltip } from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link, useLocation } from "react-router-dom";
import { IconSparkles } from "@tabler/icons-react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai";
import {
@@ -33,11 +23,8 @@ import {
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
const links = [
{ link: APP_ROUTE.HOME, label: "Home" },
];
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
export function AppHeader() {
const { t } = useTranslation();
@@ -47,12 +34,10 @@ export function AppHeader() {
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const { isTrial, trialDaysLeft } = useTrial();
const location = useLocation();
const toggleAside = useToggleAside();
const [workspace] = useAtom(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const isPageRoute = location.pathname.includes("/p/");
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const hideSidebar = isHomeRoute || isSpacesRoute;
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
@@ -64,44 +49,39 @@ export function AppHeader() {
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group wrap="nowrap">
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
{!hideSidebar && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
<Link to="/home" className={classes.brand} aria-label="Docmost">
<Box hiddenFrom="sm" className={classes.brandIcon}>
<img
src="/icons/favicon-32x32.png"
alt="Docmost"
width={22}
height={22}
/>
</Box>
<Text
size="lg"
fw={600}
style={{ userSelect: "none" }}
visibleFrom="sm"
>
Docmost
</Text>
</Link>
<Text
size="lg"
fw={600}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
>
Docmost
</Text>
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
{items}
@@ -118,49 +98,6 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
{aiChatEnabled && (
<>
<UnstyledButton
component={Link}
to="/ai"
className={classes.link}
visibleFrom="sm"
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
{t("AI Chat")}
</UnstyledButton>
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="dark"
size="sm"
hiddenFrom="sm"
aria-label={t("AI Chat")}
onClick={(e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
if (isPageRoute) {
e.preventDefault();
toggleAside("chat");
}
}}
>
<IconSparkles size={20} stroke={2} />
</ActionIcon>
</Tooltip>
</>
)}
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
@@ -27,3 +27,5 @@
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
}
}
@@ -1,27 +1,17 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { Box, ScrollArea, Text } from "@mantine/core";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
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 { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
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() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
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 component: ReactNode;
@@ -35,41 +25,21 @@ export default function Aside() {
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
case "chat":
component = <AsideChatPanel />;
title = "AI Chat";
break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default:
component = null;
title = null;
}
return (
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box p="md">
{component && (
<>
{tab !== "chat" && (
<Group justify="space-between" wrap="nowrap" mb="md">
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
<Text mb="md" fw={500}>
{t(title)}
</Text>
{tab === "comments" || tab === "chat" ? (
component
{tab === "comments" ? (
<CommentListWithTabs />
) : (
<ScrollArea
style={{ height: "85vh" }}
@@ -1,7 +1,6 @@
import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
@@ -11,27 +10,22 @@ import {
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
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({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
@@ -78,23 +72,24 @@ export default function GlobalAppShell({
const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
const isAiRoute = location.pathname.startsWith("/ai");
const isHomeRoute = location.pathname.startsWith("/home");
const isSpacesRoute = location.pathname === "/spaces";
const isPageRoute = location.pathname.includes("/p/");
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
const hideSidebar = isHomeRoute || isSpacesRoute;
return (
<>
<SkipToMain />
<AppShell
<AppShell
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}}
navbar={
!hideSidebar && {
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}
}
aside={
isPageRoute && {
width: 350,
@@ -107,61 +102,30 @@ export default function GlobalAppShell({
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
</AppShell.Header>
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
aria-label={
isSpaceRoute
? t("Space navigation")
: isSettingsRoute
? t("Settings navigation")
: isAiRoute
? t("AI navigation")
: t("Main navigation")
}
>
{isSpaceRoute && (
{!hideSidebar && (
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
)}
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
{isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
</AppShell.Navbar>
)}
<AppShell.Main>
{isSettingsRoute ? (
<Container size={900} pb={80}>
{children}
</Container>
<Container size={850}>{children}</Container>
) : (
children
)}
</AppShell.Main>
{isPageRoute && (
<AppShell.Aside
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
}
>
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
<Aside />
</AppShell.Aside>
)}
</AppShell>
</>
);
}
@@ -1,109 +0,0 @@
.navbar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
}
.section {
padding-bottom: var(--mantine-spacing-xs);
}
.link {
cursor: pointer;
display: flex;
align-items: center;
text-decoration: none;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding-left: var(--mantine-spacing-xs);
min-height: 30px;
border-radius: var(--mantine-radius-sm);
font-weight: 500;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
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] {
&,
& :hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
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 {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-right: var(--mantine-spacing-sm);
width: rem(16px);
height: rem(16px);
}
.sectionHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.spacer {
flex: 1;
}
.bottomSection {
padding-top: var(--mantine-spacing-xs);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.spaceItem {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
text-decoration: none;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding-left: var(--mantine-spacing-xs);
min-height: 30px;
border-radius: var(--mantine-radius-sm);
font-weight: 500;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
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,186 +0,0 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
import {
IconHome,
IconClock,
IconStar,
IconLayoutGrid,
IconSettings,
IconUserPlus,
IconTemplate,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
import { getSpaceUrl } from "@/lib/config";
import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function GlobalSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(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 favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
.filter((fav) => fav.space)
.sort((a, b) => {
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
});
const [inviteOpened, { open: openInvite, close: closeInvite }] =
useDisclosure(false);
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const handleNavClick = () => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
};
return (
<div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}>
{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
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
aria-current={active === item.path ? "page" : undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
),
)}
</div>
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
</Text>
) : (
<>
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
<Link
key={fav.id}
className={classes.spaceItem}
to={getSpaceUrl(fav.space!.slug)}
onClick={handleNavClick}
>
<CustomAvatar
name={fav.space!.name}
avatarUrl={fav.space!.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
<Text size="sm" fw={500} lineClamp={1}>
{fav.space!.name}
</Text>
</Link>
))}
{sortedFavoriteSpaces.length > 10 && (
<Link
className={classes.spaceItem}
to="/spaces"
onClick={handleNavClick}
>
<Text size="xs" c="dimmed">
{t("View all")}
</Text>
</Link>
)}
</>
)}
</div>
</ScrollArea>
<div className={classes.bottomSection}>
<UnstyledButton
className={classes.link}
onClick={openInvite}
>
<IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span>
</UnstyledButton>
<Link
className={classes.link}
data-active={active.startsWith("/settings") || undefined}
aria-current={active.startsWith("/settings") ? "page" : undefined}
to="/settings/account/profile"
onClick={handleNavClick}
>
<IconSettings className={classes.linkIcon} stroke={2} />
<span>{t("Settings")}</span>
</Link>
</div>
<Modal
size="550"
opened={inviteOpened}
onClose={closeInvite}
title={t("Invite new members")}
centered
>
<Divider size="xs" mb="xs" />
<ScrollArea h="80%">
<WorkspaceInviteForm onClose={closeInvite} />
</ScrollArea>
</Modal>
</div>
);
}
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
@@ -11,9 +11,6 @@ import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -83,26 +80,3 @@ export const prefetchApiKeyManagement = () => {
queryFn: () => getApiKeys({ adminView: true }),
});
};
export const prefetchAuditLogs = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
});
};
export const prefetchVerifiedPages = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["verification-list", params],
queryFn: () => getVerificationList(params),
});
};
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -13,8 +13,6 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -22,41 +20,38 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchScimTokens,
prefetchShares,
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
prefetchAuditLogs,
prefetchVerifiedPages,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
type DataItem = {
interface DataItem {
label: string;
icon: React.ElementType;
path: string;
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
};
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
type DataGroup = {
interface DataGroup {
heading: string;
items: DataItem[];
};
}
const groupedData: DataGroup[] = [
{
@@ -72,7 +67,9 @@ const groupedData: DataGroup[] = [
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
],
},
@@ -80,50 +77,44 @@ const groupedData: DataGroup[] = [
heading: "Workspace",
items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
{ label: "Members", icon: IconUsers, path: "/settings/members" },
{
label: "Members",
icon: IconUsers,
path: "/settings/members",
},
{
label: "Billing",
icon: IconCoin,
path: "/settings/billing",
role: "admin",
env: "cloud",
isCloud: true,
isAdmin: true,
},
{
label: "Security & SSO",
icon: IconLock,
path: "/settings/security",
feature: Feature.SECURITY_SETTINGS,
role: "admin",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "Verified pages",
icon: IconShieldCheck,
path: "/settings/verifications",
feature: Feature.PAGE_VERIFICATION,
},
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
feature: Feature.API_KEYS,
role: "admin",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
role: "admin",
},
{
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
role: "owner",
env: "selfhosted",
isAdmin: true,
},
],
},
@@ -144,9 +135,8 @@ export default function SettingsSidebar() {
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole();
const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -154,20 +144,41 @@ export default function SettingsSidebar() {
setActive(location.pathname);
}, [location.pathname]);
const hasFeature = (f: string) =>
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
if (item.showDisabledInNonEE && item.isEnterprise) {
// Check admin permission regardless of license
return item.isAdmin ? isAdmin : true;
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
}
if (item.isCloud) {
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
};
const isItemDisabled = (item: DataItem) => {
if (!item.feature) return false;
return !hasFeature(item.feature);
if (item.showDisabledInNonEE && item.isEnterprise) {
return !(isCloud() || workspace?.hasLicenseKey);
}
return false;
};
const menuItems = groupedData.map((group) => {
@@ -200,15 +211,12 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (entitlements?.tier !== "free") {
if (workspace?.hasLicenseKey) {
prefetchHandler = prefetchLicense;
}
break;
case "Security & SSO":
prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
prefetchHandler = prefetchSsoProviders;
break;
case "Public sharing":
prefetchHandler = prefetchShares;
@@ -219,61 +227,52 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
prefetchHandler = prefetchVerifiedPages;
break;
default:
break;
}
const isDisabled = isItemDisabled(item);
if (isDisabled) {
return (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<span
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>
);
}
return (
const linkElement = (
<Link
onMouseEnter={prefetchHandler}
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
data-disabled={isDisabled || undefined}
key={item.label}
to={item.path}
onClick={() => {
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) {
return (
<Tooltip
key={item.label}
label={t("Available in enterprise edition")}
position="right"
withArrow
>
{linkElement}
</Tooltip>
);
}
return linkElement;
})}
</div>
);
@@ -291,7 +290,7 @@ export default function SettingsSidebar() {
}}
variant="transparent"
c="gray"
aria-label={t("Back")}
aria-label="Back"
>
<IconArrowLeft stroke={2} />
</ActionIcon>
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) {
return (
<>
<Title order={1} size="h3">
<Title order={3}>
{title}
</Title>
<Divider my="md" />
@@ -34,7 +34,6 @@ export function AutoTooltipText({
disabled={!isTruncated || !label}
multiline
withArrow
withinPortal={false}
{...tooltipProps}
>
<Text
@@ -1,68 +0,0 @@
.root {
position: relative;
}
.track {
display: flex;
gap: var(--mantine-spacing-md);
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 2px;
margin: -2px;
}
.track::-webkit-scrollbar {
display: none;
}
.track > * {
scroll-snap-align: start;
flex: 0 0 auto;
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.root:hover .arrow.visible,
.arrow.visible:focus-visible {
opacity: 1;
pointer-events: auto;
}
.arrow:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.arrow:active {
transform: translateY(-50%) scale(0.95);
}
.arrowLeft {
left: -14px;
}
.arrowRight {
right: -14px;
}
@@ -1,77 +0,0 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./card-carousel.module.css";
type Props = {
children: ReactNode;
ariaLabel?: string;
};
export default function CardCarousel({ children, ariaLabel }: Props) {
const { t } = useTranslation();
const trackRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft < maxScroll - 1);
}, []);
useEffect(() => {
updateScrollState();
const el = trackRef.current;
if (!el) return;
const observer = new ResizeObserver(updateScrollState);
observer.observe(el);
for (const child of Array.from(el.children)) {
observer.observe(child);
}
return () => observer.disconnect();
}, [updateScrollState, children]);
const scrollBy = (direction: 1 | -1) => {
const el = trackRef.current;
if (!el) return;
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
};
return (
<div className={classes.root}>
<div
ref={trackRef}
className={classes.track}
onScroll={updateScrollState}
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
>
{children}
</div>
<button
type="button"
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
onClick={() => scrollBy(-1)}
aria-label={t("Scroll left")}
tabIndex={canScrollLeft ? 0 : -1}
>
<IconChevronLeft size={18} />
</button>
<button
type="button"
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
onClick={() => scrollBy(1)}
aria-label={t("Scroll right")}
tabIndex={canScrollRight ? 0 : -1}
>
<IconChevronRight size={18} />
</button>
</div>
);
}
@@ -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,10 +1,10 @@
import React from "react";
import { Avatar, MantineColor } from "@mantine/core";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl?: string;
avatarUrl: string;
name: string;
color?: string;
size?: string | number;
@@ -16,57 +16,19 @@ interface CustomAvatarProps {
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<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return (
<Avatar
ref={ref}
src={avatarLink}
name={initialsSource}
name={name}
alt={name}
color={resolvedColor}
color="initials"
{...props}
/>
);
@@ -1,71 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker";
import {
DestinationPickerModalProps,
DestinationSelection,
} from "./destination-picker.types";
export function DestinationPickerModal({
opened,
onClose,
title,
actionLabel,
onSelect,
loading,
excludePageId,
pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) {
const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null);
useEffect(() => {
if (!opened) {
setSelection(null);
}
}, [opened]);
return (
<Modal.Root
opened={opened}
onClose={onClose}
size={550}
padding="lg"
yOffset="10vh"
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content>
<Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
</Modal.Header>
<Modal.Body>
<DestinationPicker
onSelectionChange={setSelection}
excludePageId={excludePageId}
pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
<Button
onClick={() => selection && onSelect(selection)}
disabled={!selection}
loading={loading}
>
{actionLabel}
</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -1,134 +0,0 @@
.searchInput {
margin-bottom: var(--mantine-spacing-sm);
}
.scrollArea {
max-height: 50vh;
}
.row {
padding: 6px 12px;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease;
user-select: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.selected {
background-color: light-dark(
var(--mantine-color-blue-0),
var(--mantine-color-dark-5)
);
border-left: 2px solid var(--mantine-primary-color-filled);
}
.spaceRow {
composes: row;
font-weight: 500;
}
.pageRow {
composes: row;
font-weight: 400;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.chevron {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
}
.chevronExpanded {
transform: rotate(90deg);
}
.loadMore {
text-align: center;
padding: 6px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-sm);
cursor: pointer;
@mixin hover {
text-decoration: underline;
}
}
.selectedIndicator {
padding: 8px 12px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: var(--mantine-spacing-xs);
}
.emptyState {
padding: 12px;
text-align: center;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.searchResult {
composes: row;
}
.pageTitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
}
.spaceName {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
.iconWrapper {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
@@ -1,234 +0,0 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
import { DestinationSelection } from "./destination-picker.types";
import { SpaceRow } from "./space-row";
import classes from "./destination-picker.module.css";
type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
export function DestinationPicker({
onSelectionChange,
excludePageId,
pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100,
});
const searchEnabled =
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({
query: searchEnabled ? debouncedQuery : "",
includePages: true,
limit: 20,
});
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 =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
const updateSelection = useCallback(
(next: DestinationSelection | null) => {
setSelection(next);
onSelectionChange(next);
},
[onSelectionChange],
);
const handleSearchResultClick = (page: Partial<IPage>) => {
if (!page.space || !page.id) return;
updateSelection({
type: "page",
spaceId: page.space.id,
pageId: page.id,
page,
space: page.space,
});
setSearchQuery("");
};
const handleSelectSpace = useCallback(
(space: ISpace) => {
updateSelection({ type: "space", spaceId: space.id, space });
},
[updateSelection],
);
const handleSelectPage = useCallback(
(page: Partial<IPage>, space: ISpace) => {
if (!page.id) return;
updateSelection({
type: "page",
spaceId: page.spaceId ?? space.id,
pageId: page.id,
page,
space,
});
},
[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 (
<>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
aria-label={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
variant="filled"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput}
/>
<ScrollArea
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
{isSearching ? (
searchLoading ? (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : searchData?.pages && searchData.pages.length > 0 ? (
searchData.pages.map(
(page) =>
page && (
<div
key={page.id}
className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
>
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
</div>
{page.space && (
<div className={classes.spaceName}>
{page.space.name}
</div>
)}
</div>
),
)
) : (
<div className={classes.emptyState}>{t("No results found")}</div>
)
) : spacesLoading ? (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : filteredSpaces.length === 0 ? (
<div className={classes.emptyState}>
{searchSpacesOnly && debouncedQuery
? t("No spaces found")
: t("No results found")}
</div>
) : (
filteredSpaces.map((space) => (
<SpaceRow
key={space.id}
space={space}
limit={pageLimit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectSpace={handleSelectSpace}
onSelectPage={handleSelectPage}
/>
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -1,25 +0,0 @@
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
export type DestinationSelection =
| { type: "space"; spaceId: string; space: ISpace }
| {
type: "page";
spaceId: string;
pageId: string;
page: Partial<IPage>;
space: Partial<ISpace>;
};
export type DestinationPickerModalProps = {
opened: boolean;
onClose: () => void;
title: string;
actionLabel: string;
onSelect: (selection: DestinationSelection) => void | Promise<void>;
loading?: boolean;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
@@ -1,94 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { getSidebarPages } from "@/features/page/services/page-service";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types";
import { PageRow } from "./page-row";
import classes from "./destination-picker.module.css";
type PageChildrenProps = {
spaceId: string;
pageId?: string;
depth: number;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelectPage: (page: Partial<IPage>) => void;
};
export function PageChildren({
spaceId,
pageId,
depth,
limit,
selectedId,
excludePageId,
onSelectPage,
}: PageChildrenProps) {
const { t } = useTranslation();
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
queryFn: ({ pageParam }) =>
getSidebarPages({
spaceId,
pageId,
limit,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IPagination<IPage>) =>
lastPage.meta?.nextCursor ?? undefined,
});
const pages = data?.pages.flatMap((page) => page.items) ?? [];
if (isLoading) {
return (
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
);
}
if (pages.length === 0) {
return (
<div className={classes.emptyState}>
{pageId ? t("No pages inside") : t("No pages in this space")}
</div>
);
}
return (
<>
{pages.map((page) => (
<PageRow
key={page.id}
page={page}
depth={depth}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelect={onSelectPage}
/>
))}
{hasNextPage && (
<div
className={classes.loadMore}
onClick={() => fetchNextPage()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fetchNextPage();
}
}}
role="button"
tabIndex={0}
>
{t("Load more")}
</div>
)}
</>
);
}
@@ -1,115 +0,0 @@
import { KeyboardEvent, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
type PageRowProps = {
page: Partial<IPage>;
depth: number;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelect: (page: Partial<IPage>) => void;
};
export function PageRow({
page,
depth,
limit,
selectedId,
excludePageId,
onSelect,
}: PageRowProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const isExcluded = page.id === excludePageId;
const isSelected = page.id === selectedId;
const rowClasses = [
classes.pageRow,
isSelected && classes.selected,
isExcluded && classes.disabled,
]
.filter(Boolean)
.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 (
<>
<div
className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }}
role="button"
tabIndex={isExcluded ? -1 : 0}
aria-disabled={isExcluded || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{page.hasChildren ? (
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
</div>
</div>
{expanded && page.hasChildren && (
<PageChildren
spaceId={page.spaceId}
pageId={page.id}
depth={depth + 1}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectPage={onSelect}
/>
)}
</>
);
}
@@ -1,130 +0,0 @@
import { KeyboardEvent, useState } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types";
import { IPage } from "@/features/page/types/page.types";
import { SpaceRole } from "@/lib/types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
type SpaceRowProps = {
space: ISpace;
limit: number;
selectedId: string | null;
excludePageId?: string;
onSelectSpace: (space: ISpace) => void;
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
};
export function SpaceRow({
space,
limit,
selectedId,
excludePageId,
onSelectSpace,
onSelectPage,
}: SpaceRowProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const writable =
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
const isSelected = space.id === selectedId;
const rowClasses = [
classes.spaceRow,
isSelected && classes.selected,
!writable && classes.disabled,
]
.filter(Boolean)
.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 = (
<div
className={rowClasses}
data-space-id={space.id}
role="button"
tabIndex={writable ? 0 : -1}
aria-disabled={!writable || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
>
{writable ? (
<ActionIcon
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</ActionIcon>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
size={22}
/>
<div className={classes.pageTitle}>{space.name}</div>
{!writable && (
<IconLock
size={14}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
);
return (
<>
{writable ? (
rowContent
) : (
<Tooltip
label={t("You don't have permission to create pages here")}
position="right"
withArrow
>
<div>{rowContent}</div>
</Tooltip>
)}
{expanded && writable && (
<PageChildren
spaceId={space.id}
depth={1}
limit={limit}
selectedId={selectedId}
excludePageId={excludePageId}
onSelectPage={(page) => onSelectPage(page, space)}
/>
)}
</>
);
}
+6 -57
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from "react";
import React, { ReactNode, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,24 +7,9 @@ import {
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
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 {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
@@ -34,7 +19,6 @@ export interface EmojiPickerInterface {
size?: string;
variant?: 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) => {
onEmojiSelect(emoji);
handlers.close();
@@ -118,15 +70,11 @@ function EmojiPicker({
closeOnEscape={true}
>
<Popover.Target ref={setTarget}>
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
aria-expanded={opened}
>
{icon}
</ActionIcon>
@@ -134,6 +82,7 @@ function EmojiPicker({
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
@@ -14,14 +14,7 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => {
return (
<ActionIcon
size={size}
aria-expanded={opened}
{...others}
variant="subtle"
color="gray"
ref={ref}
>
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
{opened ? (
<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>
);
}
@@ -1,105 +0,0 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import { useChatStream } from "../hooks/use-chat-stream";
import ChatMessageList from "./chat-message-list";
import ChatEmptyState from "./chat-empty-state";
import ChatInput from "./chat-input";
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
import classes from "../styles/ai-chat.module.css";
export default function AiChatLayout() {
const { chatId } = useParams<{ chatId: string }>();
const location = useLocation();
const navigate = useNavigate();
const chatInfoQuery = useChatInfoQuery(chatId);
// If the URL points at a chat the user does not own, the info fetch 404s.
// Bounce them back to /ai so they cannot interact with any chat UI (including
// kicking off orphan uploads) tied to a chat they have no access to.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
navigate("/ai", { replace: true });
}
}, [chatId, chatInfoQuery.isError, navigate]);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId);
const autoSentRef = useRef(false);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
useEffect(() => {
if (autoSentRef.current || chatId) return;
const state = location.state as HomeAiPromptInitialState | null;
if (!state?.initialContent && !state?.initialAttachments?.length) return;
autoSentRef.current = true;
sendMessage(
state.initialContent ?? "",
state.initialMentions ?? [],
state.initialAttachments ?? [],
);
navigate(location.pathname, { replace: true, state: null });
}, [chatId, location, navigate, sendMessage]);
const hasMessages = messages.length > 0 || isStreaming || !!chatId;
// While the redirect effect is running (or if the user is still on this
// component for any reason) never render the chat UI for a forbidden chat.
if (chatId && chatInfoQuery.isError) {
return null;
}
return (
<div className={classes.main}>
{hasMessages ? (
<>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
{error && (
<div
style={{
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-sm)",
}}
>
{error}
</div>
)}
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
chatId={chatId}
/>
</div>
</>
) : (
<ChatEmptyState
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
/>
)}
</div>
);
}
@@ -1,167 +0,0 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { ActionIcon, Menu, TextInput } from "@mantine/core";
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
type Props = {
chat: AiChat;
isActive: boolean;
onDelete: (chatId: string, title: string | null) => void;
onRename: (chatId: string, title: string) => void;
};
function formatChatDate(
isoString: string | Date,
locale: string | undefined,
): string {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const ts = date.getTime();
const sameYear = date.getFullYear() === now.getFullYear();
if (ts >= startOfToday) {
return date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
});
}
if (sameYear) {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export default function AiChatSidebarItem({
chat,
isActive,
onDelete,
onRename,
}: Props) {
const { t, i18n } = useTranslation();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const formattedDate = useMemo(
() => formatChatDate(chat.updatedAt, i18n.language),
[chat.updatedAt, i18n.language],
);
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
const id = window.setTimeout(() => inputRef.current?.select(), 0);
return () => window.clearTimeout(id);
}
}, [renaming]);
const startRename = useCallback(() => {
setRenameValue(chat.title || "");
setRenaming(true);
}, [chat.title]);
const submitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== chat.title) {
onRename(chat.id, trimmed);
}
setRenaming(false);
}, [renameValue, chat.id, chat.title, onRename]);
if (renaming) {
return (
<div className={classes.chatItem} data-active={isActive || undefined}>
<TextInput
ref={inputRef}
size="xs"
variant="unstyled"
placeholder={t("Chat name")}
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitRename();
} else if (e.key === "Escape") {
e.preventDefault();
setRenaming(false);
}
}}
onBlur={submitRename}
classNames={{ input: classes.chatItemRenameInput }}
style={{ flex: 1 }}
/>
</div>
);
}
return (
<Link
to={`/ai/chat/${chat.id}`}
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename();
}}
>
{t("Rename")}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id, chat.title);
}}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</Link>
);
}
@@ -1,204 +0,0 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Center,
Text,
TextInput,
Loader,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useChatsQuery,
useDeleteChatMutation,
useUpdateChatTitleMutation,
useSearchChatsQuery,
} from "../queries/ai-chat-query";
import AiChatSidebarItem from "./ai-chat-sidebar-item";
import { groupChatsByAge } from "../utils/group-chats-by-age";
import classes from "../styles/chat-sidebar.module.css";
export default function AiChatSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { chatId } = useParams<{ chatId: string }>();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const deleteMutation = useDeleteChatMutation();
const renameMutation = useUpdateChatTitleMutation();
const chats = useMemo(() => {
if (debouncedSearch) {
return searchQuery.data || [];
}
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
const sentinelRef = useRef<HTMLDivElement>(null);
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
const isSearching = Boolean(debouncedSearch);
useEffect(() => {
if (isSearching) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
navigate("/ai");
},
[navigate],
);
const handleDelete = useCallback(
(id: string, title: string | null) => {
modals.openConfirmModal({
title: t("Delete chat"),
centered: true,
children: (
<Text size="sm">
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
title: title || t("Untitled"),
})}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
},
});
},
[deleteMutation, chatId, navigate, t],
);
const handleRename = useCallback(
(chatId: string, title: string) => {
renameMutation.mutate({ chatId, title });
},
[renameMutation],
);
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<h2 className={classes.title}>{t("AI Chat")}</h2>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("New chat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</div>
<TextInput
className={classes.searchInput}
placeholder={t("Search chats...")}
aria-label={t("Search chats")}
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<div className={classes.chatList}>
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
{!isLoading && chats.length === 0 && (
<div className={classes.chatListEmpty}>
<IconMessageCircle2
size={28}
stroke={1.5}
className={classes.chatListEmptyIcon}
/>
<div className={classes.chatListEmptyTitle}>
{isSearching ? t("No chats found") : t("No conversations yet")}
</div>
<div className={classes.chatListEmptyHint}>
{isSearching
? t("Try a different search term.")
: t("Start a new chat to see it here.")}
</div>
</div>
)}
{isSearching
? chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</div>
))}
{!isSearching && (
<>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</>
)}
</div>
</div>
);
}
@@ -1,67 +0,0 @@
import { useState } from "react";
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import classes from "../styles/aside-chat-panel.module.css";
type Props = {
activeChatId: string | undefined;
onSelect: (chatId: string) => void;
};
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const isSearching = debouncedSearch.length > 0;
const chats = isSearching
? (searchQuery.data ?? [])
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
return (
<div>
<TextInput
placeholder={t("Search chats...")}
leftSection={<IconSearch size={14} />}
size="xs"
mb="xs"
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Loader size="sm" />
</div>
) : chats.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{isSearching ? t("No chats found") : t("No chat history")}
</Text>
) : (
<ScrollArea.Autosize mah={300} scrollbars="y">
<div className={classes.historyList}>
{chats.map((chat) => (
<div
key={chat.id}
className={classes.historyItem}
data-active={chat.id === activeChatId || undefined}
onClick={() => onSelect(chat.id)}
>
<span className={classes.historyItemTitle}>
{chat.title || t("Untitled chat")}
</span>
</div>
))}
</div>
</ScrollArea.Autosize>
)}
</div>
);
}
@@ -1,269 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconPlus,
IconChevronDown,
IconArrowsDiagonal,
IconX,
IconSparkles,
IconFileText,
IconLanguage,
IconSearch,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useChatStream } from "../hooks/use-chat-stream";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import ChatMessageList from "./chat-message-list";
import ChatInput from "./chat-input";
import AsideChatHistory from "./aside-chat-history";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/aside-chat-panel.module.css";
type QuickAction = {
icon: React.ReactNode;
label: string;
prompt: string;
};
export default function AsideChatPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setAsideState] = useAtom(asideStateAtom);
const [chatId, setChatId] = useState<string | undefined>(undefined);
const [historyOpen, setHistoryOpen] = useState(false);
const [contextPages, setContextPages] = useState<PageMention[]>([]);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: slugId });
const chatInfoQuery = useChatInfoQuery(chatId);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId, {
onChatCreated: (newChatId) => {
setChatId(newChatId);
},
});
useEffect(() => {
if (page && !chatId) {
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
}
}, [page, chatId]);
const handleRemoveContextPage = useCallback((pageId: string) => {
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
}, []);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
// Drop the open chatId if the current user lost access to it (404/403 on
// the info fetch). Reverts the panel to a fresh chat instead of presenting
// an input tied to a chat the user does not own.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
setChatId(undefined);
}
}, [chatId, chatInfoQuery.isError]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
setChatId(undefined);
if (page) {
setContextPages([
{ id: page.id, title: page.title || "", slugId: page.slugId },
]);
}
},
[page],
);
const handleSelectChat = useCallback((selectedChatId: string) => {
setChatId(selectedChatId);
setHistoryOpen(false);
}, []);
const handleExpand = useCallback(() => {
if (chatId) {
navigate(`/ai/chat/${chatId}`);
} else {
navigate("/ai");
}
setAsideState({ tab: "", isAsideOpen: false });
}, [chatId, navigate, setAsideState]);
const handleClose = useCallback(() => {
setAsideState({ tab: "", isAsideOpen: false });
}, [setAsideState]);
const handleSend = useCallback(
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
sendMessage(content, mentions, attachments, contextPageId);
},
[sendMessage, contextPages],
);
const handleQuickAction = useCallback(
(prompt: string) => {
handleSend(prompt, [], []);
},
[handleSend],
);
const hasMessages = messages.length > 0 || isStreaming;
const quickActions: QuickAction[] = [
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
];
return (
<div className={classes.panel}>
<div className={classes.toolbar}>
<Popover
opened={historyOpen}
onChange={setHistoryOpen}
position="bottom-start"
width={280}
shadow="md"
>
<Popover.Target>
<UnstyledButton
className={classes.titleButton}
onClick={() => setHistoryOpen((o) => !o)}
>
<span className={classes.titleText}>
{chatInfoQuery.data?.chat?.title || t("New chat")}
</span>
<IconChevronDown size={16} stroke={1.75} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
</Popover.Dropdown>
</Popover>
<div className={classes.toolbarSpacer} />
<Tooltip label={t("New chat")} openDelay={250}>
<ActionIcon
component="a"
href="/ai"
variant="subtle"
color="dark"
aria-label={t("New chat")}
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
</div>
{error && (
<div
style={{
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-xs)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<div className={classes.messages} data-aside-chat>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</div>
</>
) : (
<div className={classes.emptyState}>
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
<div className={classes.quickActions}>
{quickActions.map((action) => (
<button
key={action.label}
type="button"
className={classes.quickAction}
onClick={() => handleQuickAction(action.prompt)}
>
<span className={classes.quickActionIcon}>{action.icon}</span>
{action.label}
</button>
))}
</div>
</div>
)}
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={handleSend}
onStop={stopGeneration}
placeholder={t("Ask anything...")}
autofocus={false}
contextPages={contextPages}
onRemoveContextPage={handleRemoveContextPage}
variant="flat"
chatId={chatId}
/>
</div>
</div>
);
}
@@ -1,91 +0,0 @@
import {
IconSparkles,
IconSearch,
IconFilePlus,
IconEdit,
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
type Suggestion = {
icon: React.ReactNode;
text: string;
prompt: string;
};
const SUGGESTIONS: Suggestion[] = [
{
icon: <IconSearch size={16} />,
text: "Search across all pages",
prompt: "Search for pages about ",
},
{
icon: <IconFilePlus size={16} />,
text: "Create a new page",
prompt: "Create a new page titled ",
},
{
icon: <IconFileText size={16} />,
text: "Summarize a page",
prompt: "Summarize the page @",
},
{
icon: <IconEdit size={16} />,
text: "Update page content",
prompt: "Update the page @",
},
];
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
};
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
};
return (
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<h1 className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</h1>
<div className={classes.emptyStateInput}>
<ChatInput
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder={t("Ask anything... Use @ to mention pages")}
autofocus
/>
</div>
<div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
key={s.text}
type="button"
className={classes.suggestionCard}
onClick={() => handleSuggestionClick(s.prompt)}
>
<span className={classes.suggestionIcon}>{s.icon}</span>
<span className={classes.suggestionText}>{s.text}</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -1,424 +0,0 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { CharacterCount } from "@tiptap/extensions";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
import { uploadChatFile } from "../services/ai-chat-service";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/chat-input.module.css";
type PendingAttachment = ChatAttachment & { uploading: boolean };
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
placeholder?: string;
autofocus?: boolean;
contextPages?: PageMention[];
onRemoveContextPage?: (pageId: string) => void;
variant?: "card" | "flat";
showDisclaimer?: boolean;
chatId?: string;
};
function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = [];
const seen = new Set<string>();
function walk(node: any) {
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
if (!seen.has(node.attrs.entityId)) {
seen.add(node.attrs.entityId);
mentions.push({
id: node.attrs.entityId,
title: node.attrs.label || "",
slugId: node.attrs.slugId || "",
});
}
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return mentions;
}
function editorJsonToText(json: any): string {
let text = "";
function walk(node: any) {
if (node.type === "text") {
text += node.text || "";
} else if (node.type === "mention") {
text += `@${node.attrs?.label || ""}`;
} else if (node.type === "paragraph") {
if (text.length > 0) text += "\n";
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
return;
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return text;
}
export default function ChatInput({
isStreaming,
onSend,
onStop,
placeholder,
autofocus = true,
contextPages,
onRemoveContextPage,
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
if (room <= 0) {
notifications.show({
color: "yellow",
message: t("You can attach up to {{max}} files per message.", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
}),
});
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
const incoming = Array.from(files);
const accepted = incoming.slice(0, room);
if (incoming.length > accepted.length) {
notifications.show({
color: "yellow",
message: t(
"Only the first {{n}} file(s) were added (max {{max}} per message).",
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
),
});
}
for (const file of accepted) {
const tempId = `uploading-${Date.now()}-${Math.random()}`;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const placeholder: PendingAttachment = {
id: tempId,
fileName: file.name,
fileExt: ext,
fileSize: file.size,
mimeType: file.type,
uploading: true,
};
setPendingAttachments((prev) => [...prev, placeholder]);
try {
const uploaded = await uploadChatFile(file, chatIdRef.current);
setPendingAttachments((prev) =>
prev.map((a) =>
a.id === tempId ? { ...uploaded, uploading: false } : a,
),
);
} catch {
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [pendingAttachments.length, t]);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
if (!text && readyAttachments.length === 0) return;
const mentions = extractMentions(json);
onSendRef.current(text, mentions, readyAttachments);
editor.commands.clearContent();
editor.commands.focus();
setPendingAttachments([]);
}, [isStreaming, pendingAttachments]);
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const editor = useEditor({
extensions: [
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
}),
CharacterCount.configure({
limit: 50000,
}),
LinkExtension,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
event.key,
)
) {
const emojiCommand = document.querySelector("#emoji-command");
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmitRef.current();
return true;
}
},
},
},
content: "",
editable: true,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
onUpdate: ({ editor: e }) => {
setIsEmpty(!e.getText().trim());
},
});
useEffect(() => {
if (editor && autofocus) {
editor.commands.focus();
}
}, [editor]);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
return (
<>
<div className={wrapperClass} data-chat-input>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
<div className={classes.attachmentChips}>
{contextPages?.map((page) => (
<div key={page.id} className={classes.attachmentChip}>
<IconFileText size={14} />
<span className={classes.attachmentChipName}>
{page.title || "Untitled"}
</span>
{onRemoveContextPage && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => onRemoveContextPage(page.id)}
aria-label={`Remove ${page.title}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
>
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
<IconPhoto size={14} />
) : (
<IconFile size={14} />
)}
<span className={classes.attachmentChipName}>
{attachment.fileName}
</span>
{!attachment.uploading && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => removeAttachment(attachment.id)}
aria-label={`Remove ${attachment.fileName}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
</div>
)}
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover
opened={plusMenuOpen}
onChange={setPlusMenuOpen}
position="top-start"
width={220}
shadow="md"
trapFocus
returnFocus
>
<Popover.Target>
<button
type="button"
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
setPlusMenuOpen(false);
}}
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
title={
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
? t("Max {{max}} files per message", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
})
: undefined
}
>
<IconPaperclip size={16} className={classes.plusMenuIcon} />
{t("Add files")}
</button>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
editor?.commands.focus();
setPlusMenuOpen(false);
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
</button>
</Popover.Dropdown>
</Popover>
<div style={{ flex: 1 }} />
{isStreaming ? (
<button
type="button"
className={classes.stopButton}
onClick={onStop}
aria-label="Stop generation"
>
<IconPlayerStopFilled size={14} />
</button>
) : (
<button
type="button"
className={classes.sendButton}
onClick={handleSubmit}
disabled={!hasContent}
aria-label="Send message"
>
<IconArrowUp size={16} stroke={2.5} />
</button>
)}
</div>
</div>
{showDisclaimer && (
<div className={classes.disclaimer}>
{t("AI-generated content may not be accurate.")}
</div>
)}
</>
);
}
@@ -1,219 +0,0 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
function ChatMessageErrorFallback() {
const { t } = useTranslation();
return (
<div className={classes.messageErrorFallback}>
<IconAlertTriangle size={14} />
<span>{t("Failed to render this message.")}</span>
</div>
);
}
type Props = {
messages: AiChatMessage[];
isStreaming: boolean;
streamingContent: string;
streamingToolCalls: AiChatToolCall[];
};
const BOTTOM_THRESHOLD_PX = 32;
const SCROLL_UP_THRESHOLD_PX = 5;
const SMOOTH_SCROLL_SETTLE_MS = 600;
export default function ChatMessageList({
messages,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const isAutoScrollingRef = useRef(false);
const prevScrollTopRef = useRef(0);
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 container = containerRef.current;
if (!container) return;
isAutoScrollingRef.current = true;
const target = container.scrollHeight - container.clientHeight;
container.scrollTo({ top: target, behavior });
prevScrollTopRef.current = target;
isAtBottomRef.current = true;
setShowScrollButton(false);
if (behavior === "smooth") {
setTimeout(() => {
isAutoScrollingRef.current = false;
if (containerRef.current) {
prevScrollTopRef.current = containerRef.current.scrollTop;
}
}, SMOOTH_SCROLL_SETTLE_MS);
} else {
isAutoScrollingRef.current = false;
}
}, []);
const handleScroll = useCallback(() => {
if (isAutoScrollingRef.current) return;
const container = containerRef.current;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrolledUp =
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
prevScrollTopRef.current = currentScrollTop;
const distanceFromBottom =
container.scrollHeight - currentScrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
if (scrolledUp) {
isAtBottomRef.current = atBottom;
} else if (atBottom) {
isAtBottomRef.current = true;
}
setShowScrollButton(!atBottom);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// Instant scroll during streaming to keep up with rapid updates
useEffect(() => {
if (isAtBottomRef.current) {
scrollToBottom("instant");
}
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
// Smooth scroll for new messages. Always force-scroll when the latest
// message is from the user (they just sent it), even if they were reading
// scrollback.
useEffect(() => {
const lastMessage = messages[messages.length - 1];
const lastIsUser = lastMessage?.role === "user";
if (lastIsUser || isAtBottomRef.current) {
scrollToBottom("smooth");
return;
}
// No auto-scroll: recompute from actual layout so that chat switches to
// content that doesn't overflow correctly hide the button even when no
// scroll event fires.
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, [messages, scrollToBottom]);
return (
<div className={classes.messageListWrapper}>
{/* Single status region for chat announcements. Kept outside the
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) => (
<ErrorBoundary
key={msg.id}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage message={msg} />
</ErrorBoundary>
))}
{isStreaming && (
<ErrorBoundary
resetKeys={[streamingContent, streamingToolCalls.length]}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage
message={{
id: "streaming",
chatId: "",
role: "assistant",
content: null,
toolCalls: null,
metadata: null,
createdAt: new Date().toISOString(),
}}
isStreaming
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</ErrorBoundary>
)}
<div ref={bottomRef} />
</div>
{showScrollButton && (
<button
type="button"
aria-label={t("Scroll to bottom")}
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
<IconArrowDown size={16} stroke={2} />
</button>
)}
</div>
);
}
@@ -1,168 +0,0 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconCheck,
IconCopy,
IconFile,
IconLoader2,
IconPhoto,
} from "@tabler/icons-react";
import { markdownToHtml } from "@docmost/editor-ext";
import { CopyButton } from "@/components/common/copy-button";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName !== "A") return;
const href = node.getAttribute("href") || "";
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
const m = href.match(PAGE_PATH_RE);
if (m) {
node.setAttribute("href", m[0]);
node.removeAttribute("target");
node.removeAttribute("rel");
return;
}
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
});
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
type Props = {
message: AiChatMessage;
isStreaming?: boolean;
streamingContent?: string;
streamingToolCalls?: AiChatToolCall[];
};
export default function ChatMessage({
message,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
e.preventDefault();
navigate(href);
}
},
[navigate],
);
if (message.role === "tool") return null;
const isUser = message.role === "user";
const content = isStreaming ? streamingContent : message.content;
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
if (isUser) {
const displayContent = (content || "").replace(
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
"",
);
const attachments =
(message.metadata?.attachments as {
id: string;
fileName: string;
fileExt: string;
}[]) || [];
return (
<div
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
{attachments.map((a) => (
<span key={a.id} className={classes.messageAttachmentChip}>
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
<IconPhoto size={13} />
) : (
<IconFile size={13} />
)}
{a.fileName}
</span>
))}
</div>
)}
{displayContent}
</div>
</div>
);
}
// 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 (
<div
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
)}
{content && (
<div
onClick={handleContentClick}
dangerouslySetInnerHTML={{
__html: chatSanitizer.sanitize(
markdownToHtml(content) as string,
{ ADD_ATTR: ["target", "rel"] },
),
}}
/>
)}
{isStreaming && (
<>
{!content && (
<span className={classes.processingIndicator}>
<IconLoader2 size={16} className={classes.processingSpinner} />
Thinking
</span>
)}
<span className={classes.streamingCursor} />
</>
)}
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton
text={message?.content}
label={t("Copy assistant response")}
/>
</div>
)}
</div>
);
}
@@ -1,65 +0,0 @@
import { useState } from "react";
import {
IconChevronRight,
IconChevronDown,
IconLoader2,
} from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
import classes from "../styles/chat-message.module.css";
type Props = {
toolCalls: AiChatToolCall[];
isStreaming?: boolean;
};
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
const [expanded, setExpanded] = useState(false);
if (!toolCalls || toolCalls.length === 0) return null;
const activeCall =
isStreaming && toolCalls.length > 0
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
: undefined;
const activeLabel = activeCall
? TOOL_LABELS[activeCall.name] || activeCall.name
: null;
return (
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
) : expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span className={classes.toolGroupLabel}>
{activeLabel ? `${activeLabel}` : `Steps ${toolCalls.length}`}
</span>
</div>
{expanded && (
<div className={classes.toolGroupSteps}>
{toolCalls.map((tc) => (
<ChatToolResult key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
@@ -1,49 +0,0 @@
import { useState } from "react";
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import classes from "../styles/chat-message.module.css";
export const TOOL_LABELS: Record<string, string> = {
list_spaces: "Listed spaces",
search_pages: "Searched pages",
get_page: "Read page",
create_page: "Created page",
update_page: "Updated page",
};
type Props = {
toolCall: AiChatToolCall;
};
export default function ChatToolResult({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
return (
<div className={classes.toolStep}>
<div
className={classes.toolStepRow}
onClick={() => setExpanded((prev) => !prev)}
>
<span className={classes.toolStepBullet}>·</span>
{expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span>{label}</span>
</div>
{expanded && (
<div className={classes.toolStepDetails}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(
{ args: toolCall.args, result: toolCall.result },
null,
2,
)}
</pre>
</div>
)}
</div>
);
}
@@ -1,67 +0,0 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { 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";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiChat() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("AI Chat")}</Text>
<Badge color="gray" variant="light" size="sm" radius="sm">
{t("Beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
)}
</Text>
</div>
<AiChatToggle />
</Group>
);
}
function AiChatToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat")}
/>
</Tooltip>
);
}
@@ -1,227 +0,0 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { sendChatMessage } from "../services/ai-chat-service";
import type {
AiChatMessage,
AiChatStreamEvent,
AiChatToolCall,
ChatAttachment,
PageMention,
} from "../types/ai-chat.types";
type ChatStreamOptions = {
onChatCreated?: (chatId: string) => void;
};
export function useChatStream(
chatId: string | undefined,
options?: ChatStreamOptions,
) {
const [messages, setMessages] = useState<AiChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
[],
);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [isRetryable, setIsRetryable] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const currentChatIdRef = useRef(chatId);
currentChatIdRef.current = chatId;
// Tracks which chatId the local `messages` state currently represents.
// Set when we seed from a server fetch AND when we optimistically own a
// freshly-created chat after `chat_created`. This is the single authority
// marker that keeps server-state effects from clobbering in-flight streams.
const hydratedChatIdRef = useRef<string | undefined>(undefined);
// Reset local state when the consumer switches to a different chat.
// Skip the reset if the new chatId is one the hook itself already claimed
// during a new-chat flow — in that case our optimistic state is the truth.
useEffect(() => {
if (chatId && chatId === hydratedChatIdRef.current) return;
hydratedChatIdRef.current = undefined;
setMessages([]);
setError(null);
setErrorCode(null);
setIsRetryable(false);
}, [chatId]);
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
const forId = currentChatIdRef.current;
if (!forId) return;
if (hydratedChatIdRef.current === forId) return;
hydratedChatIdRef.current = forId;
setMessages(msgs);
}, []);
const sendMessage = useCallback(
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
setError(null);
setErrorCode(null);
setIsRetryable(false);
setIsStreaming(true);
setStreamingContent("");
setStreamingToolCalls([]);
const metadata: Record<string, unknown> = {};
if (mentions.length) {
metadata.mentionedPageIds = mentions.map((m) => m.id);
}
if (attachments.length) {
metadata.attachments = attachments.map((a) => ({
id: a.id,
fileName: a.fileName,
fileExt: a.fileExt,
}));
}
const userMessage: AiChatMessage = {
id: `temp-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "user",
content,
toolCalls: null,
metadata: Object.keys(metadata).length ? metadata : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
const attachmentIds = attachments.map((a) => a.id);
const abortController = sendChatMessage(
{
chatId: currentChatIdRef.current,
content,
mentionedPageIds: mentions.map((m) => m.id),
...(contextPageId && { contextPageId }),
...(attachmentIds.length && { attachmentIds }),
},
(event: AiChatStreamEvent) => {
switch (event.type) {
case "chat_created":
currentChatIdRef.current = event.chatId;
// Claim authority over this new chatId so when the consumer's
// prop catches up via navigation/onChatCreated, the reset effect
// sees a match and preserves our optimistic messages.
hydratedChatIdRef.current = event.chatId;
if (options?.onChatCreated) {
options.onChatCreated(event.chatId);
} else {
navigate(`/ai/chat/${event.chatId}`, { replace: true });
}
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
break;
case "content":
setStreamingContent((prev) => prev + event.text);
break;
case "tool_call":
setStreamingToolCalls((prev) => [
...prev,
{
id: event.id,
name: event.name,
args: event.args,
},
]);
break;
case "tool_result":
setStreamingToolCalls((prev) =>
prev.map((tc) =>
tc.id === event.id ? { ...tc, result: event.result } : tc,
),
);
break;
case "done": {
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
const assistantMessage: AiChatMessage = {
id: event.messageId,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length
? currentToolCalls
: null,
metadata: event.usage ? { tokenUsage: event.usage } : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
return [];
});
return "";
});
setIsStreaming(false);
queryClient.invalidateQueries({
queryKey: ["ai-chat", currentChatIdRef.current],
});
break;
}
case "error":
setError(event.message);
setErrorCode(event.code || null);
setIsRetryable(event.retryable || false);
setIsStreaming(false);
break;
}
},
(errorMsg) => {
setError(errorMsg);
setIsStreaming(false);
},
() => {
setIsStreaming(false);
},
);
abortRef.current = abortController;
},
[isStreaming, navigate, queryClient],
);
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
if (currentContent || currentToolCalls.length > 0) {
const partialMessage: AiChatMessage = {
id: `stopped-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length ? currentToolCalls : null,
metadata: null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, partialMessage]);
}
return [];
});
return "";
});
setIsStreaming(false);
}, []);
return {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
errorCode,
isRetryable,
sendMessage,
stopGeneration,
hydrateFromServer,
};
}
@@ -1,39 +0,0 @@
import { useParams } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import AiChatLayout from "../components/ai-chat-layout";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import classes from "../styles/ai-chat.module.css";
export default function AiChat() {
const { t } = useTranslation();
const { chatId } = useParams<{ chatId: string }>();
return (
<div className={classes.layout}>
<ErrorBoundary
resetKeys={[chatId]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load chat. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
<AiChatLayout />
</ErrorBoundary>
</div>
);
}
@@ -1,61 +0,0 @@
import {
useQuery,
useMutation,
useQueryClient,
useInfiniteQuery,
} from "@tanstack/react-query";
import {
listChats,
getChatInfo,
deleteChat,
updateChatTitle,
searchChats,
} from "../services/ai-chat-service";
export function useChatsQuery() {
return useInfiniteQuery({
queryKey: ["ai-chats"],
queryFn: ({ pageParam }) =>
listChats({ cursor: pageParam, limit: 30 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useChatInfoQuery(chatId: string | undefined) {
return useQuery({
queryKey: ["ai-chat", chatId],
queryFn: () => getChatInfo(chatId!),
enabled: !!chatId,
});
}
export function useDeleteChatMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (chatId: string) => deleteChat(chatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useUpdateChatTitleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
updateChatTitle(chatId, title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useSearchChatsQuery(query: string) {
return useQuery({
queryKey: ["ai-chats-search", query],
queryFn: () => searchChats(query),
enabled: query.length > 0,
});
}
@@ -1,144 +0,0 @@
import api from "@/lib/api-client.ts";
import type {
AiChat,
AiChatMessage,
AiChatStreamEvent,
ChatAttachment,
} from "../types/ai-chat.types";
import { IPagination } from "@/lib/types.ts";
export async function createChat(): Promise<AiChat> {
const req = await api.post<AiChat>("/ai/chats/create");
return req.data;
}
export async function listChats(params?: {
limit?: number;
cursor?: string;
}): Promise<IPagination<AiChat>> {
const req = await api.post("/ai/chats", params);
return req.data;
}
export async function getChatInfo(
chatId: string,
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
const req = await api.post("/ai/chats/info", { chatId });
return req.data;
}
export async function deleteChat(chatId: string): Promise<void> {
await api.post("/ai/chats/delete", { chatId });
}
export async function updateChatTitle(
chatId: string,
title: string,
): Promise<void> {
await api.post("/ai/chats/update", { chatId, title });
}
export async function searchChats(query: string): Promise<AiChat[]> {
const req = await api.post("/ai/chats/search", { query });
return req.data;
}
export async function uploadChatFile(
file: File,
chatId?: string,
): Promise<ChatAttachment> {
const formData = new FormData();
formData.append("file", file);
if (chatId) {
formData.append("chatId", chatId);
}
return await api.post("/ai/chats/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
export function sendChatMessage(
params: {
chatId?: string;
content: string;
mentionedPageIds?: string[];
contextPageId?: string;
attachmentIds?: string[];
},
onEvent: (event: AiChatStreamEvent) => void,
onError?: (error: string) => void,
onComplete?: () => void,
): AbortController {
const abortController = new AbortController();
(async () => {
try {
const response = await fetch("/api/ai/chats/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal: abortController.signal,
credentials: "include",
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = `HTTP error ${response.status}`;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.message || errorMessage;
} catch {
// use default
}
onError?.(errorMessage);
return;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
onError?.("Response body is not readable");
return;
}
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data) as AiChatStreamEvent;
onEvent(parsed);
} catch {
// Skip invalid JSON
}
}
}
}
} finally {
reader.releaseLock();
}
onComplete?.();
} catch (error: any) {
if (error.name !== "AbortError") {
onError?.(error.message);
}
}
})();
return abortController;
}
@@ -1,171 +0,0 @@
.layout {
display: flex;
height: 100%;
width: 100%;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.messageListWrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
width: 100%;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
}
.messageErrorFallback {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
}
.scrollToBottomButton {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.scrollToBottomButton:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.scrollToBottomButton:active {
transform: translateX(-50%) scale(0.95);
}
.inputArea {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
}
/* Empty state - Notion AI style centered layout */
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
}
.emptyStateIcon {
width: 48px;
height: 48px;
margin-bottom: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.emptyStateBrand {
font-size: var(--mantine-font-size-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs);
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
.emptyStateInput {
width: 100%;
max-width: 600px;
margin-bottom: var(--mantine-spacing-xl);
padding: 6px 0;
}
.suggestionsSection {
width: 100%;
max-width: 600px;
}
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: var(--mantine-color-dimmed);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm);
}
.suggestionsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--mantine-spacing-sm);
}
.suggestionCard {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
transition: background-color 150ms, border-color 150ms;
text-align: left;
width: 100%;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.suggestionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-top: 1px;
}
.suggestionText {
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
line-height: 1.4;
}
@@ -1,139 +0,0 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 0 var(--mantine-spacing-sm) 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.toolbarSpacer {
flex: 1;
}
.titleButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
max-width: 60%;
min-width: 0;
}
.titleButton:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.titleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-sm) 0;
scroll-behavior: smooth;
}
.inputArea {
padding-top: var(--mantine-spacing-sm);
}
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
}
.emptyStateIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.emptyStateTitle {
font-size: var(--mantine-font-size-lg);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
text-align: center;
}
.quickActions {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.quickAction {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
text-align: left;
width: 100%;
transition: background-color 150ms, border-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.quickActionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.historyList {
max-height: 300px;
overflow-y: auto;
}
.historyItem {
display: flex;
align-items: center;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&[data-active] {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.historyItemTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,242 +0,0 @@
.inputWrapper {
position: relative;
overflow: hidden;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: light-dark(
0 2px 40px 4px rgba(0, 0, 0, 0.07),
0 2px 40px 4px rgba(0, 0, 0, 0.5)
);
transition:
border-color 150ms,
box-shadow 150ms;
&:focus-within {
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
box-shadow: light-dark(
0 4px 48px 6px rgba(0, 0, 0, 0.09),
0 4px 48px 6px rgba(0, 0, 0, 0.6)
);
}
}
.inputWrapperFlat {
position: relative;
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 12px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: none;
transition: border-color 150ms;
&:focus-within {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.disclaimer {
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
}
.attachmentChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 14px 0;
}
.attachmentChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 8px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-xs);
max-width: 200px;
}
.attachmentChipUploading {
opacity: 0.55;
}
.attachmentChipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentChipRemove {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 0;
margin-left: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
border-radius: 50%;
@mixin hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
}
.editorContent {
overflow: hidden;
:global(.ProseMirror) {
outline: none;
border: none;
background-color: transparent;
padding: 14px 18px 8px;
font-size: 15px;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
min-height: 24px;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
:global(.ProseMirror p) {
margin-block-start: 0;
margin-block-end: 0;
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: var(--mantine-color-placeholder);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 4px 12px 10px;
gap: var(--mantine-spacing-xs);
}
.sendButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: background-color 150ms, opacity 150ms;
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
&:disabled {
opacity: 0.25;
cursor: default;
}
@mixin hover {
&:not(:disabled) {
opacity: 0.85;
}
}
}
.attachButton {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
.plusButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}
.plusMenuItem {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: none;
background: none;
cursor: pointer;
width: 100%;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
border-radius: var(--mantine-radius-sm);
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
background: none;
}
}
.plusMenuIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.stopButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
cursor: pointer;
transition: background-color 150ms;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
@mixin hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -1,286 +0,0 @@
.message {
margin-bottom: var(--mantine-spacing-lg);
}
.userMessage {
composes: message;
display: flex;
justify-content: flex-end;
}
.userBubble {
max-width: 75%;
padding: 10px 16px;
border-radius: 18px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
font-size: 15px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
}
[data-aside-chat] .userBubble {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.userBubble p {
margin: 0;
}
.messageAttachments {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.messageAttachmentChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistantMessage {
composes: message;
}
.messageContent {
font-size: 15px;
line-height: 1.7;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
word-wrap: break-word;
overflow-wrap: break-word;
}
.messageContent p {
margin: 0 0 0.75em 0;
}
.messageContent p:last-child {
margin-bottom: 0;
}
.messageContent ul,
.messageContent ol {
margin: 0.5em 0 0.75em 0;
padding-left: 1.5em;
}
.messageContent li {
margin-bottom: 0.3em;
}
.messageContent h1,
.messageContent h2,
.messageContent h3 {
margin: 1em 0 0.5em 0;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
.messageContent h1 {
font-size: 1.4em;
}
.messageContent h2 {
font-size: 1.2em;
}
.messageContent h3 {
font-size: 1.05em;
}
.messageContent pre {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
overflow-x: auto;
font-size: var(--mantine-font-size-sm);
margin: 0.75em 0;
}
.messageContent code {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
}
.messageContent pre code {
background: none;
padding: 0;
}
.messageContent blockquote {
border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: var(--mantine-spacing-md);
margin: 0.75em 0;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
}
.messageContent a {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
text-decoration: none;
@mixin hover {
text-decoration: underline;
}
}
.messageContent a[href^="/s/"],
.messageContent a[href^="/p/"] {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
font-weight: 500;
text-decoration: none;
cursor: pointer;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
@mixin hover {
text-decoration: none;
@mixin light {
border-bottom-color: var(--mantine-color-dark-2);
}
@mixin dark {
border-bottom-color: var(--mantine-color-dark-0);
}
}
}
.messageContent hr {
border: none;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
margin: 1em 0;
}
.toolGroup {
margin: 6px 0;
font-size: var(--mantine-font-size-xs);
}
.toolGroupHeader {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.4;
transition: color 120ms ease;
}
.toolGroupHeader:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolGroupLabel {
font-weight: 500;
}
.toolGroupSteps {
margin-top: 4px;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.toolStep {
font-size: var(--mantine-font-size-xs);
}
.toolStepRow {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.5;
transition: color 120ms ease;
}
.toolStepRow:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolStepBullet {
display: inline-block;
width: 8px;
text-align: center;
opacity: 0.6;
}
.toolStepDetails {
margin-top: 4px;
margin-left: 18px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
}
.messageActions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.processingIndicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-sm);
}
.processingSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.streamingCursor {
display: inline-block;
width: 2px;
height: 1em;
background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@@ -1,147 +0,0 @@
.sidebar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--mantine-spacing-xs);
}
.title {
margin: 0;
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
.searchInput {
margin-bottom: var(--mantine-spacing-xs);
}
.chatList {
flex: 1;
overflow-y: auto;
}
.chatGroup + .chatGroup {
margin-top: var(--mantine-spacing-sm);
}
.chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: var(--mantine-color-dimmed);
user-select: none;
}
.chatListEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
text-align: center;
gap: 4px;
user-select: none;
}
.chatListEmptyIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
margin-bottom: var(--mantine-spacing-xs);
}
.chatListEmptyTitle {
font-size: var(--mantine-font-size-sm);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.chatListEmptyHint {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
line-height: 1.4;
}
.chatItem {
display: flex;
align-items: center;
padding: 8px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
cursor: pointer;
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
gap: var(--mantine-spacing-xs);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
&[data-active] {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
}
.chatItemTitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatItemDate {
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
white-space: nowrap;
transition: opacity 150ms;
}
.chatItemRenameInput {
font-size: var(--mantine-font-size-sm);
padding: 0;
height: auto;
min-height: 0;
background: transparent;
color: inherit;
}
.chatItem:hover .chatItemDate,
.chatItem:focus-within .chatItemDate {
opacity: 0;
}
.chatItemActions {
position: absolute;
right: var(--mantine-spacing-xs);
opacity: 0;
transition: opacity 150ms;
}
.chatItem {
position: relative;
}
.chatItem:hover .chatItemActions,
.chatItem:focus-within .chatItemActions {
opacity: 1;
}
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@@ -1,49 +0,0 @@
export type AiChat = {
id: string;
workspaceId: string;
creatorId: string;
title: string | null;
createdAt: string;
updatedAt: string;
};
export type AiChatToolCall = {
id: string;
name: string;
args: Record<string, unknown>;
result?: unknown;
};
export type AiChatMessage = {
id: string;
chatId: string;
role: 'user' | 'assistant' | 'tool';
content: string | null;
toolCalls: AiChatToolCall[] | null;
metadata: Record<string, unknown> | null;
createdAt: string;
};
export type AiChatStreamEvent =
| { type: 'chat_created'; chatId: string }
| { type: 'content'; text: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; id: string; result: unknown }
| { type: 'done'; messageId: string; usage?: Record<string, number> }
| { type: 'error'; message: string; code?: string; retryable?: boolean };
export type PageMention = {
id: string;
title: string;
slugId: string;
spaceSlug?: string;
icon?: string;
};
export type ChatAttachment = {
id: string;
fileName: string;
fileExt: string;
fileSize: number;
mimeType: string;
};
@@ -1,45 +0,0 @@
import type { AiChat } from "../types/ai-chat.types";
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
export function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -14,7 +14,7 @@ import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -52,34 +52,16 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
if (!editor || !showAiMenu) return;
const { view } = editor;
const { from, to } = editor.state.selection;
const { to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const fromCoords = view.coordsAtPos(from);
const toCoords = view.coordsAtPos(to);
const cursorCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
const anchorBottom =
toCoords.bottom > 0 && toCoords.bottom < window.innerHeight
? toCoords.bottom
: fromCoords.bottom;
const menuMaxWidth = 600;
const editorLeft = editorRect.left + editorPadding;
const editorRight = editorRect.right - editorPadding;
const availableWidth = editorRight - editorLeft;
const menuWidth = Math.min(menuMaxWidth, availableWidth);
let menuLeft = Math.max(editorLeft, fromCoords.left);
if (menuLeft + menuWidth > editorRight) {
menuLeft = editorRight - menuWidth;
}
menuLeft = Math.max(editorLeft, menuLeft);
setMenuPlacement({
top: anchorBottom + topOffset + window.scrollY,
left: menuLeft + window.scrollX,
width: menuWidth,
top: cursorCoords.bottom + topOffset + window.scrollY,
left: editorRect.left + editorPadding + window.scrollX,
width: editorRect.width - editorPadding * 2,
});
}, [editor, showAiMenu, isSmBreakpoint]);
const resetMenu = useCallback(() => {
@@ -128,7 +110,6 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setPrompt("");
setIsLoading(false);
setActiveCommandSet("result");
},
@@ -165,18 +146,13 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
}
const html = (marked.parse(output) as string).trim();
const isSingleParagraph =
// Strip <p> wrapper for single-paragraph output to preserve inline context
const content =
html.startsWith("<p>") &&
html.endsWith("</p>") &&
html.lastIndexOf("<p>") === 0;
// Strip <p> wrapper for single-paragraph output to preserve inline context,
// then decode HTML entities via DOMParser since TipTap would otherwise
// treat the tagless string as plain text and insert entities literally.
const content = isSingleParagraph
? new DOMParser().parseFromString(html.slice(3, -4), "text/html")
.body.innerHTML
: html;
html.lastIndexOf("<p>") === 0
? html.slice(3, -4)
: html;
chain.insertContent(content).run();
@@ -193,7 +169,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-copy") {
copyToClipboard(output);
navigator.clipboard.writeText(output);
return setShowAiMenu(false);
}
@@ -295,7 +271,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return createPortal(
<div
style={{
zIndex: 199,
zIndex: 200,
position: "absolute",
top: menuPlacement.top,
left: menuPlacement.left,
@@ -1,13 +1,12 @@
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { Group, Text, Switch, MantineSize, Title } 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";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
@@ -38,8 +37,9 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -56,16 +56,14 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
</Tooltip>
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -1,20 +1,17 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { Group, Text, Switch } 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";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useIsCloudEE();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -41,13 +38,11 @@ export default function EnableGenerativeAi() {
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
);
}
@@ -1,156 +0,0 @@
import {
Anchor,
Group,
List,
Text,
Switch,
TextInput,
ActionIcon,
Tooltip,
Stack,
Alert,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Trans, 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";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ mcpEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Stack gap="lg">
{!hasAccess && (
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
{t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Model Context Protocol (MCP)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "}
<Trans
i18nKey="View the <anchor>MCP documentation</anchor>."
components={{
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
}}
/>
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
</Group>
{checked && (
<div>
<Text size="sm" fw={500} mb={4}>
{t("MCP Server URL")}
</Text>
<Group gap="xs">
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
<CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")}
</Text>
<List size="sm" spacing={2}>
<List.Item>
<Text size="sm" c="dimmed" span>
search_pages, get_page, create_page, update_page
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
list_pages, list_child_pages, duplicate_page
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
copy_page_to_space, move_page, move_page_to_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_space, list_spaces, create_space, update_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_comments, create_comment, update_comment
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
search_attachments, list_workspace_members, get_current_user
</Text>
</List.Item>
</List>
</div>
</div>
)}
</Stack>
);
}
+19 -55
View File
@@ -6,80 +6,44 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { Alert, Stack } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai";
const hasAccess = useIsCloudEE();
if (!isAdmin) {
return null;
}
const handleTabChange = (value: string | null) => {
if (value === "mcp") {
navigate("/settings/ai/mcp");
} else {
navigate("/settings/ai");
}
};
return (
<>
<Helmet>
<title>AI settings - {getAppName()}</title>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="ai">
{t("AI")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="mcp">
{t("MCP")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="ai" pt="md">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={upgradeLabel}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
<EnableAiChat />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="mcp" pt="md">
<McpSettings />
</Tabs.Panel>
</Tabs>
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</>
);
}
@@ -31,9 +31,8 @@ export function ApiKeyCreatedModal({
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })}
title={t("API key created")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -42,8 +41,7 @@ export function ApiKeyCreatedModal({
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("API key") },
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
@@ -66,7 +64,7 @@ export function ApiKeyCreatedModal({
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("API key") })}
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
@@ -44,7 +44,7 @@ export function ApiKeyTable({
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -106,11 +106,7 @@ export function ApiKeyTable({
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -1,8 +1,8 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
@@ -36,7 +36,7 @@ export function CreateApiKeyModal({
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
@@ -105,9 +105,8 @@ export function CreateApiKeyModal({
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })}
title={t("Create API Key")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -1,71 +0,0 @@
import { 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";
import { Feature } from "@/ee/features";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true,
);
const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
restrictApiToAdmins: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">
{t("Restrict API key creation to admins")}
</Text>
<Text size="sm" c="dimmed">
{t(
"Only admins and owners can create new API keys. Existing member keys will continue to work.",
)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Tooltip
label={upgradeLabel}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle restrict API keys to admins")}
/>
</Tooltip>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
@@ -30,15 +30,12 @@ export function RevokeApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })}
title={t("Revoke API key")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("API key"),
})}{" "}
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
@@ -1,7 +1,7 @@
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 { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
@@ -27,7 +27,7 @@ export function UpdateApiKeyModal({
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
@@ -53,9 +53,8 @@ export function UpdateApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })}
title={t("Update API key")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -14,9 +13,6 @@ import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -27,11 +23,6 @@ export default function UserApiKeys() {
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
@@ -57,51 +48,11 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("Learn more")}
</Anchor>
</Text>
<Text size="sm" mt={4}>
{t("MCP server URL:")}{" "}
<Text size="sm" fw={500} span ff="monospace">
{`${getAppUrl()}/mcp`}
</Text>
</Text>
</Alert>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
) : null}
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -14,7 +14,6 @@ import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -55,18 +54,10 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<RestrictApiToAdmins />
<Divider my="lg" />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
@@ -63,11 +63,7 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
@@ -1,333 +0,0 @@
import { Fragment, useState } from "react";
import {
Table,
Text,
Group,
Skeleton,
Anchor,
Collapse,
Box,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
IconChevronRight,
IconChevronDown,
IconArrowRight,
} from "@tabler/icons-react";
import { IAuditLog } from "@/ee/audit/types/audit.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import classes from "./audit-logs.module.css";
type AuditLogsTableProps = {
items?: IAuditLog[];
isLoading: boolean;
};
function hasDetails(entry: IAuditLog): boolean {
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
}
function getResourceUrl(entry: IAuditLog): string | null {
if (!entry.resource) return null;
switch (entry.resourceType) {
case "group":
return `/settings/groups/${entry.resource.id}`;
case "space":
case "space_member":
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
default:
return null;
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
const { t } = useTranslation();
if (!changes) return null;
const { before, after } = changes;
const allKeys = new Set([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
]);
if (allKeys.size === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Changes")}
</Text>
{[...allKeys].map((key) => {
const hasBefore = before && key in before;
const hasAfter = after && key in after;
return (
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
<Text
fz="xs"
c="dimmed"
fw={500}
style={{ minWidth: "fit-content" }}
>
{key}:
</Text>
{hasBefore && (
<Text fz="xs" component="span">
{formatValue(before[key])}
</Text>
)}
{hasBefore && hasAfter && (
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
)}
{hasAfter && (
<Text fz="xs" component="span">
{formatValue(after[key])}
</Text>
)}
</Group>
);
})}
</Box>
);
}
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
const { t } = useTranslation();
const entries = Object.entries(metadata);
if (entries.length === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Metadata")}
</Text>
{entries.map(([key, value]) => (
<Group key={key} gap={6} mb={2} wrap="nowrap">
<Text fz="xs" c="dimmed" fw={500}>
{key}:
</Text>
<Text fz="xs">{formatValue(value)}</Text>
</Group>
))}
</Box>
);
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Skeleton circle height={36} />
<div>
<Skeleton height={14} width={120} mb={4} />
<Skeleton height={10} width={160} />
</div>
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
function ResourceCell({ entry }: { entry: IAuditLog }) {
if (!entry.resource?.name) {
return (
<Text fz="sm" c="dimmed">
</Text>
);
}
const url = getResourceUrl(entry);
if (url) {
return (
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={url}
>
<div className={classes.resourceLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.resource.name}
</Text>
</div>
</Anchor>
);
}
return (
<Text fz="sm" lineClamp={1}>
{entry.resource.name}
</Text>
);
}
export default function AuditLogsTable({
items,
isLoading,
}: AuditLogsTableProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<Table.ScrollContainer minWidth={700}>
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Actor")}</Table.Th>
<Table.Th>{t("Event")}</Table.Th>
<Table.Th>{t("Resource")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items && items.length > 0 ? (
items.map((entry) => {
const expandable = hasDetails(entry);
const isExpanded = expanded.has(entry.id);
return (
<Fragment key={entry.id}>
<Table.Tr
onClick={
expandable ? () => toggleExpanded(entry.id) : undefined
}
style={{ cursor: expandable ? "pointer" : undefined }}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{expandable ? (
isExpanded ? (
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
/>
) : (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
/>
)
) : (
<Box w={16} />
)}
{entry.actor ? (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={entry.actor.avatarUrl}
name={entry.actor.name}
size={36}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.actor.name}
</Text>
<Text fz="xs" c="dimmed">
{entry.actor.email}
</Text>
</div>
</Group>
) : (
<Text fz="sm" c="dimmed" fs="italic">
{entry.actorType === "system"
? t("System")
: t("System")}
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
</Table.Td>
<Table.Td>
<ResourceCell entry={entry} />
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(entry.createdAt))}
</Text>
</Table.Td>
</Table.Tr>
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Box
px="md"
py="sm"
className={classes.detailContent}
>
<Group gap="xl" align="flex-start">
{entry.changes && (
<ChangesDiff changes={entry.changes} />
)}
{entry.metadata && (
<MetadataDisplay metadata={entry.metadata} />
)}
</Group>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})
) : (
<NoTableResults colSpan={4} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,33 +0,0 @@
.table {
--table-border-color: var(--mantine-color-gray-2);
@mixin dark {
--table-border-color: var(--mantine-color-dark-5);
}
}
.resourceLinkText {
width: fit-content;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.detailRow {
&:hover {
background: none !important;
}
}
.detailContent {
@mixin light {
background: var(--mantine-color-gray-0);
}
@mixin dark {
background: var(--mantine-color-dark-7);
}
}
@@ -1,196 +0,0 @@
type EventOption = {
value: string;
label: string;
};
type EventGroup = {
group: string;
items: EventOption[];
};
export const auditEventLabels: Record<string, string> = {
"workspace.created": "Created workspace",
"workspace.updated": "Updated workspace",
"workspace.invite_created": "Created invitation",
"workspace.invite_resent": "Resent invitation",
"workspace.invite_revoked": "Revoked invitation",
"user.created": "Created user",
"user.deleted": "Deleted user",
"user.login": "Logged in",
"user.logout": "Logged out",
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.updated": "Updated user",
"user.deactivated": "Deactivated user",
"user.activated": "Activated user",
"user.mfa_enabled": "Enabled MFA",
"user.mfa_disabled": "Disabled MFA",
"user.mfa_backup_code_generated": "Generated MFA backup codes",
"api_key.created": "Created API key",
"api_key.updated": "Updated 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.updated": "Updated space",
"space.deleted": "Deleted space",
"space.member_added": "Added space member",
"space.member_removed": "Removed space member",
"space.member_role_changed": "Changed space member role",
"space.exported": "Exported space",
"group.created": "Created group",
"group.updated": "Updated group",
"group.deleted": "Deleted group",
"group.member_added": "Added group member",
"group.member_removed": "Removed group member",
"comment.deleted": "Deleted comment",
"page.trashed": "Trashed page",
"page.deleted": "Deleted page",
"page.restored": "Restored page",
"page.imported": "Imported page",
"page.exported": "Exported page",
"page.restricted": "Restricted page",
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification",
"page.verified": "Verified page",
"page.approval_requested": "Requested page approval",
"page.approval_rejected": "Rejected page approval",
"page.marked_obsolete": "Marked page as obsolete",
"share.created": "Created share link",
"share.deleted": "Deleted share link",
"sso.provider_created": "Created SSO provider",
"sso.provider_updated": "Updated SSO provider",
"sso.provider_deleted": "Deleted SSO provider",
"license.activated": "Activated license",
"license.removed": "Removed license",
};
export function getEventLabel(event: string): string {
return auditEventLabels[event] ?? event;
}
export const eventFilterOptions: EventGroup[] = [
{
group: "Workspace",
items: [
{ value: "workspace.updated", label: "Updated workspace" },
{ value: "workspace.invite_created", label: "Created invitation" },
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
],
},
{
group: "User",
items: [
{ value: "user.login", label: "Logged in" },
{ value: "user.logout", label: "Logged out" },
{ value: "user.created", label: "Created user" },
{ value: "user.deleted", label: "Deleted user" },
{ value: "user.deactivated", label: "Deactivated user" },
{ value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" },
{ value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" },
],
},
{
group: "Space",
items: [
{ value: "space.created", label: "Created space" },
{ value: "space.updated", label: "Updated space" },
{ value: "space.deleted", label: "Deleted space" },
{ value: "space.member_added", label: "Added space member" },
{ value: "space.member_removed", label: "Removed space member" },
],
},
{
group: "Group",
items: [
{ value: "group.created", label: "Created group" },
{ value: "group.updated", label: "Updated group" },
{ value: "group.deleted", label: "Deleted group" },
{ value: "group.member_added", label: "Added group member" },
{ value: "group.member_removed", label: "Removed group member" },
],
},
{
group: "Comment",
items: [
{ value: "comment.deleted", label: "Deleted comment" },
],
},
{
group: "Page",
items: [
{ value: "page.trashed", label: "Trashed page" },
{ value: "page.deleted", label: "Deleted page" },
{ value: "page.restored", label: "Restored page" },
{ value: "page.imported", label: "Imported page" },
{ value: "page.exported", label: "Exported page" },
{ value: "page.restricted", label: "Restricted page" },
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
{ value: "page.verification_created", label: "Created page verification" },
{ value: "page.verification_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" },
{ value: "page.verified", label: "Verified page" },
{ value: "page.approval_requested", label: "Requested page approval" },
{ value: "page.approval_rejected", label: "Rejected page approval" },
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
],
},
{
group: "Share",
items: [
{ value: "share.created", label: "Created share link" },
{ value: "share.deleted", label: "Deleted share link" },
],
},
{
group: "SSO",
items: [
{ value: "sso.provider_created", label: "Created SSO provider" },
{ value: "sso.provider_updated", label: "Updated SSO provider" },
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
],
},
{
group: "API key",
items: [
{ value: "api_key.created", label: "Created 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",
items: [
{ value: "license.activated", label: "Activated license" },
{ value: "license.removed", label: "Removed license" },
],
},
];
@@ -1,223 +0,0 @@
import { useState, useMemo, useEffect } from "react";
import {
ActionIcon,
Button,
Group,
NumberInput,
Popover,
Select,
Space,
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
useAuditLogsQuery,
useAuditRetentionQuery,
useUpdateAuditRetentionMutation,
} from "@/ee/audit/queries/audit-query";
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = useAuditRetentionQuery();
const updateRetention = useUpdateAuditRetentionMutation();
const currentDays = retentionData?.retentionDays ?? 365;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
useEffect(() => {
if (retentionData) {
const { amount, unit } = daysToRetention(retentionData.retentionDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
}
}, [retentionData?.retentionDays]);
const resetRetentionForm = () => {
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
};
const params: IAuditLogParams = useMemo(
() => ({
cursor,
limit: 50,
event: eventFilter ?? undefined,
}),
[cursor, eventFilter],
);
const { data, isLoading } = useAuditLogsQuery(params);
if (!isOwner) {
return null;
}
const handleEventChange = (value: string | null) => {
setEventFilter(value);
resetCursor();
};
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} />
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -1,51 +0,0 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAuditLogs,
getAuditRetention,
updateAuditRetention,
} from "@/ee/audit/services/audit-service";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useAuditLogsQuery(
params?: IAuditLogParams,
): UseQueryResult<IPagination<IAuditLog>, Error> {
return useQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
placeholderData: keepPreviousData,
});
}
export function useAuditRetentionQuery() {
return useQuery({
queryKey: ["audit-retention"],
queryFn: () => getAuditRetention(),
});
}
export function useUpdateAuditRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { auditRetentionDays: number }) =>
updateAuditRetention(data),
onSuccess: () => {
notifications.show({ message: t("Audit retention updated") });
queryClient.invalidateQueries({ queryKey: ["audit-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -1,22 +0,0 @@
import api from "@/lib/api-client";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
export async function getAuditLogs(
params?: IAuditLogParams,
): Promise<IPagination<IAuditLog>> {
const req = await api.post("/audit", { ...params });
return req.data;
}
export async function getAuditRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention");
return req.data;
}
export async function updateAuditRetention(data: {
auditRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention/update", data);
return req.data;
}
@@ -1,40 +0,0 @@
export type IAuditLog = {
id: string;
workspaceId: string;
actorId?: string;
actorType: string;
event: string;
resourceType: string;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
ipAddress?: string;
createdAt: string;
actor?: {
id: string;
name: string;
email: string;
avatarUrl?: string;
};
resource?: {
id: string;
name: string;
slug?: string;
slugId?: string;
};
};
export type IAuditLogParams = {
event?: string;
resourceType?: string;
actorId?: string;
spaceId?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
};
@@ -5,15 +5,3 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -1,7 +1,6 @@
import {
useMutation,
useQueryClient,
InfiniteData,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
@@ -11,54 +10,41 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
function updateCommentInCache(
cache: InfiniteData<IPagination<IComment>>,
commentId: string,
updater: (comment: IComment) => IComment,
): InfiniteData<IPagination<IComment>> {
return {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === commentId ? updater(comment) : comment,
),
})),
};
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
const cache = previousCache as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? "optimistic" : null,
resolvedBy: variables.resolved
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
: null,
})),
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
);
}
return { previousCache };
return {
...old,
items: updatedItems,
};
});
return { previousComments };
},
onError: (_err, variables, context) => {
if (context?.previousCache) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache);
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
}
notifications.show({
message: t("Failed to resolve comment"),
@@ -66,26 +52,35 @@ export function useResolveCommentMutation() {
});
},
onSuccess: (data: IComment, variables) => {
const cache = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(data.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
});
},
});
@@ -1,6 +1,5 @@
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
Title,
@@ -20,38 +19,23 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }),
});
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
validate: zod4Resolver(formSchema),
validate: zodResolver(formSchema),
initialValues: {
hostname: "",
},
});
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
@@ -69,21 +53,8 @@ export function CloudLoginForm() {
setIsLoading(false);
}
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return (
<AuthLayout>
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
@@ -111,47 +82,15 @@ export function CloudLoginForm() {
{t("Continue")}
</Button>
</form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</Box>
</Container>
<Text ta="center" mb="xl">
<Text ta="center">
{t("Don't have a workspace?")}{" "}
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
{t("Create new workspace")}
</Anchor>
</Text>
</AuthLayout>
</div>
);
}
@@ -1,8 +1,8 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -34,7 +34,7 @@ export function LdapLoginModal({
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zod4Resolver(formSchema),
validate: zodResolver(formSchema),
initialValues: {
username: "",
password: "",
@@ -111,11 +111,6 @@ export function LdapLoginModal({
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
visibilityToggleButtonProps={{
"aria-label": t("Toggle password visibility"),
"aria-hidden": false,
tabIndex: 0,
}}
{...form.getInputProps("password")}
/>

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