Compare commits

..

44 Commits

Author SHA1 Message Date
Philipinho 68871510d8 cleanup 2026-07-17 14:01:39 +01:00
Philipinho 92b895bcbb dev package overides 2026-07-15 17:11:21 +01:00
Philipinho 7a9eb16d77 prod package overrides 2026-07-15 17:10:50 +01:00
Philipinho 698f02e4b8 Node 26; pnpm 11 2026-07-15 16:56:27 +01:00
Philipinho c216a366c5 chore: set minimum age and allowedBuilds 2026-07-15 16:55:10 +01:00
Philipinho a29b3c8615 chore: pnpm 11 2026-07-15 14:24:50 +01:00
Philipinho e28df6ad88 fix 2026-07-04 14:11:44 +01:00
Philip Okugbe 4132dd597c New Crowdin updates (#2323) 2026-07-03 11:57:07 +01:00
Philip Okugbe 8a2c457ab9 feat: kanban group editing (#2322)
* feat: support opening property menu directly on options panel

* refactor: expose group-by property from useKanbanColumns

* feat: rename kanban group by clicking column name

* feat: edit group-by property from kanban column menu

* fix: license-gate base and kanban inserts

* fix: add missing bases strings to en-US translation file
2026-07-03 11:35:26 +01:00
Philipinho 6ff7f69137 fix: file import page title 2026-06-30 02:45:07 +01:00
Philipinho 481aae5507 sync 2026-06-30 02:36:52 +01:00
Philipinho 12d68765d9 v0.95.0 2026-06-30 01:33:34 +01:00
Philipinho 27a80b57f8 sync 2026-06-30 01:32:19 +01:00
Philip Okugbe 15bdf680d9 feat: workspace default edit preference (#2319) 2026-06-30 01:10:51 +01:00
Philipinho 96faff7175 fix: reactive subpage list 2026-06-29 19:58:22 +01:00
Philipinho 2959504470 fix tree merge 2026-06-29 19:02:21 +01:00
Philip Okugbe 37a88d171a New Crowdin updates (#2318) 2026-06-29 19:01:02 +01:00
Philip Okugbe c86d556888 fix: templates ui fixes (#2316)
* fix: editor menus
* fix: templates creation UI gating
2026-06-29 12:32:09 +01:00
Philipinho e8efef235c fix: tooltip on focus 2026-06-29 00:22:59 +01:00
Philipinho 04110884b4 better zip handling 2026-06-28 23:45:38 +01:00
Philipinho b88833277d fix: default radius 2026-06-27 21:25:45 +01:00
Philipinho 186a0bf71b sync 2026-06-27 00:49:39 +01:00
Philipinho 303b6a048f fix: cleanup title 2026-06-27 00:40:19 +01:00
Philipinho 4aa4feb41f fix: validate dto object 2026-06-27 00:15:35 +01:00
Philip Okugbe 4cf47f4538 New Crowdin updates (#2309) 2026-06-26 12:00:29 +01:00
Philipinho ccf61f3f84 fix multi-select 2026-06-26 11:54:21 +01:00
Philipinho d469558c1e sync 2026-06-25 01:39:31 +01:00
Philipinho fca5619eb7 fix: adjust spacing 2026-06-22 00:50:38 +01:00
Philipinho b7f1dd3a6b fix: spacing 2026-06-22 00:50:09 +01:00
Philipinho 5658d30484 sync 2026-06-21 03:09:40 +01:00
Philip Okugbe 89f13c3fbc feat(ee): bases (#2295)
* feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension.

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

* feat(base): add focused-cell atom and cell coordinate types

* feat(base): add cell focus-ring style

* feat(base): add pure next-cell navigation helper

* feat(base): keyboard navigation controller and grid wiring

* update offerings

* feat(base): cell focus ring, click-to-focus, and gridcell ARIA

* feat(base): row ARIA index and selected state

* feat(base): seed editor value on type-to-edit for free-text cells

* feat(base): make column headers keyboard-focusable as tab stops

* fix(base): remove focus outline on grid container

* fix(base): show cell focus ring only while the grid is focused

* feat(base): keyboard-navigate the row-number column for selection

* fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order

* fix(base): tab from long-text editor moves to next cell instead of leaving the table

* fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop

* fix(base): show cell focus ring only while the grid body itself is focused

* fix(base): render view-tab rename as an inline pill so the tab band height stays put

* fix(base): refer to the feature as 'base' rather than 'database'

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

* fix: preserve sidebar title/icon on partial page updates

* fix: skip duplicate row fetch when opening new kanban card

* fix refetch

* fix focus

* fix spacing

* fix(base): select grid cell on mousedown to avoid stale focus ring flash

The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.

* fix: activate New row button via keyboard (Enter/Space)

The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.

* fix(base): keep add-property popover within viewport on mobile

Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.

* fix(base): enable grid cell editing on touch devices

Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.

* feat(editor): add base and kanban inserts to the toolbar

* feat(base): insert row below via Shift+Enter on the primary cell

* fix(base): place caret at end instead of selecting all when editing cells

* fix(base): prevent popover inputs from losing focus on mobile in row detail modal

* fix grid cells on mobile

* sync

* fix: read-only export

* feat(base): add prefixed nanoid id schemas and generators

* feat(base): enforce strict property/choice id validation

* feat(base): make property id varchar with per-base composite pk

* feat(base): pass property id as text to cell extractors

* feat(base): scope property lookups per base and generate property ids in repo

* feat(base): generate status template choice ids as nanoid

* feat(base): generate choice ids as nanoid on the client

* chore(base): seed choice ids with nanoid

* fix(base): mint kanban choice ids as nanoid

* sync

* sync

* sync
2026-06-21 03:07:14 +01:00
Philipinho e0fdc0c178 chore: update dot ignore files 2026-06-21 02:06:39 +01:00
Philipinho e44ce2714f fix: safe editor 2026-06-20 23:12:32 +01:00
Philipinho d99d321e4a update dependencies 2026-06-20 18:24:19 +01:00
Philip Okugbe 4f202e4ab5 feat: package updates (#2300)
* package updates
* update tiptap
* fix editor crash
* fix
* update list
* fix(theme): size badges to their content
* fix mantine avatar color regression
2026-06-20 18:11:14 +01:00
Philipinho d7fdcdec80 sync 2026-06-20 14:34:12 +01:00
Abhigyan Sinha 2dc35fa100 fix: resolve draw.io save failure on copied blocks (#2288) (#2297) 2026-06-20 14:31:16 +01:00
Philip Okugbe 4f2fbf22d4 chore: migrate to Mantine 9 and React 19 (#2293) 2026-06-20 14:28:31 +01:00
Philip Okugbe d68e241f45 feat(ee): personal spaces (#2298)
* feat(ee): personal spaces

* pref

* feat: on-demand only

* error notification
2026-06-20 14:27:41 +01:00
Philip Okugbe 510199cf04 feat(ee): docx word export (#2294)
* vendorize  prosemirror-docx - wip
* feat(ee): docx word export
* sync
2026-06-20 14:23:56 +01:00
Philip Okugbe aa5d52ad3e feat(editor): add /time slash command to insert current time (#2290) 2026-06-20 14:22:54 +01:00
Mayank-2-16 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
Philip Okugbe 6191acfa14 fix: a11y (#2275) 2026-06-09 22:51:55 +01:00
241 changed files with 10193 additions and 12390 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.
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules
.git
dist
**/node_modules
**/.git
**/dist
/data
.env*
.nx
**/.env*
**/.nx
+1 -1
View File
@@ -3,7 +3,7 @@
.env.prod
data
# compiled output
/dist
dist
/node_modules
# Logs
-1
View File
@@ -1 +0,0 @@
shamefully-hoist = true
+2 -3
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
RUN npm install -g pnpm@11.13.0
FROM base AS builder
@@ -34,7 +34,6 @@ COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-f
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
+18 -17
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.1",
"version": "0.95.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -21,20 +21,20 @@
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@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",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
"@mantine/hooks": "9.3.2",
"@mantine/modals": "9.3.2",
"@mantine/notifications": "9.3.2",
"@mantine/spotlight": "9.3.2",
"@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.14.2",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
@@ -43,7 +43,7 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai": "2.20.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
@@ -52,15 +52,16 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"nanoid": "3.3.8",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-dom": "19.2.7",
"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",
"react-router-dom": "7.18.0",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -75,8 +76,8 @@
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
@@ -91,7 +92,7 @@
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite": "8.0.16",
"vitest": "4.1.6"
}
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Dunkel",
"Date": "Datum",
"Delete": "Löschen",
"Remove from page": "Von Seite entfernen",
"Base options": "Basisoptionen",
"Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
@@ -76,6 +78,24 @@
"Failed to import pages": "Import der Seiten fehlgeschlagen",
"Failed to load page. An error occurred.": "Seite konnte nicht geladen werden. Es ist ein Fehler aufgetreten.",
"Failed to update data": "Aktualisierung der Daten fehlgeschlagen",
"Failed to create base": "Basis konnte nicht erstellt werden",
"Failed to update base": "Basis konnte nicht aktualisiert werden",
"Failed to delete base": "Basis konnte nicht gelöscht werden",
"Failed to create property": "Eigenschaft konnte nicht erstellt werden",
"Failed to update property": "Eigenschaft konnte nicht aktualisiert werden",
"Failed to delete property": "Eigenschaft konnte nicht gelöscht werden",
"Failed to reorder property": "Eigenschaft konnte nicht neu angeordnet werden",
"Failed to create view": "Ansicht konnte nicht erstellt werden",
"Failed to update view": "Ansicht konnte nicht aktualisiert werden",
"Failed to delete view": "Ansicht konnte nicht gelöscht werden",
"Failed to create row": "Zeile konnte nicht erstellt werden",
"Failed to update row": "Zeile konnte nicht aktualisiert werden",
"Failed to delete row": "Zeile konnte nicht gelöscht werden",
"Failed to delete rows": "Zeilen konnten nicht gelöscht werden",
"Failed to reorder row": "Zeile konnte nicht neu angeordnet werden",
"Failed to move card": "Karte konnte nicht verschoben werden",
"Failed to add card": "Karte konnte nicht hinzugefügt werden",
"Failed to export CSV": "CSV konnte nicht exportiert werden",
"Favorite spaces": "Favorisierte Bereiche",
"Favorite spaces appear here": "Favorisierte Bereiche werden hier angezeigt",
"Favorites": "Favoriten",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid-Diagramm einfügen",
"Insert and design Drawio diagrams": "Drawio-Diagramme einfügen und gestalten",
"Insert current date": "Aktuelles Datum einfügen",
"Time": "Uhrzeit",
"Insert current time": "Aktuelle Uhrzeit einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrfach",
"Turn into": "In verwandeln",
@@ -424,6 +446,7 @@
"Names do not match": "Namen stimmen nicht überein",
"Today, {{time}}": "Heute, {{time}}",
"Yesterday, {{time}}": "Gestern, {{time}}",
"now": "jetzt",
"Space created successfully": "Bereich erfolgreich erstellt",
"Space updated successfully": "Bereich erfolgreich aktualisiert",
"Space deleted successfully": "Bereich erfolgreich gelöscht",
@@ -596,6 +619,8 @@
"Deleted by": "Gelöscht von",
"Deleted at": "Gelöscht am",
"Preview": "Vorschau",
"Base preview unavailable": "Basisvorschau nicht verfügbar",
"Restore this base to view its contents.": "Stelle diese Basis wieder her, um ihren Inhalt anzuzeigen.",
"Subpages": "Unterseiten",
"Failed to load subpages": "Unterseiten konnten nicht geladen werden",
"No subpages": "Keine Unterseiten",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Sind Sie sicher, dass Sie diese Vorlage löschen möchten?",
"Template scope updated": "Vorlagenbereich aktualisiert",
"Choose which space this template belongs to": "Wählen Sie den Bereich aus, zu dem diese Vorlage gehört",
"Only workspace admins can create global templates.": "Nur Workspace-Admins können globale Vorlagen erstellen.",
"You need edit access to this space to create templates.": "Sie benötigen Bearbeitungszugriff auf diesen Space, um Vorlagen zu erstellen.",
"Scope": "Bereich",
"Select scope": "Bereich auswählen",
"Title": "Titel",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Seiten und Bereiche suchen...",
"No results found": "Keine Ergebnisse gefunden",
"You don't have permission to create pages here": "Sie haben hier keine Berechtigung, Seiten zu erstellen",
"Chat menu": "Chatmenü",
"Chat menu for {{title}}": "Chat-Menü für {{title}}",
"API key menu": "API-Schlüssel-Menü",
"Jump to comment selection": "Zur Kommentarauswahl springen",
"Slash commands": "Slash-Befehle",
@@ -1063,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Seitentitel",
"Page content": "Seiteninhalt",
"Member actions": "Mitgliederaktionen",
"Member actions for {{name}}": "Mitgliederaktionen für {{name}}",
"Toggle password visibility": "Passwortsichtbarkeit umschalten",
"Send comment": "Kommentar senden",
"Token actions": "Token-Aktionen",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"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."
"Allow personal spaces": "Persönliche Bereiche erlauben",
"Members can create their own personal space.": "Mitglieder können ihren eigenen persönlichen Bereich erstellen.",
"Toggle allow personal spaces": "Persönliche Bereiche erlauben umschalten",
"Create personal space": "Persönlichen Bereich erstellen",
"Personal space": "Persönlicher Bereich",
"{{name}}'s space": "Bereich von {{name}}",
"Apply": "Anwenden",
"Cells that aren't already a page reference will be cleared.": "Zellen, die noch keine Seitenreferenz sind, werden geleert.",
"Cells that aren't a valid URL will be cleared.": "Zellen, die keine gültige URL sind, werden geleert.",
"Cells that aren't a valid email address will be cleared.": "Zellen, die keine gültige E-Mail-Adresse sind, werden geleert.",
"Cells that can't be parsed as a date will be cleared.": "Zellen, die nicht als Datum geparst werden können, werden geleert.",
"Cells that can't be parsed as a number will be cleared.": "Zellen, die nicht als Zahl geparst werden können, werden geleert.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Zellen werden umgewandelt (yes/true/1 werden als ausgewählt markiert; alles andere wird als nicht ausgewählt markiert oder geleert).",
"Cells will be reinterpreted under the new type.": "Zellen werden unter dem neuen Typ neu interpretiert.",
"Cells will be replaced with a comma-separated list of file names.": "Zellen werden durch eine kommagetrennte Liste von Dateinamen ersetzt.",
"Cells will be replaced with a comma-separated list of option names.": "Zellen werden durch eine kommagetrennte Liste von Optionsnamen ersetzt.",
"Cells will be replaced with the option name.": "Zellen werden durch den Optionsnamen ersetzt.",
"Cells will be replaced with the page title.": "Zellen werden durch den Seitentitel ersetzt.",
"Cells will be replaced with the person's name.": "Zellen werden durch den Namen der Person ersetzt.",
"Change type": "Typ ändern",
"Change type to {{label}}?": "Typ in {{label}} ändern?",
"Converting…": "Wird umgewandelt…",
"Existing values become single-item lists. No data is lost.": "Vorhandene Werte werden zu Einzellisten. Es gehen keine Daten verloren.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Nur das erste ausgewählte Element pro Zeile wird beibehalten; der Rest wird verworfen.",
"Previous record": "Vorheriger Datensatz",
"Next record": "Nächster Datensatz",
"Record actions": "Datensatzaktionen",
"Delete record": "Datensatz löschen",
"Delete record?": "Datensatz löschen?",
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
"to navigate": "zum Navigieren",
"to close": "zum Schließen",
"Expand row {{number}}": "Zeile {{number}} erweitern",
"Saving…": "Wird gespeichert…",
"Read-only": "Schreibgeschützt",
"Loading…": "Wird geladen…",
"Updated {{when}}": "Aktualisiert {{when}}",
"Add property": "Eigenschaft hinzufügen",
"Create property": "Eigenschaft erstellen",
"Hide properties": "Eigenschaften ausblenden",
"Find a property type": "Einen Eigenschaftstyp finden",
"Properties": "Eigenschaften",
"A property with this name already exists": "Eine Eigenschaft mit diesem Namen existiert bereits",
"Add card": "Karte hinzufügen",
"Add filter": "Filter hinzufügen",
"Add option": "Option hinzufügen",
"Add sort": "Sortierung hinzufügen",
"Add view": "Ansicht hinzufügen",
"All data in this column will be lost.": "Alle Daten in dieser Spalte gehen verloren.",
"Allow multiple people": "Mehrere Personen zulassen",
"Alphabetize": "Alphabetisch sortieren",
"Are you sure you want to delete": "Sind Sie sicher, dass Sie löschen möchten",
"Ascending": "Aufsteigend",
"Base deleted": "Base gelöscht",
"Base name": "Base-Name",
"Base table": "Base-Tabelle",
"Card properties": "Karteneigenschaften",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Zellen, die das Textlimit überschreiten, werden abgeschnitten und der zusätzliche Inhalt geht dauerhaft verloren.",
"Checkbox": "Kontrollkästchen",
"Checked by default": "Standardmäßig aktiviert",
"Choose a property": "Wählen Sie eine Eigenschaft",
"Clear selection": "Auswahl aufheben",
"Column options": "Spaltenoptionen",
"Comma, period": "Komma, Punkt",
"Complete": "Abgeschlossen",
"Contains": "Enthält",
"Copy link to view": "Link zur Ansicht kopieren",
"Create a status property": "Eine Statuseigenschaft erstellen",
"Created at": "Erstellt am",
"Currency": "Währung",
"Decimal places": "Dezimalstellen",
"Default value": "Standardwert",
"Delete property": "Eigenschaft löschen",
"Delete view": "Ansicht löschen",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Absteigend",
"Discard": "Verwerfen",
"Doesn't contain": "Enthält nicht",
"Done": "Fertig",
"Edit formula": "Formel bearbeiten",
"Edit property": "Eigenschaft bearbeiten",
"Export CSV": "CSV exportieren",
"Failed to load base": "Base konnte nicht geladen werden",
"False": "Falsch",
"File": "Datei",
"Filter and sort changes are visible only to you": "Änderungen an Filtern und Sortierungen sind nur für Sie sichtbar",
"Filter by": "Filtern nach",
"Formula": "Formel",
"Get started with": "Erste Schritte mit",
"Greater than": "Größer als",
"Group by": "Gruppieren nach",
"Group this board by a select or status property.": "Gruppieren Sie dieses Board nach einer Auswahl- oder Statuseigenschaft.",
"Hide all": "Alle ausblenden",
"Hide group": "Gruppe ausblenden",
"In Progress": "In Bearbeitung",
"In progress": "In Bearbeitung",
"Include time": "Uhrzeit einschließen",
"Insert a kanban board on this page": "Fügen Sie ein Kanban-Board auf dieser Seite ein",
"Insert an inline base on this page": "Fügen Sie eine Inline-Base auf dieser Seite ein",
"Is": "Ist",
"Is after": "Ist nach",
"Is any of": "Ist eines von",
"Is before": "Ist vor",
"Is empty": "Ist leer",
"Is none of": "Ist keines von",
"Is not": "Ist nicht",
"Is not empty": "Ist nicht leer",
"Is on or after": "Ist am oder nach",
"Is on or before": "Ist am oder vor",
"Is within": "Ist innerhalb von",
"Keep editing": "Weiter bearbeiten",
"Last edited at": "Zuletzt bearbeitet am",
"Last edited by": "Zuletzt bearbeitet von",
"Less than": "Kleiner als",
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
"Local": "Lokal",
"Long text": "Langer Text",
"Moved card to {{column}}": "Karte nach {{column}} verschoben",
"Moved column to {{column}}": "Spalte nach {{column}} verschoben",
"Multi-select": "Mehrfachauswahl",
"New row": "Neue Zeile",
"Next month": "Nächster Monat",
"Next week": "Nächste Woche",
"Next year": "Nächstes Jahr",
"No filters applied": "Keine Filter angewendet",
"No match": "Keine Übereinstimmung",
"No options for this property type": "Keine Optionen für diesen Eigenschaftstyp",
"No sorts applied": "Keine Sortierungen angewendet",
"No value": "Kein Wert",
"None": "Keine",
"Not started": "Nicht begonnen",
"Number": "Zahl",
"One month ago": "Vor einem Monat",
"One month from now": "In einem Monat",
"One week ago": "Vor einer Woche",
"One week from now": "In einer Woche",
"Open as page": "Als Seite öffnen",
"Options": "Optionen",
"Past month": "Letzter Monat",
"Past week": "Letzte Woche",
"Past year": "Letztes Jahr",
"Percent": "Prozent",
"Period, comma": "Punkt, Komma",
"Person": "Person",
"Please enter a valid email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"Progress": "Fortschritt",
"Property options": "Eigenschaftsoptionen",
"Relative": "Relativ",
"Rename property": "Eigenschaft umbenennen",
"Reset": "Zurücksetzen",
"Save for everyone": "Für alle speichern",
"Select": "Auswahl",
"Select a property": "Eigenschaft auswählen",
"Select all loaded rows": "Alle geladenen Zeilen auswählen",
"Show all": "Alle anzeigen",
"Sort": "Sortieren",
"Sort by": "Sortieren nach",
"Space, comma": "Leerzeichen, Komma",
"Space, period": "Leerzeichen, Punkt",
"This board has no grouping property yet.": "Dieses Board hat noch keine Gruppierungseigenschaft.",
"This month": "Diesen Monat",
"This year": "Dieses Jahr",
"Thousands and decimal separators": "Tausender- und Dezimaltrennzeichen",
"Time format": "Zeitformat",
"To Do": "Zu erledigen",
"Tomorrow": "Morgen",
"True": "Wahr",
"Unsaved changes": "Nicht gespeicherte Änderungen",
"Untitled base": "Unbenannte Base",
"URL": "URL",
"Value": "Wert",
"View updated for everyone": "Ansicht für alle aktualisiert",
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"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",
"Failed to create base": "Failed to create base",
"Failed to update base": "Failed to update base",
"Failed to delete base": "Failed to delete base",
"Failed to create property": "Failed to create property",
"Failed to update property": "Failed to update property",
"Failed to delete property": "Failed to delete property",
"Failed to reorder property": "Failed to reorder property",
"Failed to create view": "Failed to create view",
"Failed to update view": "Failed to update view",
"Failed to delete view": "Failed to delete view",
"Failed to create row": "Failed to create row",
"Failed to update row": "Failed to update row",
"Failed to delete row": "Failed to delete row",
"Failed to delete rows": "Failed to delete rows",
"Failed to reorder row": "Failed to reorder row",
"Failed to move card": "Failed to move card",
"Failed to add card": "Failed to add card",
"Failed to export CSV": "Failed to export CSV",
"Favorite spaces": "Favorite spaces",
"Favorite spaces appear here": "Favorite spaces appear here",
"Favorites": "Favorites",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
@@ -597,6 +619,8 @@
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview",
"Base preview unavailable": "Base preview unavailable",
"Restore this base to view its contents.": "Restore this base to view its contents.",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
@@ -966,6 +990,8 @@
"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",
"Only workspace admins can create global templates.": "Only workspace admins can create global templates.",
"You need edit access to this space to create templates.": "You need edit access to this space to create templates.",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
@@ -978,7 +1004,7 @@
"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",
"Chat menu for {{title}}": "Chat menu for {{title}}",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
"Slash commands": "Slash commands",
@@ -1064,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Member actions for {{name}}": "Member actions for {{name}}",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
@@ -1086,6 +1112,12 @@
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Allow personal spaces": "Allow personal spaces",
"Members can create their own personal space.": "Members can create their own personal space.",
"Toggle allow personal spaces": "Toggle allow personal spaces",
"Create personal space": "Create personal space",
"Personal space": "Personal space",
"{{name}}'s space": "{{name}}'s space",
"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.",
@@ -1121,5 +1153,141 @@
"Create property": "Create property",
"Hide properties": "Hide properties",
"Find a property type": "Find a property type",
"Properties": "Properties"
"Properties": "Properties",
"A property with this name already exists": "A property with this name already exists",
"Add card": "Add card",
"Add filter": "Add filter",
"Add option": "Add option",
"Add sort": "Add sort",
"Add view": "Add view",
"All data in this column will be lost.": "All data in this column will be lost.",
"Allow multiple people": "Allow multiple people",
"Alphabetize": "Alphabetize",
"Are you sure you want to delete": "Are you sure you want to delete",
"Ascending": "Ascending",
"Base deleted": "Base deleted",
"Base name": "Base name",
"Base table": "Base table",
"Card properties": "Card properties",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
"Checkbox": "Checkbox",
"Checked by default": "Checked by default",
"Choose a property": "Choose a property",
"Clear selection": "Clear selection",
"Column options": "Column options",
"Comma, period": "Comma, period",
"Complete": "Complete",
"Contains": "Contains",
"Copy link to view": "Copy link to view",
"Create a status property": "Create a status property",
"Created at": "Created at",
"Currency": "Currency",
"Decimal places": "Decimal places",
"Default value": "Default value",
"Delete property": "Delete property",
"Delete view": "Delete view",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descending",
"Discard": "Discard",
"Doesn't contain": "Doesn't contain",
"Done": "Done",
"Edit formula": "Edit formula",
"Edit property": "Edit property",
"Export CSV": "Export CSV",
"Failed to load base": "Failed to load base",
"False": "False",
"File": "File",
"Filter and sort changes are visible only to you": "Filter and sort changes are visible only to you",
"Filter by": "Filter by",
"Formula": "Formula",
"Get started with": "Get started with",
"Greater than": "Greater than",
"Group by": "Group by",
"Group this board by a select or status property.": "Group this board by a select or status property.",
"Hide all": "Hide all",
"Hide group": "Hide group",
"In Progress": "In Progress",
"In progress": "In progress",
"Include time": "Include time",
"Insert a kanban board on this page": "Insert a kanban board on this page",
"Insert an inline base on this page": "Insert an inline base on this page",
"Is": "Is",
"Is after": "Is after",
"Is any of": "Is any of",
"Is before": "Is before",
"Is empty": "Is empty",
"Is none of": "Is none of",
"Is not": "Is not",
"Is not empty": "Is not empty",
"Is on or after": "Is on or after",
"Is on or before": "Is on or before",
"Is within": "Is within",
"Keep editing": "Keep editing",
"Last edited at": "Last edited at",
"Last edited by": "Last edited by",
"Less than": "Less than",
"Link copied to clipboard": "Link copied to clipboard",
"Local": "Local",
"Long text": "Long text",
"Moved card to {{column}}": "Moved card to {{column}}",
"Moved column to {{column}}": "Moved column to {{column}}",
"Multi-select": "Multi-select",
"New row": "New row",
"Next month": "Next month",
"Next week": "Next week",
"Next year": "Next year",
"No filters applied": "No filters applied",
"No match": "No match",
"No options for this property type": "No options for this property type",
"No sorts applied": "No sorts applied",
"No value": "No value",
"None": "None",
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
"One month from now": "One month from now",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
"Options": "Options",
"Past month": "Past month",
"Past week": "Past week",
"Past year": "Past year",
"Percent": "Percent",
"Period, comma": "Period, comma",
"Person": "Person",
"Please enter a valid email": "Please enter a valid email",
"Progress": "Progress",
"Property options": "Property options",
"Relative": "Relative",
"Rename property": "Rename property",
"Reset": "Reset",
"Save for everyone": "Save for everyone",
"Select": "Select",
"Select a property": "Select a property",
"Select all loaded rows": "Select all loaded rows",
"Show all": "Show all",
"Sort": "Sort",
"Sort by": "Sort by",
"Space, comma": "Space, comma",
"Space, period": "Space, period",
"This board has no grouping property yet.": "This board has no grouping property yet.",
"This month": "This month",
"This year": "This year",
"Thousands and decimal separators": "Thousands and decimal separators",
"Time format": "Time format",
"To Do": "To Do",
"Tomorrow": "Tomorrow",
"True": "True",
"Unsaved changes": "Unsaved changes",
"Untitled base": "Untitled base",
"URL": "URL",
"Value": "Value",
"View updated for everyone": "View updated for everyone",
"You have unsaved changes. Do you want to discard them?": "You have unsaved changes. Do you want to discard them?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Oscuro",
"Date": "Fecha",
"Delete": "Eliminar",
"Remove from page": "Quitar de la página",
"Base options": "Opciones de la base",
"Delete group": "Eliminar grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.",
"Description": "Descripción",
@@ -76,6 +78,24 @@
"Failed to import pages": "No se pudieron importar las páginas",
"Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.",
"Failed to update data": "No se pudo actualizar los datos",
"Failed to create base": "No se pudo crear la base",
"Failed to update base": "No se pudo actualizar la base",
"Failed to delete base": "No se pudo eliminar la base",
"Failed to create property": "No se pudo crear la propiedad",
"Failed to update property": "No se pudo actualizar la propiedad",
"Failed to delete property": "No se pudo eliminar la propiedad",
"Failed to reorder property": "No se pudo reordenar la propiedad",
"Failed to create view": "No se pudo crear la vista",
"Failed to update view": "No se pudo actualizar la vista",
"Failed to delete view": "No se pudo eliminar la vista",
"Failed to create row": "No se pudo crear la fila",
"Failed to update row": "No se pudo actualizar la fila",
"Failed to delete row": "No se pudo eliminar la fila",
"Failed to delete rows": "No se pudieron eliminar las filas",
"Failed to reorder row": "No se pudo reordenar la fila",
"Failed to move card": "No se pudo mover la tarjeta",
"Failed to add card": "No se pudo añadir la tarjeta",
"Failed to export CSV": "No se pudo exportar el CSV",
"Favorite spaces": "Espacios favoritos",
"Favorite spaces appear here": "Los espacios favoritos aparecen aquí",
"Favorites": "Favoritos",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insertar diagrama Mermaid",
"Insert and design Drawio diagrams": "Insertar y diseñar diagramas de Drawio",
"Insert current date": "Insertar fecha actual",
"Time": "Hora",
"Insert current time": "Insertar hora actual",
"Draw and sketch excalidraw diagrams": "Dibujar y crear bocetos de diagramas de Excalidraw",
"Multiple": "Múltiple",
"Turn into": "Convertir en",
@@ -424,6 +446,7 @@
"Names do not match": "Los nombres no coinciden",
"Today, {{time}}": "Hoy, {{time}}",
"Yesterday, {{time}}": "Ayer, {{time}}",
"now": "ahora",
"Space created successfully": "Espacio creado correctamente",
"Space updated successfully": "Espacio actualizado correctamente",
"Space deleted successfully": "Espacio eliminado correctamente",
@@ -596,6 +619,8 @@
"Deleted by": "Eliminado por",
"Deleted at": "Eliminado el",
"Preview": "Vista previa",
"Base preview unavailable": "Vista previa de la base no disponible",
"Restore this base to view its contents.": "Restaura esta base para ver su contenido.",
"Subpages": "Subpáginas",
"Failed to load subpages": "No se pudieron cargar las subpáginas",
"No subpages": "No hay subpáginas",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "¿Seguro que quieres eliminar esta plantilla?",
"Template scope updated": "Alcance de la plantilla actualizado",
"Choose which space this template belongs to": "Elige a qué espacio pertenece esta plantilla",
"Only workspace admins can create global templates.": "Solo los administradores del espacio de trabajo pueden crear plantillas globales.",
"You need edit access to this space to create templates.": "Necesitas acceso de edición a este espacio para crear plantillas.",
"Scope": "Alcance",
"Select scope": "Seleccionar alcance",
"Title": "Título",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Buscar páginas y espacios...",
"No results found": "No se encontraron resultados",
"You don't have permission to create pages here": "No tienes permiso para crear páginas aquí",
"Chat menu": "Menú del chat",
"Chat menu for {{title}}": "Menú del chat de {{title}}",
"API key menu": "Menú de la clave API",
"Jump to comment selection": "Ir a la selección de comentarios",
"Slash commands": "Comandos de barra",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrar",
"Page title": "Título de la página",
"Page content": "Contenido de la página",
"Member actions": "Acciones de miembro",
"Member actions for {{name}}": "Acciones de miembro para {{name}}",
"Toggle password visibility": "Alternar visibilidad de la contraseña",
"Send comment": "Enviar comentario",
"Token actions": "Acciones de token",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
"Page menu for {{name}}": "Menú de página para {{name}}",
"Create subpage of {{name}}": "Crear 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."
"Allow personal spaces": "Permitir espacios personales",
"Members can create their own personal space.": "Los miembros pueden crear su propio espacio personal.",
"Toggle allow personal spaces": "Alternar permitir espacios personales",
"Create personal space": "Crear espacio personal",
"Personal space": "Espacio personal",
"{{name}}'s space": "Espacio de {{name}}",
"Apply": "Aplicar",
"Cells that aren't already a page reference will be cleared.": "Las celdas que aún no sean una referencia de página se borrarán.",
"Cells that aren't a valid URL will be cleared.": "Las celdas que no sean una URL válida se borrarán.",
"Cells that aren't a valid email address will be cleared.": "Las celdas que no sean una dirección de correo electrónico válida se borrarán.",
"Cells that can't be parsed as a date will be cleared.": "Las celdas que no puedan interpretarse como una fecha se borrarán.",
"Cells that can't be parsed as a number will be cleared.": "Las celdas que no puedan interpretarse como un número se borrarán.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Las celdas se convertirán de forma forzada (yes/true/1 pasarán a estar marcadas; todo lo demás pasará a estar desmarcado o se borrará).",
"Cells will be reinterpreted under the new type.": "Las celdas se reinterpretarán según el nuevo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "Las celdas se reemplazarán por una lista de nombres de archivo separados por comas.",
"Cells will be replaced with a comma-separated list of option names.": "Las celdas se reemplazarán por una lista de nombres de opciones separados por comas.",
"Cells will be replaced with the option name.": "Las celdas se reemplazarán por el nombre de la opción.",
"Cells will be replaced with the page title.": "Las celdas se reemplazarán por el título de la página.",
"Cells will be replaced with the person's name.": "Las celdas se reemplazarán por el nombre de la persona.",
"Change type": "Cambiar tipo",
"Change type to {{label}}?": "¿Cambiar el tipo a {{label}}?",
"Converting…": "Convirtiendo…",
"Existing values become single-item lists. No data is lost.": "Los valores existentes se convierten en listas de un solo elemento. No se pierde ningún dato.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Solo se conservará el primer elemento seleccionado por fila; el resto se descartará.",
"Previous record": "Registro anterior",
"Next record": "Siguiente registro",
"Record actions": "Acciones del registro",
"Delete record": "Eliminar registro",
"Delete record?": "¿Eliminar registro?",
"This action cannot be undone.": "Esta acción no se puede deshacer.",
"to navigate": "para navegar",
"to close": "para cerrar",
"Expand row {{number}}": "Expandir fila {{number}}",
"Saving…": "Guardando…",
"Read-only": "Solo lectura",
"Loading…": "Cargando…",
"Updated {{when}}": "Actualizado {{when}}",
"Add property": "Añadir propiedad",
"Create property": "Crear propiedad",
"Hide properties": "Ocultar propiedades",
"Find a property type": "Buscar un tipo de propiedad",
"Properties": "Propiedades",
"A property with this name already exists": "Ya existe una propiedad con este nombre",
"Add card": "Agregar tarjeta",
"Add filter": "Agregar filtro",
"Add option": "Agregar opción",
"Add sort": "Agregar orden",
"Add view": "Agregar vista",
"All data in this column will be lost.": "Todos los datos de esta columna se perderán.",
"Allow multiple people": "Permitir varias personas",
"Alphabetize": "Ordenar alfabéticamente",
"Are you sure you want to delete": "¿Está seguro de que desea eliminar",
"Ascending": "Ascendente",
"Base deleted": "Base eliminada",
"Base name": "Nombre de la base",
"Base table": "Tabla base",
"Card properties": "Propiedades de la tarjeta",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Las celdas más largas que el límite de texto se truncarán y el contenido adicional se perderá permanentemente.",
"Checkbox": "Casilla de verificación",
"Checked by default": "Marcado por defecto",
"Choose a property": "Seleccione una propiedad",
"Clear selection": "Borrar selección",
"Column options": "Opciones de columna",
"Comma, period": "Coma, punto",
"Complete": "Completo",
"Contains": "Contiene",
"Copy link to view": "Copiar enlace a la vista",
"Create a status property": "Crear una propiedad de estado",
"Created at": "Creado el",
"Currency": "Moneda",
"Decimal places": "Decimales",
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
"Done": "Hecho",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propiedad",
"Export CSV": "Exportar CSV",
"Failed to load base": "No se pudo cargar la base",
"False": "Falso",
"File": "Archivo",
"Filter and sort changes are visible only to you": "Los cambios de filtro y ordenación solo son visibles para usted",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Comenzar con",
"Greater than": "Mayor que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este tablero por una propiedad de selección o estado.",
"Hide all": "Ocultar todo",
"Hide group": "Ocultar grupo",
"In Progress": "En curso",
"In progress": "En curso",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Insertar un tablero kanban en esta página",
"Insert an inline base on this page": "Insertar una base integrada en esta página",
"Is": "Es",
"Is after": "Es posterior a",
"Is any of": "Es cualquiera de",
"Is before": "Es anterior a",
"Is empty": "Está vacío",
"Is none of": "No es ninguno de",
"Is not": "No es",
"Is not empty": "No está vacío",
"Is on or after": "Es en o después de",
"Is on or before": "Es en o antes de",
"Is within": "Está dentro de",
"Keep editing": "Seguir editando",
"Last edited at": "Última edición el",
"Last edited by": "Última edición por",
"Less than": "Menor que",
"Link copied to clipboard": "Enlace copiado al portapapeles",
"Local": "Local",
"Long text": "Texto largo",
"Moved card to {{column}}": "Tarjeta movida a {{column}}",
"Moved column to {{column}}": "Columna movida a {{column}}",
"Multi-select": "Selección múltiple",
"New row": "Nueva fila",
"Next month": "Próximo mes",
"Next week": "Próxima semana",
"Next year": "Próximo año",
"No filters applied": "No hay filtros aplicados",
"No match": "Sin coincidencias",
"No options for this property type": "No hay opciones para este tipo de propiedad",
"No sorts applied": "No hay ordenaciones aplicadas",
"No value": "Sin valor",
"None": "Ninguno",
"Not started": "No iniciado",
"Number": "Número",
"One month ago": "Hace un mes",
"One month from now": "Dentro de un mes",
"One week ago": "Hace una semana",
"One week from now": "Dentro de una semana",
"Open as page": "Abrir como página",
"Options": "Opciones",
"Past month": "Último mes",
"Past week": "Última semana",
"Past year": "Último año",
"Percent": "Porcentaje",
"Period, comma": "Punto, coma",
"Person": "Persona",
"Please enter a valid email": "Por favor, introduzca un correo electrónico válido",
"Progress": "Progreso",
"Property options": "Opciones de propiedad",
"Relative": "Relativo",
"Rename property": "Renombrar propiedad",
"Reset": "Restablecer",
"Save for everyone": "Guardar para todos",
"Select": "Selección",
"Select a property": "Seleccionar una propiedad",
"Select all loaded rows": "Seleccionar todas las filas cargadas",
"Show all": "Mostrar todo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espacio, coma",
"Space, period": "Espacio, punto",
"This board has no grouping property yet.": "Este tablero aún no tiene una propiedad de agrupación.",
"This month": "Este mes",
"This year": "Este año",
"Thousands and decimal separators": "Separadores de miles y decimales",
"Time format": "Formato de hora",
"To Do": "Por hacer",
"Tomorrow": "Mañana",
"True": "Verdadero",
"Unsaved changes": "Cambios no guardados",
"Untitled base": "Base sin título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Sombre",
"Date": "Date",
"Delete": "Supprimer",
"Remove from page": "Supprimer de la page",
"Base options": "Options de la base",
"Delete group": "Supprimer groupe",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"Failed to import pages": "Échec de l'importation des pages",
"Failed to load page. An error occurred.": "Échec du chargement de la page. Une erreur s'est produite.",
"Failed to update data": "Échec de la mise à jour des données",
"Failed to create base": "Échec de la création de la base",
"Failed to update base": "Échec de la mise à jour de la base",
"Failed to delete base": "Échec de la suppression de la base",
"Failed to create property": "Échec de la création de la propriété",
"Failed to update property": "Échec de la mise à jour de la propriété",
"Failed to delete property": "Échec de la suppression de la propriété",
"Failed to reorder property": "Échec de la réorganisation de la propriété",
"Failed to create view": "Échec de la création de la vue",
"Failed to update view": "Échec de la mise à jour de la vue",
"Failed to delete view": "Échec de la suppression de la vue",
"Failed to create row": "Échec de la création de la ligne",
"Failed to update row": "Échec de la mise à jour de la ligne",
"Failed to delete row": "Échec de la suppression de la ligne",
"Failed to delete rows": "Échec de la suppression des lignes",
"Failed to reorder row": "Échec de la réorganisation de la ligne",
"Failed to move card": "Échec du déplacement de la carte",
"Failed to add card": "Échec de l'ajout de la carte",
"Failed to export CSV": "Échec de l'exportation du CSV",
"Favorite spaces": "Espaces favoris",
"Favorite spaces appear here": "Les espaces favoris apparaissent ici",
"Favorites": "Favoris",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insérer un diagramme Mermaid",
"Insert and design Drawio diagrams": "Insérer et concevoir des diagrammes Drawio",
"Insert current date": "Insérer la date actuelle",
"Time": "Heure",
"Insert current time": "Insérer l'heure actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple",
"Turn into": "Transformer en",
@@ -424,6 +446,7 @@
"Names do not match": "Les noms ne correspondent pas",
"Today, {{time}}": "Aujourdhui, {{time}}",
"Yesterday, {{time}}": "Hier, {{time}}",
"now": "maintenant",
"Space created successfully": "Espace créé avec succès",
"Space updated successfully": "Espace mis à jour avec succès",
"Space deleted successfully": "Espace supprimé avec succès",
@@ -596,6 +619,8 @@
"Deleted by": "Supprimé par",
"Deleted at": "Supprimé le",
"Preview": "Aperçu",
"Base preview unavailable": "Aperçu de la base indisponible",
"Restore this base to view its contents.": "Restaurez cette base pour afficher son contenu.",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"No subpages": "Aucune sous-page",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Êtes-vous sûr de vouloir supprimer ce modèle ?",
"Template scope updated": "Portée du modèle mise à jour",
"Choose which space this template belongs to": "Choisissez à quel espace appartient ce modèle",
"Only workspace admins can create global templates.": "Seuls les administrateurs de lespace de travail peuvent créer des modèles globaux.",
"You need edit access to this space to create templates.": "Vous devez disposer dun accès en modification à cet espace pour créer des modèles.",
"Scope": "Portée",
"Select scope": "Sélectionner la portée",
"Title": "Titre",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Rechercher des pages et des espaces...",
"No results found": "Aucun résultat trouvé",
"You don't have permission to create pages here": "Vous navez pas lautorisation de créer des pages ici",
"Chat menu": "Menu du chat",
"Chat menu for {{title}}": "Menu de discussion pour {{title}}",
"API key menu": "Menu de la clé API",
"Jump to comment selection": "Aller à la sélection de commentaires",
"Slash commands": "Commandes slash",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrer",
"Page title": "Titre de la page",
"Page content": "Contenu de la page",
"Member actions": "Actions des membres",
"Member actions for {{name}}": "Actions du membre pour {{name}}",
"Toggle password visibility": "Afficher/masquer le mot de passe",
"Send comment": "Envoyer le commentaire",
"Token actions": "Actions du jeton",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
"Page menu for {{name}}": "Menu de la page pour {{name}}",
"Create subpage of {{name}}": "Créer une sous-page 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."
"Allow personal spaces": "Autoriser les espaces personnels",
"Members can create their own personal space.": "Les membres peuvent créer leur propre espace personnel.",
"Toggle allow personal spaces": "Activer/désactiver l'autorisation des espaces personnels",
"Create personal space": "Créer un espace personnel",
"Personal space": "Espace personnel",
"{{name}}'s space": "Espace de {{name}}",
"Apply": "Appliquer",
"Cells that aren't already a page reference will be cleared.": "Les cellules qui ne sont pas déjà une référence de page seront effacées.",
"Cells that aren't a valid URL will be cleared.": "Les cellules qui ne sont pas une URL valide seront effacées.",
"Cells that aren't a valid email address will be cleared.": "Les cellules qui ne sont pas une adresse e-mail valide seront effacées.",
"Cells that can't be parsed as a date will be cleared.": "Les cellules qui ne peuvent pas être interprétées comme une date seront effacées.",
"Cells that can't be parsed as a number will be cleared.": "Les cellules qui ne peuvent pas être interprétées comme un nombre seront effacées.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Les cellules seront converties (yes/true/1 deviendront cochées ; tout le reste deviendra décoché ou sera effacé).",
"Cells will be reinterpreted under the new type.": "Les cellules seront réinterprétées selon le nouveau type.",
"Cells will be replaced with a comma-separated list of file names.": "Les cellules seront remplacées par une liste de noms de fichiers séparés par des virgules.",
"Cells will be replaced with a comma-separated list of option names.": "Les cellules seront remplacées par une liste de noms d'options séparés par des virgules.",
"Cells will be replaced with the option name.": "Les cellules seront remplacées par le nom de l'option.",
"Cells will be replaced with the page title.": "Les cellules seront remplacées par le titre de la page.",
"Cells will be replaced with the person's name.": "Les cellules seront remplacées par le nom de la personne.",
"Change type": "Changer le type",
"Change type to {{label}}?": "Changer le type en {{label}} ?",
"Converting…": "Conversion…",
"Existing values become single-item lists. No data is lost.": "Les valeurs existantes deviennent des listes à un seul élément. Aucune donnée n'est perdue.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Seul le premier élément sélectionné par ligne sera conservé ; les autres seront ignorés.",
"Previous record": "Enregistrement précédent",
"Next record": "Enregistrement suivant",
"Record actions": "Actions de l'enregistrement",
"Delete record": "Supprimer l'enregistrement",
"Delete record?": "Supprimer l'enregistrement ?",
"This action cannot be undone.": "Cette action ne peut pas être annulée.",
"to navigate": "pour naviguer",
"to close": "pour fermer",
"Expand row {{number}}": "Développer la ligne {{number}}",
"Saving…": "Enregistrement…",
"Read-only": "Lecture seule",
"Loading…": "Chargement…",
"Updated {{when}}": "Mis à jour {{when}}",
"Add property": "Ajouter une propriété",
"Create property": "Créer une propriété",
"Hide properties": "Masquer les propriétés",
"Find a property type": "Rechercher un type de propriété",
"Properties": "Propriétés",
"A property with this name already exists": "Une propriété portant ce nom existe déjà",
"Add card": "Ajouter une carte",
"Add filter": "Ajouter un filtre",
"Add option": "Ajouter une option",
"Add sort": "Ajouter un tri",
"Add view": "Ajouter une vue",
"All data in this column will be lost.": "Toutes les données de cette colonne seront perdues.",
"Allow multiple people": "Autoriser plusieurs personnes",
"Alphabetize": "Trier par ordre alphabétique",
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
"Ascending": "Croissant",
"Base deleted": "Base supprimée",
"Base name": "Nom de la base",
"Base table": "Table de base",
"Card properties": "Propriétés de la carte",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Les cellules dépassant la limite de texte seront tronquées et le contenu supplémentaire sera définitivement perdu.",
"Checkbox": "Case à cocher",
"Checked by default": "Coché par défaut",
"Choose a property": "Choisir une propriété",
"Clear selection": "Effacer la sélection",
"Column options": "Options de la colonne",
"Comma, period": "Virgule, point",
"Complete": "Terminé",
"Contains": "Contient",
"Copy link to view": "Copier le lien vers la vue",
"Create a status property": "Créer une propriété de statut",
"Created at": "Créé le",
"Currency": "Devise",
"Decimal places": "Décimales",
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
"Done": "Terminé",
"Edit formula": "Modifier la formule",
"Edit property": "Modifier la propriété",
"Export CSV": "Exporter en CSV",
"Failed to load base": "Échec du chargement de la base",
"False": "Faux",
"File": "Fichier",
"Filter and sort changes are visible only to you": "Les modifications de filtre et de tri ne sont visibles que par vous",
"Filter by": "Filtrer par",
"Formula": "Formule",
"Get started with": "Commencer avec",
"Greater than": "Supérieur à",
"Group by": "Grouper par",
"Group this board by a select or status property.": "Groupez ce tableau par une propriété de sélection ou de statut.",
"Hide all": "Tout masquer",
"Hide group": "Masquer le groupe",
"In Progress": "En cours",
"In progress": "En cours",
"Include time": "Inclure l'heure",
"Insert a kanban board on this page": "Insérer un tableau kanban sur cette page",
"Insert an inline base on this page": "Insérer une base intégrée sur cette page",
"Is": "Est",
"Is after": "Est après",
"Is any of": "Est l'un de",
"Is before": "Est avant",
"Is empty": "Est vide",
"Is none of": "N'est aucun de",
"Is not": "N'est pas",
"Is not empty": "N'est pas vide",
"Is on or after": "Est le ou après le",
"Is on or before": "Est le ou avant le",
"Is within": "Est dans la plage",
"Keep editing": "Continuer la modification",
"Last edited at": "Dernière modification le",
"Last edited by": "Dernière modification par",
"Less than": "Inférieur à",
"Link copied to clipboard": "Lien copié dans le presse-papiers",
"Local": "Local",
"Long text": "Texte long",
"Moved card to {{column}}": "Carte déplacée vers {{column}}",
"Moved column to {{column}}": "Colonne déplacée vers {{column}}",
"Multi-select": "Sélection multiple",
"New row": "Nouvelle ligne",
"Next month": "Le mois prochain",
"Next week": "La semaine prochaine",
"Next year": "L'année prochaine",
"No filters applied": "Aucun filtre appliqué",
"No match": "Aucune correspondance",
"No options for this property type": "Aucune option pour ce type de propriété",
"No sorts applied": "Aucun tri appliqué",
"No value": "Aucune valeur",
"None": "Aucun",
"Not started": "Non commencé",
"Number": "Nombre",
"One month ago": "Il y a un mois",
"One month from now": "Dans un mois",
"One week ago": "Il y a une semaine",
"One week from now": "Dans une semaine",
"Open as page": "Ouvrir comme page",
"Options": "Options",
"Past month": "Le mois dernier",
"Past week": "La semaine dernière",
"Past year": "L'année dernière",
"Percent": "Pourcentage",
"Period, comma": "Point, virgule",
"Person": "Personne",
"Please enter a valid email": "Veuillez entrer une adresse e-mail valide",
"Progress": "Progression",
"Property options": "Options de la propriété",
"Relative": "Relatif",
"Rename property": "Renommer la propriété",
"Reset": "Réinitialiser",
"Save for everyone": "Enregistrer pour tout le monde",
"Select": "Sélection",
"Select a property": "Sélectionner une propriété",
"Select all loaded rows": "Sélectionner toutes les lignes chargées",
"Show all": "Tout afficher",
"Sort": "Trier",
"Sort by": "Trier par",
"Space, comma": "Espace, virgule",
"Space, period": "Espace, point",
"This board has no grouping property yet.": "Ce tableau n'a pas encore de propriété de groupement.",
"This month": "Ce mois-ci",
"This year": "Cette année",
"Thousands and decimal separators": "Séparateurs des milliers et des décimales",
"Time format": "Format de l'heure",
"To Do": "À faire",
"Tomorrow": "Demain",
"True": "Vrai",
"Unsaved changes": "Modifications non enregistrées",
"Untitled base": "Base sans titre",
"URL": "URL",
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Scuro",
"Date": "Data",
"Delete": "Elimina",
"Remove from page": "Rimuovi dalla pagina",
"Base options": "Opzioni della base",
"Delete group": "Elimina gruppo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
"Description": "Descrizione",
@@ -76,6 +78,24 @@
"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",
"Failed to create base": "Impossibile creare la base",
"Failed to update base": "Impossibile aggiornare la base",
"Failed to delete base": "Impossibile eliminare la base",
"Failed to create property": "Impossibile creare la proprietà",
"Failed to update property": "Impossibile aggiornare la proprietà",
"Failed to delete property": "Impossibile eliminare la proprietà",
"Failed to reorder property": "Impossibile riordinare la proprietà",
"Failed to create view": "Impossibile creare la vista",
"Failed to update view": "Impossibile aggiornare la vista",
"Failed to delete view": "Impossibile eliminare la vista",
"Failed to create row": "Impossibile creare la riga",
"Failed to update row": "Impossibile aggiornare la riga",
"Failed to delete row": "Impossibile eliminare la riga",
"Failed to delete rows": "Impossibile eliminare le righe",
"Failed to reorder row": "Impossibile riordinare la riga",
"Failed to move card": "Impossibile spostare la scheda",
"Failed to add card": "Impossibile aggiungere la scheda",
"Failed to export CSV": "Impossibile esportare il CSV",
"Favorite spaces": "Spazi preferiti",
"Favorite spaces appear here": "Gli spazi preferiti appariranno qui",
"Favorites": "Preferiti",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Inserisci diagramma Mermaid",
"Insert and design Drawio diagrams": "Inserisci e progetta diagrammi Drawio",
"Insert current date": "Inserisci data corrente",
"Time": "Ora",
"Insert current time": "Inserisci ora corrente",
"Draw and sketch excalidraw diagrams": "Disegna e abbozza diagrammi Excalidraw",
"Multiple": "Multiplo",
"Turn into": "Trasforma in",
@@ -424,6 +446,7 @@
"Names do not match": "I nomi non corrispondono",
"Today, {{time}}": "Oggi, {{time}}",
"Yesterday, {{time}}": "Ieri, {{time}}",
"now": "ora",
"Space created successfully": "Spazio creato con successo",
"Space updated successfully": "Spazio aggiornato con successo",
"Space deleted successfully": "Spazio eliminato con successo",
@@ -596,6 +619,8 @@
"Deleted by": "Eliminato da",
"Deleted at": "Eliminato il",
"Preview": "Anteprima",
"Base preview unavailable": "Anteprima della base non disponibile",
"Restore this base to view its contents.": "Ripristina questa base per visualizzarne i contenuti.",
"Subpages": "Sottopagine",
"Failed to load subpages": "Caricamento delle sottopagine non riuscito",
"No subpages": "Nessuna sottopagina",
@@ -965,6 +990,8 @@
"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",
"Only workspace admins can create global templates.": "Solo gli amministratori dell'area di lavoro possono creare modelli globali.",
"You need edit access to this space to create templates.": "Hai bisogno dell'accesso in modifica a questo spazio per creare modelli.",
"Scope": "Ambito",
"Select scope": "Seleziona ambito",
"Title": "Titolo",
@@ -977,7 +1004,7 @@
"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",
"Chat menu for {{title}}": "Menu chat per {{title}}",
"API key menu": "Menu della chiave API",
"Jump to comment selection": "Vai alla selezione dei commenti",
"Slash commands": "Comandi slash",
@@ -1063,7 +1090,7 @@
"Filter": "Filtro",
"Page title": "Titolo pagina",
"Page content": "Contenuto della pagina",
"Member actions": "Azioni membro",
"Member actions for {{name}}": "Azioni membro per {{name}}",
"Toggle password visibility": "Attiva/disattiva visibilità password",
"Send comment": "Invia commento",
"Token actions": "Azioni token",
@@ -1085,22 +1112,182 @@
"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."
"Allow personal spaces": "Consenti spazi personali",
"Members can create their own personal space.": "I membri possono creare il proprio spazio personale.",
"Toggle allow personal spaces": "Attiva/disattiva spazi personali",
"Create personal space": "Crea spazio personale",
"Personal space": "Spazio personale",
"{{name}}'s space": "Spazio di {{name}}",
"Apply": "Applica",
"Cells that aren't already a page reference will be cleared.": "Le celle che non sono già un riferimento a una pagina verranno svuotate.",
"Cells that aren't a valid URL will be cleared.": "Le celle che non contengono un URL valido verranno svuotate.",
"Cells that aren't a valid email address will be cleared.": "Le celle che non contengono un indirizzo email valido verranno svuotate.",
"Cells that can't be parsed as a date will be cleared.": "Le celle che non possono essere interpretate come una data verranno svuotate.",
"Cells that can't be parsed as a number will be cleared.": "Le celle che non possono essere interpretate come un numero verranno svuotate.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Le celle verranno convertite forzatamente (yes/true/1 diventeranno selezionate; tutto il resto diventerà non selezionato o verrà svuotato).",
"Cells will be reinterpreted under the new type.": "Le celle verranno reinterpretate con il nuovo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "Le celle verranno sostituite con un elenco di nomi file separati da virgole.",
"Cells will be replaced with a comma-separated list of option names.": "Le celle verranno sostituite con un elenco di nomi di opzioni separati da virgole.",
"Cells will be replaced with the option name.": "Le celle verranno sostituite con il nome dell'opzione.",
"Cells will be replaced with the page title.": "Le celle verranno sostituite con il titolo della pagina.",
"Cells will be replaced with the person's name.": "Le celle verranno sostituite con il nome della persona.",
"Change type": "Cambia tipo",
"Change type to {{label}}?": "Cambiare tipo in {{label}}?",
"Converting…": "Conversione…",
"Existing values become single-item lists. No data is lost.": "I valori esistenti diventano elenchi con un solo elemento. Nessun dato andrà perso.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Verrà mantenuto solo il primo elemento selezionato per riga; gli altri verranno scartati.",
"Previous record": "Record precedente",
"Next record": "Record successivo",
"Record actions": "Azioni record",
"Delete record": "Elimina record",
"Delete record?": "Eliminare il record?",
"This action cannot be undone.": "Questa azione non può essere annullata.",
"to navigate": "per navigare",
"to close": "per chiudere",
"Expand row {{number}}": "Espandi riga {{number}}",
"Saving…": "Salvataggio…",
"Read-only": "Sola lettura",
"Loading…": "Caricamento…",
"Updated {{when}}": "Aggiornato {{when}}",
"Add property": "Aggiungi proprietà",
"Create property": "Crea proprietà",
"Hide properties": "Nascondi proprietà",
"Find a property type": "Trova un tipo di proprietà",
"Properties": "Proprietà",
"A property with this name already exists": "Esiste già una proprietà con questo nome",
"Add card": "Aggiungi scheda",
"Add filter": "Aggiungi filtro",
"Add option": "Aggiungi opzione",
"Add sort": "Aggiungi ordinamento",
"Add view": "Aggiungi vista",
"All data in this column will be lost.": "Tutti i dati in questa colonna andranno persi.",
"Allow multiple people": "Consenti più persone",
"Alphabetize": "Ordina alfabeticamente",
"Are you sure you want to delete": "Sei sicuro di voler eliminare",
"Ascending": "Crescente",
"Base deleted": "Base eliminata",
"Base name": "Nome della base",
"Base table": "Tabella della base",
"Card properties": "Proprietà della scheda",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Le celle più lunghe del limite del testo verranno troncate e il contenuto aggiuntivo andrà perso in modo permanente.",
"Checkbox": "Casella di controllo",
"Checked by default": "Selezionata per impostazione predefinita",
"Choose a property": "Scegli una proprietà",
"Clear selection": "Cancella selezione",
"Column options": "Opzioni della colonna",
"Comma, period": "Virgola, punto",
"Complete": "Completato",
"Contains": "Contiene",
"Copy link to view": "Copia link alla vista",
"Create a status property": "Crea una proprietà di stato",
"Created at": "Creato il",
"Currency": "Valuta",
"Decimal places": "Posizioni decimali",
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
"Done": "Fatto",
"Edit formula": "Modifica formula",
"Edit property": "Modifica proprietà",
"Export CSV": "Esporta CSV",
"Failed to load base": "Impossibile caricare la base",
"False": "Falso",
"File": "File",
"Filter and sort changes are visible only to you": "Le modifiche a filtri e ordinamenti sono visibili solo a te",
"Filter by": "Filtra per",
"Formula": "Formula",
"Get started with": "Inizia con",
"Greater than": "Maggiore di",
"Group by": "Raggruppa per",
"Group this board by a select or status property.": "Raggruppa questa bacheca per una proprietà di selezione o di stato.",
"Hide all": "Nascondi tutto",
"Hide group": "Nascondi gruppo",
"In Progress": "In corso",
"In progress": "In corso",
"Include time": "Includi ora",
"Insert a kanban board on this page": "Inserisci una bacheca kanban in questa pagina",
"Insert an inline base on this page": "Inserisci una base in linea in questa pagina",
"Is": "È",
"Is after": "È dopo",
"Is any of": "È uno tra",
"Is before": "È prima",
"Is empty": "È vuoto",
"Is none of": "Non è nessuno di",
"Is not": "Non è",
"Is not empty": "Non è vuoto",
"Is on or after": "È il o dopo il",
"Is on or before": "È il o prima del",
"Is within": "È entro",
"Keep editing": "Continua a modificare",
"Last edited at": "Ultima modifica il",
"Last edited by": "Ultima modifica di",
"Less than": "Minore di",
"Link copied to clipboard": "Link copiato negli appunti",
"Local": "Locale",
"Long text": "Testo lungo",
"Moved card to {{column}}": "Scheda spostata in {{column}}",
"Moved column to {{column}}": "Colonna spostata in {{column}}",
"Multi-select": "Selezione multipla",
"New row": "Nuova riga",
"Next month": "Prossimo mese",
"Next week": "Prossima settimana",
"Next year": "Prossimo anno",
"No filters applied": "Nessun filtro applicato",
"No match": "Nessuna corrispondenza",
"No options for this property type": "Nessuna opzione per questo tipo di proprietà",
"No sorts applied": "Nessun ordinamento applicato",
"No value": "Nessun valore",
"None": "Nessuno",
"Not started": "Non iniziato",
"Number": "Numero",
"One month ago": "Un mese fa",
"One month from now": "Tra un mese",
"One week ago": "Una settimana fa",
"One week from now": "Tra una settimana",
"Open as page": "Apri come pagina",
"Options": "Opzioni",
"Past month": "Mese scorso",
"Past week": "Settimana scorsa",
"Past year": "Anno scorso",
"Percent": "Percentuale",
"Period, comma": "Punto, virgola",
"Person": "Persona",
"Please enter a valid email": "Inserisci un indirizzo email valido",
"Progress": "Avanzamento",
"Property options": "Opzioni della proprietà",
"Relative": "Relativo",
"Rename property": "Rinomina proprietà",
"Reset": "Reimposta",
"Save for everyone": "Salva per tutti",
"Select": "Seleziona",
"Select a property": "Seleziona una proprietà",
"Select all loaded rows": "Seleziona tutte le righe caricate",
"Show all": "Mostra tutto",
"Sort": "Ordina",
"Sort by": "Ordina per",
"Space, comma": "Spazio, virgola",
"Space, period": "Spazio, punto",
"This board has no grouping property yet.": "Questa bacheca non ha ancora una proprietà di raggruppamento.",
"This month": "Questo mese",
"This year": "Quest'anno",
"Thousands and decimal separators": "Separatori delle migliaia e dei decimali",
"Time format": "Formato ora",
"To Do": "Da fare",
"Tomorrow": "Domani",
"True": "Vero",
"Unsaved changes": "Modifiche non salvate",
"Untitled base": "Base senza titolo",
"URL": "URL",
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "ダーク",
"Date": "日付",
"Delete": "削除",
"Remove from page": "ページから削除",
"Base options": "ベースのオプション",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページとページ履歴も削除されます。この操作は取り消せません。",
"Description": "説明",
@@ -76,6 +78,24 @@
"Failed to import pages": "ページのインポートに失敗しました",
"Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。",
"Failed to update data": "データの更新に失敗しました",
"Failed to create base": "ベースの作成に失敗しました",
"Failed to update base": "ベースの更新に失敗しました",
"Failed to delete base": "ベースの削除に失敗しました",
"Failed to create property": "プロパティの作成に失敗しました",
"Failed to update property": "プロパティの更新に失敗しました",
"Failed to delete property": "プロパティの削除に失敗しました",
"Failed to reorder property": "プロパティの並べ替えに失敗しました",
"Failed to create view": "ビューの作成に失敗しました",
"Failed to update view": "ビューの更新に失敗しました",
"Failed to delete view": "ビューの削除に失敗しました",
"Failed to create row": "行の作成に失敗しました",
"Failed to update row": "行の更新に失敗しました",
"Failed to delete row": "行の削除に失敗しました",
"Failed to delete rows": "行の削除に失敗しました",
"Failed to reorder row": "行の並べ替えに失敗しました",
"Failed to move card": "カードの移動に失敗しました",
"Failed to add card": "カードの追加に失敗しました",
"Failed to export CSV": "CSV のエクスポートに失敗しました",
"Favorite spaces": "お気に入りのスペース",
"Favorite spaces appear here": "お気に入りのスペースがここに表示されます",
"Favorites": "お気に入り",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入",
"Insert and design Drawio diagrams": "Drawio ダイアグラムを挿入して作成",
"Insert current date": "現在の日付を挿入",
"Time": "時刻",
"Insert current time": "現在の時刻を挿入",
"Draw and sketch excalidraw diagrams": "Excalidraw ダイアグラムを描画・スケッチ",
"Multiple": "複数",
"Turn into": "変換する",
@@ -424,6 +446,7 @@
"Names do not match": "名前が一致しません",
"Today, {{time}}": "今日 {{time}}",
"Yesterday, {{time}}": "昨日 {{time}}",
"now": "今すぐ",
"Space created successfully": "スペースが正常に作成されました",
"Space updated successfully": "スペースが正常に更新されました",
"Space deleted successfully": "スペースが正常に削除されました",
@@ -596,6 +619,8 @@
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
"Base preview unavailable": "ベースのプレビューを利用できません",
"Restore this base to view its contents.": "内容を表示するには、このベースを復元してください。",
"Subpages": "サブページ",
"Failed to load subpages": "サブページの読み込みに失敗しました",
"No subpages": "サブページはありません",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "このテンプレートを削除してもよろしいですか?",
"Template scope updated": "テンプレートのスコープを更新しました",
"Choose which space this template belongs to": "このテンプレートを所属させるスペースを選択",
"Only workspace admins can create global templates.": "グローバルテンプレートを作成できるのは、ワークスペース管理者のみです。",
"You need edit access to this space to create templates.": "テンプレートを作成するには、このスペースへの編集アクセス権が必要です。",
"Scope": "スコープ",
"Select scope": "スコープを選択",
"Title": "タイトル",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "ページとスペースを検索…",
"No results found": "結果が見つかりません",
"You don't have permission to create pages here": "ここにページを作成する権限がありません",
"Chat menu": "チャットメニュー",
"Chat menu for {{title}}": "{{title}} のチャットメニュー",
"API key menu": "API キーメニュー",
"Jump to comment selection": "コメント選択に移動",
"Slash commands": "スラッシュコマンド",
@@ -1063,7 +1090,7 @@
"Filter": "フィルター",
"Page title": "ページタイトル",
"Page content": "ページ内容",
"Member actions": "メンバー操作",
"Member actions for {{name}}": "{{name}} のメンバー操作",
"Toggle password visibility": "パスワードの表示を切り替え",
"Send comment": "コメントを送信",
"Token actions": "トークンの操作",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{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."
"Allow personal spaces": "パーソナルスペースを許可",
"Members can create their own personal space.": "メンバーは自分専用のパーソナルスペースを作成できます。",
"Toggle allow personal spaces": "パーソナルスペースの許可を切り替える",
"Create personal space": "パーソナルスペースを作成",
"Personal space": "パーソナルスペース",
"{{name}}'s space": "{{name}} のスペース",
"Apply": "適用",
"Cells that aren't already a page reference will be cleared.": "すでにページ参照になっているセル以外は消去されます。",
"Cells that aren't a valid URL will be cleared.": "有効な URL ではないセルは消去されます。",
"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 number will be cleared.": "数値として解析できないセルは消去されます。",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "セルは強制変換されます(yes/true/1 はチェック済みになり、それ以外は未チェックまたは空になります)。",
"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 option names.": "セルは、オプション名のカンマ区切りリストに置き換えられます。",
"Cells will be replaced with the option name.": "セルはオプション名に置き換えられます。",
"Cells will be replaced with the page title.": "セルはページタイトルに置き換えられます。",
"Cells will be replaced with the person's name.": "セルはその人の名前に置き換えられます。",
"Change type": "型を変更",
"Change type to {{label}}?": "型を {{label}} に変更しますか?",
"Converting…": "変換中…",
"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.": "各行で最初に選択された項目のみ保持され、残りは破棄されます。",
"Previous record": "前のレコード",
"Next record": "次のレコード",
"Record actions": "レコード操作",
"Delete record": "レコードを削除",
"Delete record?": "レコードを削除しますか?",
"This action cannot be undone.": "この操作は元に戻せません。",
"to navigate": "移動するには",
"to close": "閉じるには",
"Expand row {{number}}": "行 {{number}} を展開",
"Saving…": "保存中…",
"Read-only": "読み取り専用",
"Loading…": "読み込み中…",
"Updated {{when}}": "{{when}} に更新",
"Add property": "プロパティを追加",
"Create property": "プロパティを作成",
"Hide properties": "プロパティを非表示",
"Find a property type": "プロパティの種類を検索",
"Properties": "プロパティ",
"A property with this name already exists": "この名前のプロパティはすでに存在します",
"Add card": "カードを追加",
"Add filter": "フィルターを追加",
"Add option": "オプションを追加",
"Add sort": "並べ替えを追加",
"Add view": "ビューを追加",
"All data in this column will be lost.": "この列内のすべてのデータは失われます。",
"Allow multiple people": "複数のユーザーを許可",
"Alphabetize": "アルファベット順",
"Are you sure you want to delete": "削除してもよろしいですか",
"Ascending": "昇順",
"Base deleted": "ベースを削除しました",
"Base name": "ベース名",
"Base table": "ベーステーブル",
"Card properties": "カードのプロパティ",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "テキストの制限を超える長さのセルは切り詰められ、超過分の内容は完全に失われます。",
"Checkbox": "チェックボックス",
"Checked by default": "デフォルトでチェックを入れる",
"Choose a property": "プロパティを選択",
"Clear selection": "選択をクリア",
"Column options": "列のオプション",
"Comma, period": "カンマ、小数点",
"Complete": "完了",
"Contains": "含む",
"Copy link to view": "ビューへのリンクをコピー",
"Create a status property": "ステータスプロパティを作成",
"Created at": "作成日時",
"Currency": "通貨",
"Decimal places": "小数点以下の桁数",
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
"Doesn't contain": "含まない",
"Done": "完了",
"Edit formula": "数式を編集",
"Edit property": "プロパティを編集",
"Export CSV": "CSVをエクスポート",
"Failed to load base": "ベースの読み込みに失敗しました",
"False": "偽",
"File": "ファイル",
"Filter and sort changes are visible only to you": "フィルターと並べ替えの変更は自分にのみ表示されます",
"Filter by": "条件",
"Formula": "数式",
"Get started with": "次から始める",
"Greater than": "より大きい",
"Group by": "グループ化",
"Group this board by a select or status property.": "このボードをセレクトまたはステータスのプロパティでグループ化します。",
"Hide all": "すべて非表示",
"Hide group": "グループを非表示",
"In Progress": "進行中",
"In progress": "進行中",
"Include time": "時刻を含める",
"Insert a kanban board on this page": "このページにカンバンボードを挿入",
"Insert an inline base on this page": "このページにインラインベースを挿入",
"Is": "次に等しい",
"Is after": "次より後",
"Is any of": "次のいずれか",
"Is before": "次より前",
"Is empty": "空である",
"Is none of": "次のいずれでもない",
"Is not": "次に等しくない",
"Is not empty": "空ではない",
"Is on or after": "次以降",
"Is on or before": "次以前",
"Is within": "次の期間内",
"Keep editing": "編集を続ける",
"Last edited at": "最終編集日時",
"Last edited by": "最終編集者",
"Less than": "より小さい",
"Link copied to clipboard": "リンクをクリップボードにコピーしました",
"Local": "ローカル",
"Long text": "長いテキスト",
"Moved card to {{column}}": "カードを{{column}}に移動しました",
"Moved column to {{column}}": "列を{{column}}に移動しました",
"Multi-select": "複数選択",
"New row": "新規行",
"Next month": "来月",
"Next week": "来週",
"Next year": "来年",
"No filters applied": "フィルターは適用されていません",
"No match": "一致なし",
"No options for this property type": "このプロパティタイプにはオプションがありません",
"No sorts applied": "並べ替えは適用されていません",
"No value": "値なし",
"None": "なし",
"Not started": "未着手",
"Number": "数値",
"One month ago": "1か月前",
"One month from now": "1か月後",
"One week ago": "1週間前",
"One week from now": "1週間後",
"Open as page": "ページとして開く",
"Options": "オプション",
"Past month": "先月",
"Past week": "先週",
"Past year": "昨年",
"Percent": "パーセント",
"Period, comma": "小数点、カンマ",
"Person": "ユーザー",
"Please enter a valid email": "有効なメールアドレスを入力してください",
"Progress": "進捗",
"Property options": "プロパティのオプション",
"Relative": "相対",
"Rename property": "プロパティ名を変更",
"Reset": "リセット",
"Save for everyone": "全員に保存",
"Select": "選択",
"Select a property": "プロパティを選択",
"Select all loaded rows": "読み込まれているすべての行を選択",
"Show all": "すべて表示",
"Sort": "並べ替え",
"Sort by": "並べ替え条件",
"Space, comma": "スペース、カンマ",
"Space, period": "スペース、小数点",
"This board has no grouping property yet.": "このボードにはまだグループ化プロパティがありません。",
"This month": "今月",
"This year": "今年",
"Thousands and decimal separators": "桁区切りと小数点区切り",
"Time format": "時刻形式",
"To Do": "To Do",
"Tomorrow": "明日",
"True": "真",
"Unsaved changes": "未保存の変更",
"Untitled base": "無題のベース",
"URL": "URL",
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "어두운",
"Date": "날짜",
"Delete": "삭제",
"Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션",
"Delete group": "팀 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
@@ -76,6 +78,24 @@
"Failed to import pages": "페이지 가져오기 실패",
"Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.",
"Failed to update data": "데이터 갱신 실패",
"Failed to create base": "베이스를 만들지 못했습니다",
"Failed to update base": "베이스를 업데이트하지 못했습니다",
"Failed to delete base": "베이스를 삭제하지 못했습니다",
"Failed to create property": "속성을 만들지 못했습니다",
"Failed to update property": "속성을 업데이트하지 못했습니다",
"Failed to delete property": "속성을 삭제하지 못했습니다",
"Failed to reorder property": "속성 순서를 변경하지 못했습니다",
"Failed to create view": "보기를 만들지 못했습니다",
"Failed to update view": "보기를 업데이트하지 못했습니다",
"Failed to delete view": "보기를 삭제하지 못했습니다",
"Failed to create row": "행을 만들지 못했습니다",
"Failed to update row": "행을 업데이트하지 못했습니다",
"Failed to delete row": "행을 삭제하지 못했습니다",
"Failed to delete rows": "행을 삭제하지 못했습니다",
"Failed to reorder row": "행 순서를 변경하지 못했습니다",
"Failed to move card": "카드를 이동하지 못했습니다",
"Failed to add card": "카드를 추가하지 못했습니다",
"Failed to export CSV": "CSV를 내보내지 못했습니다",
"Favorite spaces": "즐겨찾는 스페이스",
"Favorite spaces appear here": "즐겨찾는 스페이스가 여기에 표시됩니다",
"Favorites": "즐겨찾기",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid 다이어그램 삽입",
"Insert and design Drawio diagrams": "Drawio 다이어그램 삽입 및 편집",
"Insert current date": "현재 날짜 삽입",
"Time": "시간",
"Insert current time": "현재 시간 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw 다이어그램 그리기 및 스케치",
"Multiple": "다중",
"Turn into": "변경하기",
@@ -424,6 +446,7 @@
"Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"now": "지금",
"Space created successfully": "스페이스가 성공적으로 생성되었습니다",
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
@@ -596,6 +619,8 @@
"Deleted by": "삭제한 사람",
"Deleted at": "삭제 시간",
"Preview": "미리보기",
"Base preview unavailable": "베이스 미리보기를 사용할 수 없음",
"Restore this base to view its contents.": "이 베이스를 복원하여 내용을 보세요.",
"Subpages": "하위 페이지",
"Failed to load subpages": "하위 페이지를 불러오지 못했습니다",
"No subpages": "하위 페이지가 없습니다",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "이 템플릿을 삭제하시겠습니까?",
"Template scope updated": "템플릿 범위가 업데이트되었습니다",
"Choose which space this template belongs to": "이 템플릿이 속할 스페이스를 선택하세요",
"Only workspace admins can create global templates.": "전역 템플릿은 워크스페이스 관리자만 만들 수 있습니다.",
"You need edit access to this space to create templates.": "템플릿을 만들려면 이 스페이스에 대한 편집 권한이 있어야 합니다.",
"Scope": "범위",
"Select scope": "범위 선택",
"Title": "제목",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "페이지와 스페이스 검색...",
"No results found": "결과를 찾을 수 없습니다",
"You don't have permission to create pages here": "여기에서 페이지를 만들 권한이 없습니다",
"Chat menu": "채팅 메뉴",
"Chat menu for {{title}}": "{{title}}의 채팅 메뉴",
"API key menu": "API 키 메뉴",
"Jump to comment selection": "댓글 선택으로 이동",
"Slash commands": "슬래시 명령어",
@@ -1063,7 +1090,7 @@
"Filter": "필터",
"Page title": "페이지 제목",
"Page content": "페이지 내용",
"Member actions": "멤버 작업",
"Member actions for {{name}}": "{{name}}에 대한 멤버 작업",
"Toggle password visibility": "비밀번호 표시 전환",
"Send comment": "댓글 보내기",
"Token actions": "토큰 작업",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{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."
"Allow personal spaces": "개인 공간 허용",
"Members can create their own personal space.": "멤버는 자신만의 개인 공간을 만들 수 있습니다.",
"Toggle allow personal spaces": "개인 공간 허용 전환",
"Create personal space": "개인 공간 만들기",
"Personal space": "개인 공간",
"{{name}}'s space": "{{name}}의 공간",
"Apply": "적용",
"Cells that aren't already a page reference will be cleared.": "이미 페이지 참조인 셀이 아닌 경우 지워집니다.",
"Cells that aren't a valid URL will be cleared.": "유효한 URL이 아닌 셀은 지워집니다.",
"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 number will be cleared.": "숫자로 해석할 수 없는 셀은 지워집니다.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "셀 값은 강제로 변환됩니다(yes/true/1은 선택됨이 되고, 그 외의 모든 값은 선택 해제되거나 지워집니다).",
"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 option names.": "셀은 쉼표로 구분된 옵션 이름 목록으로 대체됩니다.",
"Cells will be replaced with the option name.": "셀은 옵션 이름으로 대체됩니다.",
"Cells will be replaced with the page title.": "셀은 페이지 제목으로 대체됩니다.",
"Cells will be replaced with the person's name.": "셀은 사람 이름으로 대체됩니다.",
"Change type": "유형 변경",
"Change type to {{label}}?": "유형을 {{label}}로 변경할까요?",
"Converting…": "변환 중…",
"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.": "각 행에서 처음 선택된 항목만 유지되며 나머지는 삭제됩니다.",
"Previous record": "이전 레코드",
"Next record": "다음 레코드",
"Record actions": "레코드 작업",
"Delete record": "레코드 삭제",
"Delete record?": "레코드를 삭제할까요?",
"This action cannot be undone.": "이 작업은 되돌릴 수 없습니다.",
"to navigate": "탐색하려면",
"to close": "닫으려면",
"Expand row {{number}}": "행 {{number}} 펼치기",
"Saving…": "저장 중…",
"Read-only": "읽기 전용",
"Loading…": "불러오는 중…",
"Updated {{when}}": "{{when}}에 업데이트됨",
"Add property": "속성 추가",
"Create property": "속성 만들기",
"Hide properties": "속성 숨기기",
"Find a property type": "속성 유형 찾기",
"Properties": "속성",
"A property with this name already exists": "이 이름의 속성이 이미 있습니다",
"Add card": "카드 추가",
"Add filter": "필터 추가",
"Add option": "옵션 추가",
"Add sort": "정렬 추가",
"Add view": "보기 추가",
"All data in this column will be lost.": "이 열의 모든 데이터가 손실됩니다.",
"Allow multiple people": "여러 사람 허용",
"Alphabetize": "가나다순",
"Are you sure you want to delete": "삭제하시겠습니까",
"Ascending": "오름차순",
"Base deleted": "베이스가 삭제됨",
"Base name": "베이스 이름",
"Base table": "베이스 테이블",
"Card properties": "카드 속성",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "텍스트 제한보다 긴 셀은 잘리며, 초과된 콘텐츠는 영구적으로 손실됩니다.",
"Checkbox": "체크박스",
"Checked by default": "기본적으로 선택됨",
"Choose a property": "속성 선택",
"Clear selection": "선택 지우기",
"Column options": "열 옵션",
"Comma, period": "쉼표, 마침표",
"Complete": "완료",
"Contains": "포함",
"Copy link to view": "보기 링크 복사",
"Create a status property": "상태 속성 만들기",
"Created at": "작성 시간",
"Currency": "통화",
"Decimal places": "소수 자릿수",
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
"Done": "완료",
"Edit formula": "수식 편집",
"Edit property": "속성 편집",
"Export CSV": "CSV 내보내기",
"Failed to load base": "베이스를 불러오지 못했습니다",
"False": "거짓",
"File": "파일",
"Filter and sort changes are visible only to you": "필터 및 정렬 변경 사항은 나에게만 표시됩니다",
"Filter by": "필터 기준",
"Formula": "수식",
"Get started with": "시작하기",
"Greater than": "보다 큼",
"Group by": "그룹화 기준",
"Group this board by a select or status property.": "선택 또는 상태 속성으로 이 보드를 그룹화하세요.",
"Hide all": "모두 숨기기",
"Hide group": "그룹 숨기기",
"In Progress": "진행 중",
"In progress": "진행 중",
"Include time": "시간 포함",
"Insert a kanban board on this page": "이 페이지에 칸반 보드 삽입",
"Insert an inline base on this page": "이 페이지에 인라인 베이스 삽입",
"Is": "같음",
"Is after": "이후",
"Is any of": "다음 중 하나와 같음",
"Is before": "이전",
"Is empty": "비어 있음",
"Is none of": "다음 중 어느 것과도 같지 않음",
"Is not": "같지 않음",
"Is not empty": "비어 있지 않음",
"Is on or after": "해당 날짜 또는 이후",
"Is on or before": "해당 날짜 또는 이전",
"Is within": "다음 범위 내",
"Keep editing": "계속 편집",
"Last edited at": "마지막 편집 시간",
"Last edited by": "마지막 편집자",
"Less than": "보다 작음",
"Link copied to clipboard": "링크가 클립보드에 복사됨",
"Local": "로컬",
"Long text": "긴 텍스트",
"Moved card to {{column}}": "카드를 {{column}}(으)로 이동함",
"Moved column to {{column}}": "열을 {{column}}(으)로 이동함",
"Multi-select": "다중 선택",
"New row": "새 행",
"Next month": "다음 달",
"Next week": "다음 주",
"Next year": "내년",
"No filters applied": "적용된 필터 없음",
"No match": "일치하는 항목 없음",
"No options for this property type": "이 속성 유형에는 옵션이 없습니다",
"No sorts applied": "적용된 정렬 없음",
"No value": "값 없음",
"None": "없음",
"Not started": "시작 전",
"Number": "숫자",
"One month ago": "한 달 전",
"One month from now": "한 달 후",
"One week ago": "일주일 전",
"One week from now": "일주일 후",
"Open as page": "페이지로 열기",
"Options": "옵션",
"Past month": "지난달",
"Past week": "지난주",
"Past year": "작년",
"Percent": "백분율",
"Period, comma": "마침표, 쉼표",
"Person": "사람",
"Please enter a valid email": "유효한 이메일을 입력하세요",
"Progress": "진행률",
"Property options": "속성 옵션",
"Relative": "상대값",
"Rename property": "속성 이름 변경",
"Reset": "재설정",
"Save for everyone": "모든 사용자에게 저장",
"Select": "선택",
"Select a property": "속성 선택",
"Select all loaded rows": "불러온 모든 행 선택",
"Show all": "모두 보기",
"Sort": "정렬",
"Sort by": "정렬 기준",
"Space, comma": "공백, 쉼표",
"Space, period": "공백, 마침표",
"This board has no grouping property yet.": "이 보드에는 아직 그룹화 속성이 없습니다.",
"This month": "이번 달",
"This year": "올해",
"Thousands and decimal separators": "천 단위 및 소수 구분 기호",
"Time format": "시간 형식",
"To Do": "할 일",
"Tomorrow": "내일",
"True": "참",
"Unsaved changes": "저장되지 않은 변경 사항",
"Untitled base": "제목 없는 베이스",
"URL": "URL",
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Donker",
"Date": "Datum",
"Delete": "Verwijderen",
"Remove from page": "Van pagina verwijderen",
"Base options": "Basisopties",
"Delete group": "Groep verwijderen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Weet u zeker dat u deze pagina wilt verwijderen? Dit zal de subpagina's en paginageschiedenis verwijderen. Deze actie kan niet ongedaan gemaakt worden.",
"Description": "Beschrijving",
@@ -76,6 +78,24 @@
"Failed to import pages": "Pagina's importeren mislukt",
"Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.",
"Failed to update data": "Bijwerken van gegevens mislukt",
"Failed to create base": "Kan basis niet aanmaken",
"Failed to update base": "Kan basis niet bijwerken",
"Failed to delete base": "Kan basis niet verwijderen",
"Failed to create property": "Kan eigenschap niet aanmaken",
"Failed to update property": "Kan eigenschap niet bijwerken",
"Failed to delete property": "Kan eigenschap niet verwijderen",
"Failed to reorder property": "Kan eigenschap niet opnieuw ordenen",
"Failed to create view": "Kan weergave niet aanmaken",
"Failed to update view": "Kan weergave niet bijwerken",
"Failed to delete view": "Kan weergave niet verwijderen",
"Failed to create row": "Kan rij niet aanmaken",
"Failed to update row": "Kan rij niet bijwerken",
"Failed to delete row": "Kan rij niet verwijderen",
"Failed to delete rows": "Kan rijen niet verwijderen",
"Failed to reorder row": "Kan rij niet opnieuw ordenen",
"Failed to move card": "Kan kaart niet verplaatsen",
"Failed to add card": "Kan kaart niet toevoegen",
"Failed to export CSV": "Kan CSV niet exporteren",
"Favorite spaces": "Favoriete ruimtes",
"Favorite spaces appear here": "Favoriete ruimtes verschijnen hier",
"Favorites": "Favorieten",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid-diagram invoegen",
"Insert and design Drawio diagrams": "Drawio-diagrammen invoegen en ontwerpen",
"Insert current date": "Huidige datum invoegen",
"Time": "Tijd",
"Insert current time": "Huidige tijd invoegen",
"Draw and sketch excalidraw diagrams": "Excalidraw-diagrammen tekenen en schetsen",
"Multiple": "Meerdere",
"Turn into": "Omzetten naar",
@@ -424,6 +446,7 @@
"Names do not match": "Namen komen niet overeen",
"Today, {{time}}": "Vandaag, {{time}}",
"Yesterday, {{time}}": "Gisteren, {{time}}",
"now": "nu",
"Space created successfully": "Ruimte succesvol aangemaakt",
"Space updated successfully": "Ruimte succesvol bijgewerkt",
"Space deleted successfully": "Ruimte succesvol verwijderd",
@@ -596,6 +619,8 @@
"Deleted by": "Verwijderd door",
"Deleted at": "Verwijderd op",
"Preview": "Voorbeeld",
"Base preview unavailable": "Voorbeeld van basis niet beschikbaar",
"Restore this base to view its contents.": "Herstel deze basis om de inhoud te bekijken.",
"Subpages": "Subpagina's",
"Failed to load subpages": "Subpagina's laden mislukt",
"No subpages": "Geen subpagina's",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Weet je zeker dat je dit sjabloon wilt verwijderen?",
"Template scope updated": "Bereik van sjabloon bijgewerkt",
"Choose which space this template belongs to": "Kies bij welke werkruimte dit sjabloon hoort",
"Only workspace admins can create global templates.": "Alleen workspace-beheerders kunnen globale sjablonen maken.",
"You need edit access to this space to create templates.": "Je hebt bewerkingstoegang tot deze ruimte nodig om sjablonen te maken.",
"Scope": "Bereik",
"Select scope": "Bereik selecteren",
"Title": "Titel",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Pagina's en werkruimtes zoeken...",
"No results found": "Geen resultaten gevonden",
"You don't have permission to create pages here": "Je hebt geen toestemming om hier pagina's te maken",
"Chat menu": "Chatmenu",
"Chat menu for {{title}}": "Chatmenu voor {{title}}",
"API key menu": "API-sleutelmenu",
"Jump to comment selection": "Naar reactieselectie springen",
"Slash commands": "Slash-opdrachten",
@@ -1063,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Paginatitel",
"Page content": "Pagina-inhoud",
"Member actions": "Lidacties",
"Member actions for {{name}}": "Lidacties voor {{name}}",
"Toggle password visibility": "Wachtwoordzichtbaarheid in-/uitschakelen",
"Send comment": "Reactie verzenden",
"Token actions": "Tokenacties",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
"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."
"Allow personal spaces": "Persoonlijke ruimtes toestaan",
"Members can create their own personal space.": "Leden kunnen hun eigen persoonlijke ruimte aanmaken.",
"Toggle allow personal spaces": "Persoonlijke ruimtes toestaan in- of uitschakelen",
"Create personal space": "Persoonlijke ruimte aanmaken",
"Personal space": "Persoonlijke ruimte",
"{{name}}'s space": "ruimte van {{name}}",
"Apply": "Toepassen",
"Cells that aren't already a page reference will be cleared.": "Cellen die nog geen paginaverwijzing zijn, worden leeggemaakt.",
"Cells that aren't a valid URL will be cleared.": "Cellen die geen geldige URL zijn, worden leeggemaakt.",
"Cells that aren't a valid email address will be cleared.": "Cellen die geen geldig e-mailadres zijn, worden leeggemaakt.",
"Cells that can't be parsed as a date will be cleared.": "Cellen die niet als een datum kunnen worden geïnterpreteerd, worden leeggemaakt.",
"Cells that can't be parsed as a number will be cleared.": "Cellen die niet als een getal kunnen worden geïnterpreteerd, worden leeggemaakt.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cellen worden geconverteerd (yes/true/1 worden aangevinkt; al het andere wordt uitgevinkt of leeggemaakt).",
"Cells will be reinterpreted under the new type.": "Cellen worden opnieuw geïnterpreteerd volgens het nieuwe type.",
"Cells will be replaced with a comma-separated list of file names.": "Cellen worden vervangen door een door komma's gescheiden lijst met bestandsnamen.",
"Cells will be replaced with a comma-separated list of option names.": "Cellen worden vervangen door een door komma's gescheiden lijst met optienamen.",
"Cells will be replaced with the option name.": "Cellen worden vervangen door de optienaam.",
"Cells will be replaced with the page title.": "Cellen worden vervangen door de paginatitel.",
"Cells will be replaced with the person's name.": "Cellen worden vervangen door de naam van de persoon.",
"Change type": "Type wijzigen",
"Change type to {{label}}?": "Type wijzigen naar {{label}}?",
"Converting…": "Converteren…",
"Existing values become single-item lists. No data is lost.": "Bestaande waarden worden lijsten met één item. Er gaan geen gegevens verloren.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Alleen het eerste geselecteerde item per rij blijft behouden; de rest wordt verwijderd.",
"Previous record": "Vorige record",
"Next record": "Volgende record",
"Record actions": "Recordacties",
"Delete record": "Record verwijderen",
"Delete record?": "Record verwijderen?",
"This action cannot be undone.": "Deze actie kan niet ongedaan worden gemaakt.",
"to navigate": "om te navigeren",
"to close": "om te sluiten",
"Expand row {{number}}": "Rij {{number}} uitvouwen",
"Saving…": "Opslaan…",
"Read-only": "Alleen-lezen",
"Loading…": "Laden…",
"Updated {{when}}": "Bijgewerkt {{when}}",
"Add property": "Eigenschap toevoegen",
"Create property": "Eigenschap aanmaken",
"Hide properties": "Eigenschappen verbergen",
"Find a property type": "Een eigenschapstype zoeken",
"Properties": "Eigenschappen",
"A property with this name already exists": "Er bestaat al een eigenschap met deze naam",
"Add card": "Kaart toevoegen",
"Add filter": "Filter toevoegen",
"Add option": "Optie toevoegen",
"Add sort": "Sortering toevoegen",
"Add view": "Weergave toevoegen",
"All data in this column will be lost.": "Alle gegevens in deze kolom gaan verloren.",
"Allow multiple people": "Meerdere personen toestaan",
"Alphabetize": "Alfabetisch sorteren",
"Are you sure you want to delete": "Weet je zeker dat je wilt verwijderen",
"Ascending": "Oplopend",
"Base deleted": "Base verwijderd",
"Base name": "Basenaam",
"Base table": "Basistabel",
"Card properties": "Kaarteigenschappen",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cellen die langer zijn dan de tekstlimiet worden afgekapt en de extra inhoud gaat permanent verloren.",
"Checkbox": "Selectievakje",
"Checked by default": "Standaard aangevinkt",
"Choose a property": "Kies een eigenschap",
"Clear selection": "Selectie wissen",
"Column options": "Kolomopties",
"Comma, period": "Komma, punt",
"Complete": "Voltooid",
"Contains": "Bevat",
"Copy link to view": "Link naar weergave kopiëren",
"Create a status property": "Een statuseigenschap maken",
"Created at": "Aangemaakt op",
"Currency": "Valuta",
"Decimal places": "Decimalen",
"Default value": "Standaardwaarde",
"Delete property": "Eigenschap verwijderen",
"Delete view": "Weergave verwijderen",
"Delete {{count}} rows?_one": "1 rij verwijderen?",
"Delete {{count}} rows?_other": "{{count}} rijen verwijderen?",
"Descending": "Aflopend",
"Discard": "Negeren",
"Doesn't contain": "Bevat niet",
"Done": "Klaar",
"Edit formula": "Formule bewerken",
"Edit property": "Eigenschap bewerken",
"Export CSV": "CSV exporteren",
"Failed to load base": "Base laden mislukt",
"False": "Onwaar",
"File": "Bestand",
"Filter and sort changes are visible only to you": "Wijzigingen in filters en sortering zijn alleen zichtbaar voor jou",
"Filter by": "Filteren op",
"Formula": "Formule",
"Get started with": "Aan de slag met",
"Greater than": "Groter dan",
"Group by": "Groeperen op",
"Group this board by a select or status property.": "Groepeer dit bord op een selectie- of statuseigenschap.",
"Hide all": "Alles verbergen",
"Hide group": "Groep verbergen",
"In Progress": "In uitvoering",
"In progress": "In uitvoering",
"Include time": "Tijd opnemen",
"Insert a kanban board on this page": "Voeg een kanbanbord in op deze pagina",
"Insert an inline base on this page": "Voeg een inline base in op deze pagina",
"Is": "Is",
"Is after": "Is na",
"Is any of": "Is een van",
"Is before": "Is vóór",
"Is empty": "Is leeg",
"Is none of": "Is geen van",
"Is not": "Is niet",
"Is not empty": "Is niet leeg",
"Is on or after": "Is op of na",
"Is on or before": "Is op of vóór",
"Is within": "Is binnen",
"Keep editing": "Doorgaan met bewerken",
"Last edited at": "Laatst bewerkt op",
"Last edited by": "Laatst bewerkt door",
"Less than": "Kleiner dan",
"Link copied to clipboard": "Link gekopieerd naar klembord",
"Local": "Lokaal",
"Long text": "Lange tekst",
"Moved card to {{column}}": "Kaart verplaatst naar {{column}}",
"Moved column to {{column}}": "Kolom verplaatst naar {{column}}",
"Multi-select": "Meervoudige selectie",
"New row": "Nieuwe rij",
"Next month": "Volgende maand",
"Next week": "Volgende week",
"Next year": "Volgend jaar",
"No filters applied": "Geen filters toegepast",
"No match": "Geen overeenkomst",
"No options for this property type": "Geen opties voor dit eigenschapstype",
"No sorts applied": "Geen sorteringen toegepast",
"No value": "Geen waarde",
"None": "Geen",
"Not started": "Niet gestart",
"Number": "Getal",
"One month ago": "Een maand geleden",
"One month from now": "Over een maand",
"One week ago": "Een week geleden",
"One week from now": "Over een week",
"Open as page": "Openen als pagina",
"Options": "Opties",
"Past month": "Afgelopen maand",
"Past week": "Afgelopen week",
"Past year": "Afgelopen jaar",
"Percent": "Procent",
"Period, comma": "Punt, komma",
"Person": "Persoon",
"Please enter a valid email": "Voer een geldig e-mailadres in",
"Progress": "Voortgang",
"Property options": "Eigenschapopties",
"Relative": "Relatief",
"Rename property": "Eigenschap hernoemen",
"Reset": "Opnieuw instellen",
"Save for everyone": "Opslaan voor iedereen",
"Select": "Selecteren",
"Select a property": "Selecteer een eigenschap",
"Select all loaded rows": "Alle geladen rijen selecteren",
"Show all": "Alles tonen",
"Sort": "Sorteren",
"Sort by": "Sorteren op",
"Space, comma": "Spatie, komma",
"Space, period": "Spatie, punt",
"This board has no grouping property yet.": "Dit bord heeft nog geen groeperingseigenschap.",
"This month": "Deze maand",
"This year": "Dit jaar",
"Thousands and decimal separators": "Scheidingstekens voor duizendtallen en decimalen",
"Time format": "Tijdnotatie",
"To Do": "Te doen",
"Tomorrow": "Morgen",
"True": "Waar",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"Untitled base": "Base zonder titel",
"URL": "URL",
"Value": "Waarde",
"View updated for everyone": "Weergave bijgewerkt voor iedereen",
"You have unsaved changes. Do you want to discard them?": "Je hebt niet-opgeslagen wijzigingen. Wil je deze negeren?",
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Escuro",
"Date": "Data",
"Delete": "Excluir",
"Remove from page": "Remover da página",
"Base options": "Opções da base",
"Delete group": "Excluir grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Você tem certeza que quer deletar essa página? Isso irá deletar todas as páginas filhas e to o histórico. Esta ação é irreversível.",
"Description": "Descrição",
@@ -76,6 +78,24 @@
"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",
"Failed to create base": "Falha ao criar base",
"Failed to update base": "Falha ao atualizar base",
"Failed to delete base": "Falha ao excluir base",
"Failed to create property": "Falha ao criar propriedade",
"Failed to update property": "Falha ao atualizar propriedade",
"Failed to delete property": "Falha ao excluir propriedade",
"Failed to reorder property": "Falha ao reordenar propriedade",
"Failed to create view": "Falha ao criar visualização",
"Failed to update view": "Falha ao atualizar visualização",
"Failed to delete view": "Falha ao excluir visualização",
"Failed to create row": "Falha ao criar linha",
"Failed to update row": "Falha ao atualizar linha",
"Failed to delete row": "Falha ao excluir linha",
"Failed to delete rows": "Falha ao excluir linhas",
"Failed to reorder row": "Falha ao reordenar linha",
"Failed to move card": "Falha ao mover cartão",
"Failed to add card": "Falha ao adicionar cartão",
"Failed to export CSV": "Falha ao exportar CSV",
"Favorite spaces": "Espaços favoritos",
"Favorite spaces appear here": "Os espaços favoritos aparecem aqui",
"Favorites": "Favoritos",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Inserir diagrama Mermaid",
"Insert and design Drawio diagrams": "Inserir e criar diagramas Drawio",
"Insert current date": "Inserir data atual",
"Time": "Horário",
"Insert current time": "Inserir horário atual",
"Draw and sketch excalidraw diagrams": "Desenhar e esboçar diagramas Excalidraw",
"Multiple": "Múltiplo",
"Turn into": "Transformar em",
@@ -424,6 +446,7 @@
"Names do not match": "Os nomes não correspondem",
"Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}",
"now": "agora",
"Space created successfully": "Espaço criado com sucesso",
"Space updated successfully": "Espaço atualizado com sucesso",
"Space deleted successfully": "Espaço excluído com sucesso",
@@ -596,6 +619,8 @@
"Deleted by": "Excluído por",
"Deleted at": "Excluído em",
"Preview": "Visualização",
"Base preview unavailable": "Pré-visualização da base indisponível",
"Restore this base to view its contents.": "Restaure esta base para visualizar seu conteúdo.",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar as subpáginas",
"No subpages": "Nenhuma subpágina",
@@ -965,6 +990,8 @@
"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",
"Only workspace admins can create global templates.": "Somente administradores do espaço de trabalho podem criar modelos globais.",
"You need edit access to this space to create templates.": "Você precisa de acesso de edição a este espaço para criar modelos.",
"Scope": "Escopo",
"Select scope": "Selecionar escopo",
"Title": "Título",
@@ -977,7 +1004,7 @@
"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",
"Chat menu for {{title}}": "Menu do chat para {{title}}",
"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",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrar",
"Page title": "Título da página",
"Page content": "Conteúdo da página",
"Member actions": "Ações do membro",
"Member actions for {{name}}": "Ações do membro para {{name}}",
"Toggle password visibility": "Alternar visibilidade da senha",
"Send comment": "Enviar comentário",
"Token actions": "Ações do token",
@@ -1085,22 +1112,182 @@
"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."
"Allow personal spaces": "Permitir espaços pessoais",
"Members can create their own personal space.": "Os membros podem criar seu próprio espaço pessoal.",
"Toggle allow personal spaces": "Ativar/desativar permissão para espaços pessoais",
"Create personal space": "Criar espaço pessoal",
"Personal space": "Espaço pessoal",
"{{name}}'s space": "Espaço de {{name}}",
"Apply": "Aplicar",
"Cells that aren't already a page reference will be cleared.": "As células que ainda não forem uma referência de página serão limpas.",
"Cells that aren't a valid URL will be cleared.": "As células que não forem uma URL válida serão limpas.",
"Cells that aren't a valid email address will be cleared.": "As células que não forem um endereço de e-mail válido serão limpas.",
"Cells that can't be parsed as a date will be cleared.": "As células que não puderem ser interpretadas como uma data serão limpas.",
"Cells that can't be parsed as a number will be cleared.": "As células que não puderem ser interpretadas como um número serão limpas.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "As células serão convertidas (yes/true/1 se tornam marcadas; todo o resto se torna desmarcado ou limpo).",
"Cells will be reinterpreted under the new type.": "As células serão reinterpretadas com o novo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "As células serão substituídas por uma lista de nomes de arquivos separados por vírgula.",
"Cells will be replaced with a comma-separated list of option names.": "As células serão substituídas por uma lista de nomes de opções separados por vírgula.",
"Cells will be replaced with the option name.": "As células serão substituídas pelo nome da opção.",
"Cells will be replaced with the page title.": "As células serão substituídas pelo título da página.",
"Cells will be replaced with the person's name.": "As células serão substituídas pelo nome da pessoa.",
"Change type": "Alterar tipo",
"Change type to {{label}}?": "Alterar tipo para {{label}}?",
"Converting…": "Convertendo…",
"Existing values become single-item lists. No data is lost.": "Os valores existentes se tornam listas de item único. Nenhum dado é perdido.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Apenas o primeiro item selecionado por linha será mantido; o restante será descartado.",
"Previous record": "Registro anterior",
"Next record": "Próximo registro",
"Record actions": "Ações do registro",
"Delete record": "Excluir registro",
"Delete record?": "Excluir registro?",
"This action cannot be undone.": "Esta ação não pode ser desfeita.",
"to navigate": "para navegar",
"to close": "para fechar",
"Expand row {{number}}": "Expandir linha {{number}}",
"Saving…": "Salvando…",
"Read-only": "Somente leitura",
"Loading…": "Carregando…",
"Updated {{when}}": "Atualizado {{when}}",
"Add property": "Adicionar propriedade",
"Create property": "Criar propriedade",
"Hide properties": "Ocultar propriedades",
"Find a property type": "Encontrar um tipo de propriedade",
"Properties": "Propriedades",
"A property with this name already exists": "Já existe uma propriedade com este nome",
"Add card": "Adicionar cartão",
"Add filter": "Adicionar filtro",
"Add option": "Adicionar opção",
"Add sort": "Adicionar ordenação",
"Add view": "Adicionar visualização",
"All data in this column will be lost.": "Todos os dados nesta coluna serão perdidos.",
"Allow multiple people": "Permitir várias pessoas",
"Alphabetize": "Ordenar alfabeticamente",
"Are you sure you want to delete": "Tem certeza de que deseja excluir",
"Ascending": "Crescente",
"Base deleted": "Base excluída",
"Base name": "Nome da base",
"Base table": "Tabela da base",
"Card properties": "Propriedades do cartão",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Células maiores do que o limite de Texto serão truncadas e o conteúdo extra será perdido permanentemente.",
"Checkbox": "Caixa de seleção",
"Checked by default": "Marcado por padrão",
"Choose a property": "Escolha uma propriedade",
"Clear selection": "Limpar seleção",
"Column options": "Opções da coluna",
"Comma, period": "Vírgula, ponto",
"Complete": "Concluído",
"Contains": "Contém",
"Copy link to view": "Copiar link da visualização",
"Create a status property": "Criar uma propriedade de status",
"Created at": "Criado em",
"Currency": "Moeda",
"Decimal places": "Casas decimais",
"Default value": "Valor padrão",
"Delete property": "Excluir propriedade",
"Delete view": "Excluir visualização",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Descartar",
"Doesn't contain": "Não contém",
"Done": "Concluído",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propriedade",
"Export CSV": "Exportar CSV",
"Failed to load base": "Falha ao carregar a base",
"False": "Falso",
"File": "Arquivo",
"Filter and sort changes are visible only to you": "As alterações de filtro e ordenação são visíveis somente para você",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Começar com",
"Greater than": "Maior que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este quadro por uma propriedade de seleção ou status.",
"Hide all": "Ocultar tudo",
"Hide group": "Ocultar grupo",
"In Progress": "Em andamento",
"In progress": "Em andamento",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Inserir um quadro kanban nesta página",
"Insert an inline base on this page": "Inserir uma base inline nesta página",
"Is": "É",
"Is after": "É depois de",
"Is any of": "É qualquer um de",
"Is before": "É antes de",
"Is empty": "Está vazio",
"Is none of": "Não é nenhum de",
"Is not": "Não é",
"Is not empty": "Não está vazio",
"Is on or after": "É em ou depois de",
"Is on or before": "É em ou antes de",
"Is within": "Está dentro de",
"Keep editing": "Continuar editando",
"Last edited at": "Última edição em",
"Last edited by": "Última edição por",
"Less than": "Menor que",
"Link copied to clipboard": "Link copiado para a área de transferência",
"Local": "Local",
"Long text": "Texto longo",
"Moved card to {{column}}": "Cartão movido para {{column}}",
"Moved column to {{column}}": "Coluna movida para {{column}}",
"Multi-select": "Seleção múltipla",
"New row": "Nova linha",
"Next month": "Próximo mês",
"Next week": "Próxima semana",
"Next year": "Próximo ano",
"No filters applied": "Nenhum filtro aplicado",
"No match": "Sem correspondência",
"No options for this property type": "Sem opções para este tipo de propriedade",
"No sorts applied": "Nenhuma ordenação aplicada",
"No value": "Sem valor",
"None": "Nenhum",
"Not started": "Não iniciado",
"Number": "Número",
"One month ago": "Há um mês",
"One month from now": "Daqui a um mês",
"One week ago": "Há uma semana",
"One week from now": "Daqui a uma semana",
"Open as page": "Abrir como página",
"Options": "Opções",
"Past month": "Mês passado",
"Past week": "Semana passada",
"Past year": "Ano passado",
"Percent": "Porcentagem",
"Period, comma": "Ponto, vírgula",
"Person": "Pessoa",
"Please enter a valid email": "Por favor, insira um e-mail válido",
"Progress": "Progresso",
"Property options": "Opções da propriedade",
"Relative": "Relativo",
"Rename property": "Renomear propriedade",
"Reset": "Redefinir",
"Save for everyone": "Salvar para todos",
"Select": "Seleção",
"Select a property": "Selecione uma propriedade",
"Select all loaded rows": "Selecionar todas as linhas carregadas",
"Show all": "Mostrar tudo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espaço, vírgula",
"Space, period": "Espaço, ponto",
"This board has no grouping property yet.": "Este quadro ainda não tem uma propriedade de agrupamento.",
"This month": "Este mês",
"This year": "Este ano",
"Thousands and decimal separators": "Separadores de milhares e decimais",
"Time format": "Formato de hora",
"To Do": "A fazer",
"Tomorrow": "Amanhã",
"True": "Verdadeiro",
"Unsaved changes": "Alterações não salvas",
"Untitled base": "Base sem título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Visualização atualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Темная",
"Date": "Дата",
"Delete": "Удалить",
"Remove from page": "Удалить со страницы",
"Base options": "Параметры базы",
"Delete group": "Удалить группу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
@@ -76,6 +78,24 @@
"Failed to import pages": "Не удалось импортировать страницы",
"Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.",
"Failed to update data": "Не удалось обновить данные",
"Failed to create base": "Не удалось создать базу",
"Failed to update base": "Не удалось обновить базу",
"Failed to delete base": "Не удалось удалить базу",
"Failed to create property": "Не удалось создать свойство",
"Failed to update property": "Не удалось обновить свойство",
"Failed to delete property": "Не удалось удалить свойство",
"Failed to reorder property": "Не удалось изменить порядок свойств",
"Failed to create view": "Не удалось создать представление",
"Failed to update view": "Не удалось обновить представление",
"Failed to delete view": "Не удалось удалить представление",
"Failed to create row": "Не удалось создать строку",
"Failed to update row": "Не удалось обновить строку",
"Failed to delete row": "Не удалось удалить строку",
"Failed to delete rows": "Не удалось удалить строки",
"Failed to reorder row": "Не удалось изменить порядок строки",
"Failed to move card": "Не удалось переместить карточку",
"Failed to add card": "Не удалось добавить карточку",
"Failed to export CSV": "Не удалось экспортировать CSV",
"Favorite spaces": "Избранные пространства",
"Favorite spaces appear here": "Здесь отображаются избранные пространства",
"Favorites": "Избранное",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
"Insert and design Drawio diagrams": "Вставляйте и редактируйте диаграммы Drawio",
"Insert current date": "Вставить текущую дату",
"Time": "Время",
"Insert current time": "Вставить текущее время",
"Draw and sketch excalidraw diagrams": "Рисуйте и создавайте диаграммы Excalidraw",
"Multiple": "Несколько",
"Turn into": "Преобразовать в",
@@ -424,6 +446,7 @@
"Names do not match": "Названия не совпадают",
"Today, {{time}}": "Сегодня, {{time}}",
"Yesterday, {{time}}": "Вчера, {{time}}",
"now": "сейчас",
"Space created successfully": "Пространство успешно создано",
"Space updated successfully": "Пространство успешно обновлено",
"Space deleted successfully": "Пространство успешно удалено",
@@ -596,6 +619,8 @@
"Deleted by": "Удалено пользователем",
"Deleted at": "Удалено",
"Preview": "Предпросмотр",
"Base preview unavailable": "Предпросмотр базы недоступен",
"Restore this base to view its contents.": "Восстановите эту базу, чтобы просмотреть её содержимое.",
"Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Вы уверены, что хотите удалить этот шаблон?",
"Template scope updated": "Область действия шаблона обновлена",
"Choose which space this template belongs to": "Выберите, к какому пространству относится этот шаблон",
"Only workspace admins can create global templates.": "Только администраторы рабочего пространства могут создавать глобальные шаблоны.",
"You need edit access to this space to create templates.": "Чтобы создавать шаблоны, вам нужен доступ на редактирование этого пространства.",
"Scope": "Область действия",
"Select scope": "Выберите область действия",
"Title": "Заголовок",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Поиск страниц и пространств...",
"No results found": "Результаты не найдены",
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
"Chat menu": "Меню чата",
"Chat menu for {{title}}": "Меню чата для {{title}}",
"API key menu": "Меню API-ключа",
"Jump to comment selection": "Перейти к выбору комментария",
"Slash commands": "Команды со слешем",
@@ -1063,7 +1090,7 @@
"Filter": "Фильтр",
"Page title": "Заголовок страницы",
"Page content": "Содержимое страницы",
"Member actions": "Действия с участником",
"Member actions for {{name}}": "Действия с участниками для {{name}}",
"Toggle password visibility": "Переключить видимость пароля",
"Send comment": "Отправить комментарий",
"Token actions": "Действия с токеном",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{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."
"Allow personal spaces": "Разрешить личные пространства",
"Members can create their own personal space.": "Участники могут создавать свои собственные личные пространства.",
"Toggle allow personal spaces": "Переключить разрешение личных пространств",
"Create personal space": "Создать личное пространство",
"Personal space": "Личное пространство",
"{{name}}'s space": "Пространство {{name}}",
"Apply": "Применить",
"Cells that aren't already a page reference will be cleared.": "Ячейки, которые ещё не являются ссылкой на страницу, будут очищены.",
"Cells that aren't a valid URL will be cleared.": "Ячейки, которые не содержат допустимый URL, будут очищены.",
"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 number will be cleared.": "Ячейки, которые нельзя распознать как число, будут очищены.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Значения в ячейках будут приведены к типу (yes/true/1 станут отмеченными; всё остальное станет неотмеченным или будет очищено).",
"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 option names.": "Ячейки будут заменены списком названий параметров, разделённых запятыми.",
"Cells will be replaced with the option name.": "Ячейки будут заменены названием параметра.",
"Cells will be replaced with the page title.": "Ячейки будут заменены заголовком страницы.",
"Cells will be replaced with the person's name.": "Ячейки будут заменены именем человека.",
"Change type": "Изменить тип",
"Change type to {{label}}?": "Изменить тип на {{label}}?",
"Converting…": "Преобразование…",
"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.": "Для каждой строки будет сохранён только первый выбранный элемент; остальные будут отброшены.",
"Previous record": "Предыдущая запись",
"Next record": "Следующая запись",
"Record actions": "Действия с записью",
"Delete record": "Удалить запись",
"Delete record?": "Удалить запись?",
"This action cannot be undone.": "Это действие нельзя отменить.",
"to navigate": "для навигации",
"to close": "для закрытия",
"Expand row {{number}}": "Развернуть строку {{number}}",
"Saving…": "Сохранение…",
"Read-only": "Только для чтения",
"Loading…": "Загрузка…",
"Updated {{when}}": "Обновлено {{when}}",
"Add property": "Добавить свойство",
"Create property": "Создать свойство",
"Hide properties": "Скрыть свойства",
"Find a property type": "Найти тип свойства",
"Properties": "Свойства",
"A property with this name already exists": "Свойство с таким именем уже существует",
"Add card": "Добавить карточку",
"Add filter": "Добавить фильтр",
"Add option": "Добавить вариант",
"Add sort": "Добавить сортировку",
"Add view": "Добавить представление",
"All data in this column will be lost.": "Все данные в этом столбце будут потеряны.",
"Allow multiple people": "Разрешить нескольких пользователей",
"Alphabetize": "По алфавиту",
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
"Ascending": "По возрастанию",
"Base deleted": "База удалена",
"Base name": "Название базы",
"Base table": "Таблица базы",
"Card properties": "Свойства карточки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Ячейки, длина которых превышает ограничение для текста, будут обрезаны, а лишнее содержимое будет безвозвратно потеряно.",
"Checkbox": "Флажок",
"Checked by default": "Отмечено по умолчанию",
"Choose a property": "Выберите свойство",
"Clear selection": "Очистить выбор",
"Column options": "Параметры столбца",
"Comma, period": "Запятая, точка",
"Complete": "Завершено",
"Contains": "Содержит",
"Copy link to view": "Скопировать ссылку на представление",
"Create a status property": "Создать свойство статуса",
"Created at": "Создано",
"Currency": "Валюта",
"Decimal places": "Десятичные знаки",
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
"Done": "Готово",
"Edit formula": "Редактировать формулу",
"Edit property": "Редактировать свойство",
"Export CSV": "Экспорт CSV",
"Failed to load base": "Не удалось загрузить базу",
"False": "Ложь",
"File": "Файл",
"Filter and sort changes are visible only to you": "Изменения фильтра и сортировки видны только вам",
"Filter by": "Фильтр по",
"Formula": "Формула",
"Get started with": "Начать работу с",
"Greater than": "Больше чем",
"Group by": "Группировать по",
"Group this board by a select or status property.": "Сгруппируйте эту доску по свойству выбора или статуса.",
"Hide all": "Скрыть все",
"Hide group": "Скрыть группу",
"In Progress": "В процессе",
"In progress": "В процессе",
"Include time": "Включить время",
"Insert a kanban board on this page": "Вставить канбан-доску на эту страницу",
"Insert an inline base on this page": "Вставить встроенную базу на эту страницу",
"Is": "Равно",
"Is after": "После",
"Is any of": "Совпадает с любым из",
"Is before": "До",
"Is empty": "Пусто",
"Is none of": "Не совпадает ни с одним из",
"Is not": "Не равно",
"Is not empty": "Не пусто",
"Is on or after": "В этот день или позже",
"Is on or before": "В этот день или раньше",
"Is within": "Находится в пределах",
"Keep editing": "Продолжить редактирование",
"Last edited at": "Последнее изменение",
"Last edited by": "Последний изменивший",
"Less than": "Меньше чем",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Local": "Локальный",
"Long text": "Длинный текст",
"Moved card to {{column}}": "Карточка перемещена в {{column}}",
"Moved column to {{column}}": "Столбец перемещён в {{column}}",
"Multi-select": "Множественный выбор",
"New row": "Новая строка",
"Next month": "Следующий месяц",
"Next week": "Следующая неделя",
"Next year": "Следующий год",
"No filters applied": "Фильтры не применены",
"No match": "Нет совпадений",
"No options for this property type": "Для этого типа свойства нет параметров",
"No sorts applied": "Сортировка не применена",
"No value": "Нет значения",
"None": "Нет",
"Not started": "Не начато",
"Number": "Число",
"One month ago": "Месяц назад",
"One month from now": "Через месяц",
"One week ago": "Неделю назад",
"One week from now": "Через неделю",
"Open as page": "Открыть как страницу",
"Options": "Параметры",
"Past month": "Прошлый месяц",
"Past week": "Прошлая неделя",
"Past year": "Прошлый год",
"Percent": "Процент",
"Period, comma": "Точка, запятая",
"Person": "Пользователь",
"Please enter a valid email": "Пожалуйста, введите корректный адрес электронной почты",
"Progress": "Прогресс",
"Property options": "Параметры свойства",
"Relative": "Относительный",
"Rename property": "Переименовать свойство",
"Reset": "Сбросить",
"Save for everyone": "Сохранить для всех",
"Select": "Выбор",
"Select a property": "Выберите свойство",
"Select all loaded rows": "Выбрать все загруженные строки",
"Show all": "Показать все",
"Sort": "Сортировка",
"Sort by": "Сортировать по",
"Space, comma": "Пробел, запятая",
"Space, period": "Пробел, точка",
"This board has no grouping property yet.": "У этой доски пока нет свойства для группировки.",
"This month": "Этот месяц",
"This year": "Этот год",
"Thousands and decimal separators": "Разделители тысяч и десятичных знаков",
"Time format": "Формат времени",
"To Do": "К выполнению",
"Tomorrow": "Завтра",
"True": "Истина",
"Unsaved changes": "Несохранённые изменения",
"Untitled base": "База без названия",
"URL": "URL",
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Темна",
"Date": "Дата",
"Delete": "Видалити",
"Remove from page": "Видалити зі сторінки",
"Base options": "Параметри бази",
"Delete group": "Видалити групу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
"Description": "Опис",
@@ -76,6 +78,24 @@
"Failed to import pages": "Не вдалося імпортувати сторінки",
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
"Failed to update data": "Не вдалося оновити дані",
"Failed to create base": "Не вдалося створити базу",
"Failed to update base": "Не вдалося оновити базу",
"Failed to delete base": "Не вдалося видалити базу",
"Failed to create property": "Не вдалося створити властивість",
"Failed to update property": "Не вдалося оновити властивість",
"Failed to delete property": "Не вдалося видалити властивість",
"Failed to reorder property": "Не вдалося змінити порядок властивості",
"Failed to create view": "Не вдалося створити подання",
"Failed to update view": "Не вдалося оновити подання",
"Failed to delete view": "Не вдалося видалити подання",
"Failed to create row": "Не вдалося створити рядок",
"Failed to update row": "Не вдалося оновити рядок",
"Failed to delete row": "Не вдалося видалити рядок",
"Failed to delete rows": "Не вдалося видалити рядки",
"Failed to reorder row": "Не вдалося змінити порядок рядка",
"Failed to move card": "Не вдалося перемістити картку",
"Failed to add card": "Не вдалося додати картку",
"Failed to export CSV": "Не вдалося експортувати CSV",
"Favorite spaces": "Обрані простори",
"Favorite spaces appear here": "Тут відображаються обрані простори",
"Favorites": "Обране",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Вставити діаграму Mermaid",
"Insert and design Drawio diagrams": "Вставляйте та створюйте діаграми Drawio",
"Insert current date": "Вставити поточну дату",
"Time": "Час",
"Insert current time": "Вставити поточний час",
"Draw and sketch excalidraw diagrams": "Створюйте та кресліть діаграми Excalidraw",
"Multiple": "Кілька",
"Turn into": "Перетворити",
@@ -424,6 +446,7 @@
"Names do not match": "Назви не збігаються",
"Today, {{time}}": "Сьогодні, {{time}}",
"Yesterday, {{time}}": "Вчора, {{time}}",
"now": "зараз",
"Space created successfully": "Простір успішно створено",
"Space updated successfully": "Простір успішно оновлено",
"Space deleted successfully": "Простір успішно видалено",
@@ -596,6 +619,8 @@
"Deleted by": "Видалив",
"Deleted at": "Видалено",
"Preview": "Попередній перегляд",
"Base preview unavailable": "Попередній перегляд бази недоступний",
"Restore this base to view its contents.": "Відновіть цю базу, щоб переглянути її вміст.",
"Subpages": "Підсторінки",
"Failed to load subpages": "Не вдалося завантажити підсторінки",
"No subpages": "Немає підсторінок",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Ви впевнені, що хочете видалити цей шаблон?",
"Template scope updated": "Область дії шаблону оновлено",
"Choose which space this template belongs to": "Виберіть, до якого простору належить цей шаблон",
"Only workspace admins can create global templates.": "Лише адміністратори робочого простору можуть створювати глобальні шаблони.",
"You need edit access to this space to create templates.": "Щоб створювати шаблони, вам потрібен доступ до редагування цього простору.",
"Scope": "Область дії",
"Select scope": "Вибрати область дії",
"Title": "Назва",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Шукати сторінки та простори...",
"No results found": "Результати не знайдено",
"You don't have permission to create pages here": "У вас немає дозволу на створення сторінок тут",
"Chat menu": "Меню чату",
"Chat menu for {{title}}": "Меню чату для {{title}}",
"API key menu": "Меню ключа API",
"Jump to comment selection": "Перейти до вибору коментаря",
"Slash commands": "Слеш-команди",
@@ -1063,7 +1090,7 @@
"Filter": "Фільтр",
"Page title": "Назва сторінки",
"Page content": "Вміст сторінки",
"Member actions": "Дії з учасником",
"Member actions for {{name}}": "Дії учасника для {{name}}",
"Toggle password visibility": "Перемкнути видимість пароля",
"Send comment": "Надіслати коментар",
"Token actions": "Дії з токеном",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{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."
"Allow personal spaces": "Дозволити особисті простори",
"Members can create their own personal space.": "Учасники можуть створювати власний особистий простір.",
"Toggle allow personal spaces": "Перемкнути дозвіл на особисті простори",
"Create personal space": "Створити особистий простір",
"Personal space": "Особистий простір",
"{{name}}'s space": "Простір {{name}}",
"Apply": "Застосувати",
"Cells that aren't already a page reference will be cleared.": "Клітинки, які ще не є посиланням на сторінку, буде очищено.",
"Cells that aren't a valid URL will be cleared.": "Клітинки, які не є дійсною URL-адресою, буде очищено.",
"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 number will be cleared.": "Клітинки, які не вдається розпізнати як число, буде очищено.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Клітинки буде приведено до потрібного формату (yes/true/1 стануть позначеними; усе інше стане непозначеним або буде очищено).",
"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 option names.": "Клітинки буде замінено списком назв параметрів, розділених комами.",
"Cells will be replaced with the option name.": "Клітинки буде замінено назвою параметра.",
"Cells will be replaced with the page title.": "Клітинки буде замінено заголовком сторінки.",
"Cells will be replaced with the person's name.": "Клітинки буде замінено іменем особи.",
"Change type": "Змінити тип",
"Change type to {{label}}?": "Змінити тип на {{label}}?",
"Converting…": "Перетворення…",
"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.": "Для кожного рядка буде збережено лише перший вибраний елемент; решту буде відкинуто.",
"Previous record": "Попередній запис",
"Next record": "Наступний запис",
"Record actions": "Дії із записом",
"Delete record": "Видалити запис",
"Delete record?": "Видалити запис?",
"This action cannot be undone.": "Цю дію неможливо скасувати.",
"to navigate": "для навігації",
"to close": "для закриття",
"Expand row {{number}}": "Розгорнути рядок {{number}}",
"Saving…": "Збереження…",
"Read-only": "Лише читання",
"Loading…": "Завантаження…",
"Updated {{when}}": "Оновлено {{when}}",
"Add property": "Додати властивість",
"Create property": "Створити властивість",
"Hide properties": "Приховати властивості",
"Find a property type": "Знайти тип властивості",
"Properties": "Властивості",
"A property with this name already exists": "Властивість із такою назвою вже існує",
"Add card": "Додати картку",
"Add filter": "Додати фільтр",
"Add option": "Додати опцію",
"Add sort": "Додати сортування",
"Add view": "Додати подання",
"All data in this column will be lost.": "Усі дані в цьому стовпці буде втрачено.",
"Allow multiple people": "Дозволити кількох людей",
"Alphabetize": "За алфавітом",
"Are you sure you want to delete": "Ви впевнені, що хочете видалити",
"Ascending": "За зростанням",
"Base deleted": "Базу видалено",
"Base name": "Назва бази",
"Base table": "Таблиця бази",
"Card properties": "Властивості картки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Клітинки, довші за обмеження для тексту, буде обрізано, а зайвий вміст буде безповоротно втрачено.",
"Checkbox": "Прапорець",
"Checked by default": "Позначено за замовчуванням",
"Choose a property": "Оберіть властивість",
"Clear selection": "Очистити вибір",
"Column options": "Параметри стовпця",
"Comma, period": "Кома, крапка",
"Complete": "Завершено",
"Contains": "Містить",
"Copy link to view": "Скопіювати посилання на подання",
"Create a status property": "Створити властивість статусу",
"Created at": "Створено",
"Currency": "Валюта",
"Decimal places": "Десяткові знаки",
"Default value": "Значення за замовчуванням",
"Delete property": "Видалити властивість",
"Delete view": "Видалити подання",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "За спаданням",
"Discard": "Скасувати",
"Doesn't contain": "Не містить",
"Done": "Готово",
"Edit formula": "Редагувати формулу",
"Edit property": "Редагувати властивість",
"Export CSV": "Експорт CSV",
"Failed to load base": "Не вдалося завантажити базу",
"False": "Хибність",
"File": "Файл",
"Filter and sort changes are visible only to you": "Зміни фільтрів і сортування видимі лише вам",
"Filter by": "Фільтрувати за",
"Formula": "Формула",
"Get started with": "Почніть із",
"Greater than": "Більше ніж",
"Group by": "Групувати за",
"Group this board by a select or status property.": "Згрупуйте цю дошку за властивістю вибору або статусу.",
"Hide all": "Приховати все",
"Hide group": "Приховати групу",
"In Progress": "У процесі",
"In progress": "У процесі",
"Include time": "Включити час",
"Insert a kanban board on this page": "Вставити канбан-дошку на цю сторінку",
"Insert an inline base on this page": "Вставити вбудовану базу на цю сторінку",
"Is": "Є",
"Is after": "Після",
"Is any of": "Є одним із",
"Is before": "До",
"Is empty": "Порожнє",
"Is none of": "Не є жодним із",
"Is not": "Не є",
"Is not empty": "Не порожнє",
"Is on or after": "У цей день або пізніше",
"Is on or before": "У цей день або раніше",
"Is within": "У межах",
"Keep editing": "Продовжити редагування",
"Last edited at": "Востаннє відредаговано",
"Last edited by": "Востаннє відредагував",
"Less than": "Менше ніж",
"Link copied to clipboard": "Посилання скопійовано в буфер обміну",
"Local": "Локальний",
"Long text": "Довгий текст",
"Moved card to {{column}}": "Картку переміщено до {{column}}",
"Moved column to {{column}}": "Стовпець переміщено до {{column}}",
"Multi-select": "Мультивибір",
"New row": "Новий рядок",
"Next month": "Наступного місяця",
"Next week": "Наступного тижня",
"Next year": "Наступного року",
"No filters applied": "Фільтри не застосовано",
"No match": "Немає збігів",
"No options for this property type": "Немає опцій для цього типу властивості",
"No sorts applied": "Сортування не застосовано",
"No value": "Немає значення",
"None": "Немає",
"Not started": "Не розпочато",
"Number": "Число",
"One month ago": "Місяць тому",
"One month from now": "Через місяць",
"One week ago": "Тиждень тому",
"One week from now": "Через тиждень",
"Open as page": "Відкрити як сторінку",
"Options": "Опції",
"Past month": "За минулий місяць",
"Past week": "За минулий тиждень",
"Past year": "За минулий рік",
"Percent": "Відсоток",
"Period, comma": "Крапка, кома",
"Person": "Особа",
"Please enter a valid email": "Будь ласка, введіть коректну електронну адресу",
"Progress": "Прогрес",
"Property options": "Параметри властивості",
"Relative": "Відносний",
"Rename property": "Перейменувати властивість",
"Reset": "Скинути",
"Save for everyone": "Зберегти для всіх",
"Select": "Вибір",
"Select a property": "Виберіть властивість",
"Select all loaded rows": "Вибрати всі завантажені рядки",
"Show all": "Показати все",
"Sort": "Сортування",
"Sort by": "Сортувати за",
"Space, comma": "Пробіл, кома",
"Space, period": "Пробіл, крапка",
"This board has no grouping property yet.": "Ця дошка ще не має властивості групування.",
"This month": "Цього місяця",
"This year": "Цього року",
"Thousands and decimal separators": "Роздільники тисяч і десяткових знаків",
"Time format": "Формат часу",
"To Do": "До виконання",
"Tomorrow": "Завтра",
"True": "Істина",
"Unsaved changes": "Незбережені зміни",
"Untitled base": "База без назви",
"URL": "URL",
"Value": "Значення",
"View updated for everyone": "Подання оновлено для всіх",
"You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "深色",
"Date": "日期",
"Delete": "删除",
"Remove from page": "从页面中移除",
"Base options": "数据库选项",
"Delete group": "删除群组",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "您确定要删除这个页面吗?这将删除其子页面和页面历史记录。此操作不可逆。",
"Description": "描述",
@@ -76,6 +78,24 @@
"Failed to import pages": "导入页面失败",
"Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。",
"Failed to update data": "数据更新失败",
"Failed to create base": "创建数据库失败",
"Failed to update base": "更新数据库失败",
"Failed to delete base": "删除数据库失败",
"Failed to create property": "创建属性失败",
"Failed to update property": "更新属性失败",
"Failed to delete property": "删除属性失败",
"Failed to reorder property": "重新排序属性失败",
"Failed to create view": "创建视图失败",
"Failed to update view": "更新视图失败",
"Failed to delete view": "删除视图失败",
"Failed to create row": "创建记录失败",
"Failed to update row": "更新记录失败",
"Failed to delete row": "删除记录失败",
"Failed to delete rows": "删除记录失败",
"Failed to reorder row": "重新排序记录失败",
"Failed to move card": "移动卡片失败",
"Failed to add card": "添加卡片失败",
"Failed to export CSV": "导出 CSV 失败",
"Favorite spaces": "收藏的空间",
"Favorite spaces appear here": "收藏的空间会显示在这里",
"Favorites": "收藏",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "插入 Mermaid 图表",
"Insert and design Drawio diagrams": "插入并设计 Drawio 图表",
"Insert current date": "插入当前日期",
"Time": "时间",
"Insert current time": "插入当前时间",
"Draw and sketch excalidraw diagrams": "绘制和草绘 Excalidraw 图表",
"Multiple": "多个",
"Turn into": "变成",
@@ -424,6 +446,7 @@
"Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}",
"now": "现在",
"Space created successfully": "空间创建成功",
"Space updated successfully": "空间更新成功",
"Space deleted successfully": "空间删除成功",
@@ -596,6 +619,8 @@
"Deleted by": "删除者",
"Deleted at": "删除于",
"Preview": "预览",
"Base preview unavailable": "无法预览数据库",
"Restore this base to view its contents.": "恢复此数据库以查看其内容。",
"Subpages": "子页面",
"Failed to load subpages": "加载子页面失败",
"No subpages": "没有子页面",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "你确定要删除此模板吗?",
"Template scope updated": "模板范围已更新",
"Choose which space this template belongs to": "选择此模板所属的空间",
"Only workspace admins can create global templates.": "只有工作区管理员才能创建全局模板。",
"You need edit access to this space to create templates.": "您需要拥有此空间的编辑权限才能创建模板。",
"Scope": "范围",
"Select scope": "选择范围",
"Title": "标题",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "搜索页面和空间……",
"No results found": "未找到结果",
"You don't have permission to create pages here": "你无权在此处创建页面",
"Chat menu": "聊天菜单",
"Chat menu for {{title}}": "{{title}} 的聊天菜单",
"API key menu": "API 密钥菜单",
"Jump to comment selection": "跳转到评论选择",
"Slash commands": "斜杠命令",
@@ -1063,7 +1090,7 @@
"Filter": "筛选",
"Page title": "页面标题",
"Page content": "页面内容",
"Member actions": "成员操作",
"Member actions for {{name}}": "{{name}} 的成员操作",
"Toggle password visibility": "切换密码可见性",
"Send comment": "发送评论",
"Token actions": "令牌操作",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{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."
"Allow personal spaces": "允许个人空间",
"Members can create their own personal space.": "成员可以创建自己的个人空间。",
"Toggle allow personal spaces": "切换是否允许个人空间",
"Create personal space": "创建个人空间",
"Personal space": "个人空间",
"{{name}}'s space": "{{name}} 的空间",
"Apply": "应用",
"Cells that aren't already a page reference will be cleared.": "尚未是页面引用的单元格将被清空。",
"Cells that aren't a valid URL will be cleared.": "不是有效 URL 的单元格将被清空。",
"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 number will be cleared.": "无法解析为数字的单元格将被清空。",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "单元格将被强制转换(yes/true/1 将变为选中;其他所有值将变为未选中或被清空)。",
"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 option names.": "单元格将被替换为以逗号分隔的选项名称列表。",
"Cells will be replaced with the option name.": "单元格将被替换为选项名称。",
"Cells will be replaced with the page title.": "单元格将被替换为页面标题。",
"Cells will be replaced with the person's name.": "单元格将被替换为此人的姓名。",
"Change type": "更改类型",
"Change type to {{label}}?": "将类型更改为 {{label}}",
"Converting…": "正在转换…",
"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.": "每行仅保留第一个已选项目;其余项目将被丢弃。",
"Previous record": "上一条记录",
"Next record": "下一条记录",
"Record actions": "记录操作",
"Delete record": "删除记录",
"Delete record?": "删除记录?",
"This action cannot be undone.": "此操作无法撤销。",
"to navigate": "用于导航",
"to close": "用于关闭",
"Expand row {{number}}": "展开第 {{number}} 行",
"Saving…": "正在保存…",
"Read-only": "只读",
"Loading…": "正在加载…",
"Updated {{when}}": "更新于 {{when}}",
"Add property": "添加属性",
"Create property": "创建属性",
"Hide properties": "隐藏属性",
"Find a property type": "查找属性类型",
"Properties": "属性",
"A property with this name already exists": "已存在同名属性",
"Add card": "添加卡片",
"Add filter": "添加筛选",
"Add option": "添加选项",
"Add sort": "添加排序",
"Add view": "添加视图",
"All data in this column will be lost.": "此列中的所有数据都将丢失。",
"Allow multiple people": "允许多人",
"Alphabetize": "按字母顺序",
"Are you sure you want to delete": "您确定要删除",
"Ascending": "升序",
"Base deleted": "资料库已删除",
"Base name": "资料库名称",
"Base table": "资料库表",
"Card properties": "卡片属性",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "超出文本长度限制的单元格内容将被截断,额外内容将永久丢失。",
"Checkbox": "复选框",
"Checked by default": "默认选中",
"Choose a property": "选择一个属性",
"Clear selection": "清除选择",
"Column options": "列选项",
"Comma, period": "逗号、句点",
"Complete": "完成",
"Contains": "包含",
"Copy link to view": "复制视图链接",
"Create a status property": "创建状态属性",
"Created at": "创建于",
"Currency": "货币",
"Decimal places": "小数位数",
"Default value": "默认值",
"Delete property": "删除属性",
"Delete view": "删除视图",
"Delete {{count}} rows?_one": "删除 1 行?",
"Delete {{count}} rows?_other": "删除 {{count}} 行?",
"Descending": "降序",
"Discard": "放弃",
"Doesn't contain": "不包含",
"Done": "完成",
"Edit formula": "编辑公式",
"Edit property": "编辑属性",
"Export CSV": "导出 CSV",
"Failed to load base": "加载资料库失败",
"False": "假",
"File": "文件",
"Filter and sort changes are visible only to you": "筛选和排序更改仅对您可见",
"Filter by": "筛选条件",
"Formula": "公式",
"Get started with": "开始使用",
"Greater than": "大于",
"Group by": "分组依据",
"Group this board by a select or status property.": "按选择或状态属性对此看板进行分组。",
"Hide all": "全部隐藏",
"Hide group": "隐藏分组",
"In Progress": "进行中",
"In progress": "进行中",
"Include time": "包含时间",
"Insert a kanban board on this page": "在此页面插入一个看板",
"Insert an inline base on this page": "在此页面插入一个内联资料库",
"Is": "是",
"Is after": "在此之后",
"Is any of": "是其中之一",
"Is before": "在此之前",
"Is empty": "为空",
"Is none of": "都不是",
"Is not": "不是",
"Is not empty": "不为空",
"Is on or after": "在此日期或之后",
"Is on or before": "在此日期或之前",
"Is within": "在范围内",
"Keep editing": "继续编辑",
"Last edited at": "最后编辑于",
"Last edited by": "最后编辑者",
"Less than": "小于",
"Link copied to clipboard": "链接已复制到剪贴板",
"Local": "本地",
"Long text": "长文本",
"Moved card to {{column}}": "已将卡片移动到 {{column}}",
"Moved column to {{column}}": "已将列移动到 {{column}}",
"Multi-select": "多选",
"New row": "新行",
"Next month": "下个月",
"Next week": "下周",
"Next year": "明年",
"No filters applied": "未应用筛选",
"No match": "无匹配项",
"No options for this property type": "此属性类型没有可用选项",
"No sorts applied": "未应用排序",
"No value": "无值",
"None": "无",
"Not started": "未开始",
"Number": "数字",
"One month ago": "一个月前",
"One month from now": "从现在起一个月后",
"One week ago": "一周前",
"One week from now": "从现在起一周后",
"Open as page": "作为页面打开",
"Options": "选项",
"Past month": "过去一个月",
"Past week": "过去一周",
"Past year": "过去一年",
"Percent": "百分比",
"Period, comma": "句点、逗号",
"Person": "人员",
"Please enter a valid email": "请输入有效的电子邮件地址",
"Progress": "进度",
"Property options": "属性选项",
"Relative": "相对",
"Rename property": "重命名属性",
"Reset": "重置",
"Save for everyone": "保存给所有人",
"Select": "选择",
"Select a property": "选择一个属性",
"Select all loaded rows": "选择所有已加载的行",
"Show all": "显示全部",
"Sort": "排序",
"Sort by": "排序依据",
"Space, comma": "空格、逗号",
"Space, period": "空格、句点",
"This board has no grouping property yet.": "此看板尚无分组属性。",
"This month": "本月",
"This year": "今年",
"Thousands and decimal separators": "千位分隔符和小数分隔符",
"Time format": "时间格式",
"To Do": "待办",
"Tomorrow": "明天",
"True": "真",
"Unsaved changes": "未保存的更改",
"Untitled base": "未命名资料库",
"URL": "网址",
"Value": "值",
"View updated for everyone": "已为所有人更新视图",
"You have unsaved changes. Do you want to discard them?": "您有未保存的更改。要放弃这些更改吗?",
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项"
}
@@ -6,13 +6,21 @@ import {
Select,
Switch,
Divider,
Tooltip,
Badge,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import {
exportPage,
exportPageToDocx,
} from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
import { Feature } from "@/ee/features";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface ExportModalProps {
id: string;
@@ -32,17 +40,25 @@ export default function ExportModal({
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation();
const upgradeLabel = useUpgradeLabel();
const isDocx = format === ExportFormat.Docx;
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
const blockedByLicense = isDocx && !docxEntitled;
const handleExport = async () => {
setIsExporting(true);
try {
if (type === "page") {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
if (format === ExportFormat.Docx) {
await exportPageToDocx({ pageId: id });
} else {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
@@ -88,10 +104,15 @@ export default function ExportModal({
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
<ExportFormatSelection
format={format}
onChange={handleChange}
includeDocx={type === "page"}
docxEntitled={docxEntitled}
/>
</Group>
{type === "page" && (
{type === "page" && !isDocx && (
<>
<Divider my="sm" />
@@ -143,7 +164,16 @@ export default function ExportModal({
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
<Button
onClick={handleExport}
loading={isExporting}
disabled={blockedByLicense}
data-disabled={blockedByLicense || undefined}
>
{t("Export")}
</Button>
</Tooltip>
</Group>
</Modal.Body>
</Modal.Content>
@@ -154,23 +184,49 @@ export default function ExportModal({
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
includeDocx?: boolean;
docxEntitled?: boolean;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
function ExportFormatSelection({
format,
onChange,
includeDocx,
docxEntitled,
}: ExportFormatSelection) {
const { t } = useTranslation();
const data = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
...(includeDocx
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
: []),
];
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
data={data}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
comboboxProps={{ width: 200 }}
allowDeselect={false}
withCheckIcon={false}
aria-label={t("Select export format")}
renderOption={({ option }) =>
option.value === "docx" && !docxEntitled ? (
<div>
<Text size="sm" c="dimmed">
{option.label}
</Text>
<Badge size="xs" mt={4}>
{t("Enterprise")}
</Badge>
</div>
) : (
<Text size="sm">{option.label}</Text>
)
}
/>
);
}
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
<Text component="h2" 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")}
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
IconUser,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { getSpaceUrl } from "@/lib/config.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
const [
createOpened,
{ open: openCreate, close: closeCreate },
] = useDisclosure(false);
if (!user || !workspace) {
return <></>;
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
@@ -115,6 +131,26 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
{personalSpace ? (
<Menu.Item
component={Link}
to={getSpaceUrl(personalSpace.slug)}
leftSection={<IconUser size={16} />}
>
{t("Personal space")}
</Menu.Item>
) : (
hasPersonalSpaces &&
settingEnabled && (
<Menu.Item
onClick={openCreate}
leftSection={<IconUser size={16} />}
>
{t("Create personal space")}
</Menu.Item>
)
)}
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
@@ -160,5 +196,8 @@ export default function TopMenu() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}
+21 -10
View File
@@ -16,13 +16,10 @@ 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).
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
// Avoids lime/yellow/green/orange, too light even at dark shades.
// For non-filled variants, initials text is forced to the .9 shade at render time:
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
@@ -54,12 +51,24 @@ function sanitizeInitialsSource(name: string) {
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const isInitials = !color || color === "initials";
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const hue = pickedColor.split(".")[0];
const initialsSource = sanitizeInitialsSource(name ?? "");
const resolvedColor = variant === "filled" ? pickedColor : hue;
const placeholderStyles =
isInitials && variant !== "filled"
? {
placeholder: {
color: `var(--mantine-color-${hue}-9)`,
},
}
: undefined;
return (
<Avatar
ref={ref}
@@ -67,6 +76,8 @@ export const CustomAvatar = React.forwardRef<
name={initialsSource}
alt={name}
color={resolvedColor}
variant={variant}
styles={placeholderStyles}
{...props}
/>
);
@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
export const RadioMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
));
RadioMenuItem.displayName = "RadioMenuItem";
+39 -1
View File
@@ -1 +1,39 @@
Files in this directory are subject to the Docmost Enterprise Edition license.
Files in this directory are subject to the Docmost Enterprise Edition license.
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -66,6 +66,8 @@ export default function AiChatSidebarItem({
[chat.updatedAt, i18n.language],
);
const chatTitle = chat.title || t("Untitled chat");
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
@@ -120,9 +122,7 @@ export default function AiChatSidebarItem({
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemTitle}>{chatTitle}</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
>
<IconDots size={14} />
</ActionIcon>
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useCallback, useId, 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";
@@ -107,6 +107,7 @@ export default function ChatInput({
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const plusMenuId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
@@ -176,7 +177,7 @@ export default function ChatInput({
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
@@ -263,7 +264,7 @@ export default function ChatInput({
});
useEffect(() => {
if (editor && autofocus) {
if (editor && !editor.isDestroyed && autofocus) {
editor.commands.focus();
}
}, [editor]);
@@ -342,6 +343,7 @@ export default function ChatInput({
position="top-start"
width={220}
shadow="md"
withRoles={false}
trapFocus
returnFocus
>
@@ -351,13 +353,17 @@ export default function ChatInput({
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
aria-haspopup="menu"
aria-expanded={plusMenuOpen}
aria-controls={plusMenuOpen ? plusMenuId : undefined}
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
@@ -377,6 +383,7 @@ export default function ChatInput({
</button>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
@@ -385,7 +392,7 @@ export default function ChatInput({
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
{t("Mention a page")}
</button>
</Popover.Dropdown>
</Popover>
@@ -76,7 +76,6 @@
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;
@@ -1,7 +1,14 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
@@ -14,7 +21,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 { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
if (!isEditorReady(editor) || !showAiMenu) return;
const { view } = editor;
const { from, to } = editor.state.selection;
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
if (!isEditorReady(editor) || isLoading) return;
let command: CommandItem | null = item || null;
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
editor
.chain()
.focus()
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
useEffect(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Collapse expanded={isExpanded}>
<Box
px="md"
py="sm"
+13 -1
View File
@@ -1,6 +1,6 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell } from "@/ee/base/types/base.types";
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
// the same page don't share UI state.
@@ -41,3 +41,15 @@ export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
export const focusedCellAtomFamily = atomFamily((_pageId: string) =>
atom<FocusedCell>(null),
);
export type PendingTypeInsert = {
rowId: string;
propertyId: string;
char: string;
} | null;
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
@@ -18,7 +18,7 @@ type BaseTableProps = {
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
@@ -20,7 +20,7 @@ import {
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { getApiErrorMessage } from "@/lib/api-error";
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
@@ -61,7 +61,6 @@ export function BaseToolbar({
getViewShareUrl,
}: BaseToolbarProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false);
@@ -78,7 +77,7 @@ export function BaseToolbar({
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
message: getApiErrorMessage(err, t("Failed to export CSV")),
});
} finally {
setExporting(false);
@@ -138,19 +137,17 @@ export function BaseToolbar({
/>
<div className={classes.toolbarRight}>
{editable && (
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
<ViewFilterConfigPopover
opened={filterOpened}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { IconTable } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
@@ -12,6 +12,7 @@ import {
FilterGroup,
ViewSortConfig,
EditingCell,
FocusedCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
@@ -25,6 +26,7 @@ import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
focusedCellAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
@@ -89,6 +91,10 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setFocusedCell] = useAtom(
focusedCellAtomFamily(pageId),
) as unknown as [FocusedCell, (val: FocusedCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
@@ -221,33 +227,42 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
[editable, pageId, updateRow],
);
const handleAddRow = useCallback(() => {
if (!editable) return;
createRowMutation.mutate(
{ pageId },
{
onSuccess: (newRow) => {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
const propertyId = (
firstEditable?.columnDef.meta?.property as IBaseProperty | undefined
)?.id;
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
}
const handleAddRow = useCallback(
(afterRowId?: string, focusPropertyId?: string) => {
if (!editable) return;
createRowMutation.mutate(
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
{
onSuccess: (newRow) => {
let propertyId = focusPropertyId;
if (!propertyId) {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
propertyId = (
firstEditable?.columnDef.meta?.property as
| IBaseProperty
| undefined
)?.id;
}
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
setFocusedCell({ rowId: newRow.id, propertyId });
}
},
},
},
);
}, [editable, pageId, createRowMutation, table, setEditingCell]);
);
},
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
);
const handleViewChange = useCallback(
(viewId: string) => {
@@ -364,14 +379,14 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<IconTable size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
// Ghost rows are an "empty database" affordance, not a "filter matched nothing" state.
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
const banner = (
@@ -15,9 +15,18 @@ type CellEmailProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) {
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -125,6 +125,7 @@ export function CellFile({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -13,6 +13,7 @@ type CellLongTextProps = {
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
@@ -27,6 +28,7 @@ export function CellLongText({
onCommit,
onValueChange,
onCancel,
onTabNavigate,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
@@ -127,7 +129,11 @@ export function CellLongText({
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
if (e.key === "Tab") {
e.preventDefault();
commit();
onTabNavigate?.(e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
@@ -16,7 +16,7 @@ import {
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
@@ -109,7 +109,7 @@ export function CellMultiSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -3,6 +3,7 @@ import {
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { snapNumber } from "@docmost/base-formula/client";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
@@ -16,49 +17,98 @@ type CellNumberProps = {
onCancel: () => void;
};
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
comma_period: { group: ",", decimal: "." },
period_comma: { group: ".", decimal: "," },
space_comma: { group: " ", decimal: "," },
space_period: { group: " ", decimal: "." },
};
function separatorChars(style: string): { group: string; decimal: string } {
if (style === "local") {
const parts = new Intl.NumberFormat().formatToParts(11111.1);
return {
group: parts.find((p) => p.type === "group")?.value ?? ",",
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
};
}
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
}
function formatPlain(
value: number,
precision: number | undefined,
style: string,
): string {
const fixed = precision == null ? String(value) : value.toFixed(precision);
if (style === "none") return fixed;
const { group, decimal } = separatorChars(style);
const neg = fixed[0] === "-";
const abs = neg ? fixed.slice(1) : fixed;
const dot = abs.indexOf(".");
const intPart = dot === -1 ? abs : abs.slice(0, dot);
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
return neg ? `-${out}` : out;
}
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision ?? 0;
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "separators":
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
}).format(val);
case "currency":
return formatCurrency(val, options?.currencyCode, options?.precision);
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${val.toFixed(precision)}%`;
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function sanitizeNumberInput(text: string): string {
return text.replace(/[^0-9.-]/g, "");
}
export function parseNumberDraft(draft: string): number | null {
const cleaned = sanitizeNumberInput(draft);
if (cleaned === "" || cleaned === "-") return null;
const parsed = Number(cleaned);
return isNaN(parsed) ? null : parsed;
}
export function CellNumber({
value,
property,
rowId,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -74,6 +124,17 @@ export function CellNumber({
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
@@ -237,6 +237,7 @@ function PagePicker({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -142,6 +142,7 @@ export function CellPerson({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -9,7 +9,7 @@ import {
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
@@ -96,7 +96,7 @@ export function CellSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -16,9 +16,18 @@ type CellTextProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, isEditing, onCommit, onCancel }: CellTextProps) {
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -16,9 +16,18 @@ type CellUrlProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, isEditing, onCommit, onCancel }: CellUrlProps) {
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -9,7 +9,7 @@ import {
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
@@ -124,7 +124,7 @@ export function ChoicePicker({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -16,6 +16,12 @@ export const AddRowButton = memo(function AddRowButton({
<div
className={classes.addRowButton}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role="button"
tabIndex={0}
>
@@ -1,12 +1,15 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { flushSync } from "react-dom";
import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
import { useAtom, useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { IBaseRow, EditingCell, FocusedCell } from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
@@ -16,6 +19,7 @@ import {
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell";
@@ -24,6 +28,7 @@ import classes from "@/ee/base/styles/grid.module.css";
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
colIndex?: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
pageId: string;
};
@@ -31,6 +36,7 @@ type GridCellProps = {
export const GridCell = memo(function GridCell({
cell,
rowIndex,
colIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
@@ -44,6 +50,18 @@ export const GridCell = memo(function GridCell({
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setFocusedCell = useSetAtom(focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === cell.row.id && fc?.propertyId === property?.id,
),
[pageId, cell.row.id, property?.id],
),
);
const { t } = useTranslation();
const editable = useBaseEditable();
const readOnly = !editable;
@@ -55,14 +73,14 @@ export const GridCell = memo(function GridCell({
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleDoubleClick = useCallback(() => {
const handleEdit = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable.
if (property.type === "file") {
setEditingCell({ rowId, propertyId: property.id });
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}
return;
}
@@ -71,9 +89,32 @@ export const GridCell = memo(function GridCell({
return;
}
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || e.button !== 0 || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
},
[property, rowId, setFocusedCell, isEditing],
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[property, rowId, setFocusedCell, isEditing],
);
const cellReadOnly = property
? readOnly || isSystemPropertyType(property.type)
: false;
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
@@ -101,6 +142,31 @@ export const GridCell = memo(function GridCell({
setEditingCell(null);
}, [setEditingCell]);
const handleTabNavigate = useCallback(
(shiftKey: boolean) => {
if (!property) return;
const tableInstance = cell.getContext().table;
const colIds = tableInstance
.getVisibleLeafColumns()
.filter((c) => c.id !== "__row_number")
.map((c) => c.id);
const rowIds = tableInstance.getRowModel().rows.map((r) => r.id);
const next = computeNextCell(
rowIds,
colIds,
{ rowId, propertyId: property.id },
0,
shiftKey ? -1 : 1,
true,
);
if (next) {
setEditingCell(next);
setFocusedCell(next);
}
},
[cell, rowId, property, setEditingCell, setFocusedCell],
);
if (isRowNumber) {
return (
<RowNumberCell
@@ -122,13 +188,19 @@ export const GridCell = memo(function GridCell({
const cellInner = (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
id={`base-cell-${rowId}-${property.id}`}
role="gridcell"
aria-colindex={colIndex != null ? colIndex + 1 : undefined}
aria-readonly={cellReadOnly || undefined}
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${isFocused && !isEditing ? classes.cellFocused : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onDoubleClick={handleDoubleClick}
onClick={handleClick}
onMouseDown={handleMouseDown}
onDoubleClick={handleEdit}
>
<CellComponent
value={value}
@@ -139,12 +211,15 @@ export const GridCell = memo(function GridCell({
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
onTabNavigate={handleTabNavigate}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
tabIndex={-1}
data-base-row-expand=""
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
@@ -209,6 +284,7 @@ gridCellPropsEqual);
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.colIndex !== next.colIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
@@ -6,9 +6,24 @@ import {
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { useAtom, useSetAtom, type PrimitiveAtom } from "jotai";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
pendingTypeInsertAtom,
type FormulaEditorTarget,
type PendingTypeInsert,
} from "@/ee/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
@@ -22,6 +37,7 @@ import { AddRowButton } from "./add-row-button";
import { GridGhostRows } from "./grid-ghost-rows";
import { SelectionActionBar } from "./selection-action-bar";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
@@ -51,7 +67,7 @@ type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
onAddRow?: (afterRowId?: string, focusPropertyId?: string) => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
@@ -105,14 +121,23 @@ export function GridContainer({
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
const onExpandRow = useRowExpand();
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const editingCellRef = useRef(editingCell);
editingCellRef.current = editingCell;
const { selectionCount, clear: clearSelection } = useRowSelection(pageId);
const { selectionCount, clear: clearSelection, toggle: toggleRow } = useRowSelection(pageId);
const { deleteSelected } = useDeleteSelectedRows(pageId);
const { t } = useTranslation();
const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void];
const focusedCellRef = useRef(focusedCell);
focusedCellRef.current = focusedCell;
const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setPendingTypeInsert = useSetAtom(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
@@ -141,12 +166,6 @@ export function GridContainer({
useColumnResize(table, onResizeEnd ?? (() => {}));
useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef: bodyRef,
});
// When the scroll container is the window (inline embed mode), the default
// Element-mode observers read scrollTop/scrollLeft, which Window does not
@@ -226,6 +245,177 @@ export function GridContainer({
const virtualItems = virtualizer.getVirtualItems();
const pinnedLeftWidth = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((c) => c.getIsPinned() === "left")
.reduce((sum, c) => sum + c.getSize(), 0),
[table],
);
const scrollCellIntoView = useCallback(
(coord: CellCoord, rowIndex: number) => {
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
requestAnimationFrame(() => {
const scroller = bodyRef.current;
const el = document.getElementById(
`base-cell-${coord.rowId}-${coord.propertyId}`,
);
if (!scroller || !el) return;
const cellRect = el.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const pinned = pinnedLeftWidth();
if (cellRect.left < scRect.left + pinned) {
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
} else if (cellRect.right > scRect.right) {
scroller.scrollLeft += cellRect.right - scRect.right;
}
});
},
[virtualizer, pinnedLeftWidth],
);
useEffect(() => {
if (!editingCell) return;
const idx = rowIdsRef.current.indexOf(editingCell.rowId);
if (idx >= 0) scrollCellIntoView(editingCell, idx);
}, [editingCell, scrollCellIntoView]);
const openEditor = useCallback(
(coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop) return;
if (prop.type === "checkbox") {
if (!editable) return;
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
onCellUpdate(coord.rowId, coord.propertyId, !current);
return;
}
if (!editable) {
if (prop.type === "file") setEditingCell(coord);
return;
}
if (prop.type === "formula") {
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
return;
}
if (isSystemPropertyType(prop.type)) return;
setEditingCell(coord);
},
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
);
const clearCell = useCallback(
(coord: CellCoord) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type)) return;
onCellUpdate(coord.rowId, coord.propertyId, null);
},
[editable, properties, onCellUpdate],
);
const beginTypeToEdit = useCallback(
(coord: CellCoord, char: string) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
if (["text", "number", "url", "email"].includes(prop.type)) {
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
setEditingCell(coord);
} else {
openEditor(coord);
}
},
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
);
const toggleRowSelection = useCallback(
(rowId: string) => {
toggleRow(rowId, {
shiftKey: false,
rowIndex: rowIdsRef.current.indexOf(rowId),
orderedRowIds: rowIdsRef.current,
});
},
[toggleRow],
);
const expandRow = useCallback(
(rowId: string) => {
onExpandRow?.(rowId);
},
[onExpandRow],
);
const prevEditingRef = useRef(editingCell);
useEffect(() => {
const prev = prevEditingRef.current;
prevEditingRef.current = editingCell;
if (prev && !editingCell) {
if (!focusedCellRef.current) setFocusedCell(prev);
const grid = bodyRef.current;
const active = document.activeElement;
if (grid && active && !grid.contains(active)) {
grid.focus({ preventScroll: true });
}
}
}, [editingCell, setFocusedCell]);
useEffect(() => {
const fc = focusedCellRef.current;
if (!fc) return;
const rowOk = rowIds.includes(fc.rowId);
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
if (!rowOk || !colOk) setFocusedCell(null);
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
const handleGridFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (editingCellRef.current || focusedCellRef.current) return;
const firstRow = rowIdsRef.current[0];
const firstCol = table
.getVisibleLeafColumns()
.find((c) => c.id !== "__row_number")?.id;
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
},
[table, setFocusedCell],
);
const handleAddRowBelow = useCallback(
(afterRowId: string, focusPropertyId: string) => {
onAddRow?.(afterRowId, focusPropertyId);
},
[onAddRow],
);
useGridKeyboardNav({
table,
properties,
containerRef: bodyRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
addRow: handleAddRowBelow,
});
const activeCell = editingCell ?? focusedCell;
const activeDescendantId = activeCell
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
: undefined;
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
@@ -244,29 +434,6 @@ export function GridContainer({
}
}, [rows.length]);
useEffect(() => {
const el = bodyRef.current;
if (!el || !pageId) return;
const handler = (e: KeyboardEvent) => {
if (editingCell) return;
const active = document.activeElement as HTMLElement | null;
if (!active || !el.contains(active)) return;
const tag = active.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
return;
}
if (e.key === "Escape" && selectionCount > 0) {
clearSelection();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
e.preventDefault();
void deleteSelected();
}
};
el.addEventListener("keydown", handler);
return () => el.removeEventListener("keydown", handler);
}, [editingCell, selectionCount, clearSelection, deleteSelected, pageId]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
@@ -314,7 +481,7 @@ export function GridContainer({
);
return (
<div role="grid" style={GRID_ROOT_STYLE}>
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
@@ -341,6 +508,13 @@ export function GridContainer({
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
role="grid"
aria-label={t("Base table")}
aria-rowcount={rows.length}
aria-colcount={table.getVisibleLeafColumns().length}
aria-multiselectable
aria-activedescendant={activeDescendantId}
onFocus={handleGridFocus}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
@@ -57,9 +57,12 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection(pageId);
const { selectionCount, toggleAll } = useRowSelection(pageId);
const hasSelection = selectionCount > 0;
const editable = useBaseEditable();
const isHeaderInteractive = editable && !!property && !isRowNumber;
const isRowNumberHeaderInteractive =
isRowNumber && editable && loadedRowIds.length > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
@@ -208,6 +211,10 @@ export const GridHeaderCell = memo(function GridHeaderCell({
return (
<div
ref={cellRef}
role="columnheader"
tabIndex={isHeaderInteractive || isRowNumberHeaderInteractive ? 0 : undefined}
aria-haspopup={isHeaderInteractive ? "menu" : undefined}
aria-label={isRowNumberHeaderInteractive ? t("Select all loaded rows") : undefined}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
@@ -220,6 +227,16 @@ export const GridHeaderCell = memo(function GridHeaderCell({
resizeIntentRef.current = false;
}}
onClick={handleHeaderClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isRowNumber) {
if (isRowNumberHeaderInteractive) toggleAll(loadedRowIds);
} else {
handleHeaderClick();
}
}
}}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
@@ -269,6 +286,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
@@ -160,12 +160,15 @@ export const GridRow = memo(function GridRow({
data-index={rowIndex}
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
aria-rowindex={rowIndex + 1}
aria-selected={isSelected}
>
{row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell, colIndex) => (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
colIndex={colIndex}
onCellUpdate={onCellUpdate}
pageId={pageId}
/>
@@ -1,7 +1,11 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { FocusedCell } from "@/ee/base/types/base.types";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
@@ -26,6 +30,38 @@ export const RowNumberCell = memo(function RowNumberCell({
const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder();
const setFocusedCell = useSetAtom(
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
),
[pageId, rowId],
),
);
const handleCellMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
setFocusedCell({ rowId, propertyId: "__row_number" });
},
[rowId, setFocusedCell],
);
const handleCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setFocusedCell({ rowId, propertyId: "__row_number" });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[rowId, setFocusedCell],
);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
@@ -40,12 +76,16 @@ export const RowNumberCell = memo(function RowNumberCell({
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
id={`base-cell-${rowId}-__row_number`}
role="gridcell"
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
onClick={handleCellClick}
onMouseDown={handleCellMouseDown}
>
<div className={classes.rowNumberCellInner}>
{editable && (
@@ -60,6 +100,7 @@ export const RowNumberCell = memo(function RowNumberCell({
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
tabIndex={-1}
/>
</span>
)}
@@ -42,6 +42,7 @@ export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
tabIndex={-1}
/>
</Tooltip>
</span>
@@ -30,7 +30,7 @@ type BaseKanbanProps = {
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId);
@@ -201,6 +201,7 @@ export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
groupByProperty={groupByProperty}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
@@ -4,7 +4,7 @@ import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
@@ -1,24 +1,27 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu, Text } from "@mantine/core";
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
import { ActionIcon, Text } from "@mantine/core";
import { IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { KanbanColumn } from "@/ee/base/types/base.types";
import { IBaseProperty, KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { KanbanColumnTitle } from "@/ee/base/components/kanban/kanban-column-title";
import { KanbanColumnMenu } from "@/ee/base/components/kanban/kanban-column-menu";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
property: IBaseProperty | undefined;
count?: string;
canEdit: boolean;
onHide: () => void;
onAddCard: () => void;
};
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
export function KanbanColumnHeader({ column, pageId, property, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation();
const dotColor = column.color
? choiceColor(column.color).color as string
@@ -49,22 +52,13 @@ export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onA
background: dotColor,
}}
/>
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
<KanbanColumnTitle column={column} property={property} pageId={pageId} canEdit={canEdit} />
{count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && (
<>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
</Menu.Dropdown>
</Menu>
{property && (
<KanbanColumnMenu property={property} pageId={pageId} onHide={onHide} />
)}
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} />
</ActionIcon>
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Popover, Stack } from "@mantine/core";
import { IconDots, IconEyeOff, IconSettings } from "@tabler/icons-react";
import { useAtom } from "jotai";
import {
propertyMenuCloseRequestAtomFamily,
propertyMenuDirtyAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { IBaseProperty } from "@/ee/base/types/base.types";
import {
MenuItem,
PropertyMenuContent,
} from "@/ee/base/components/property/property-menu";
type KanbanColumnMenuProps = {
property: IBaseProperty;
pageId: string;
onHide: () => void;
};
export function KanbanColumnMenu({ property, pageId, onHide }: KanbanColumnMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [view, setView] = useState<"menu" | "property">("menu");
const [dirty, setDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const handleClose = useCallback(() => {
setOpened(false);
setView("menu");
}, []);
const wasOpenedRef = useRef(opened);
useEffect(() => {
if (wasOpenedRef.current && !opened) setDirty(false);
wasOpenedRef.current = opened;
}, [opened, setDirty]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) return;
if (dirty) {
setCloseRequest(closeRequest + 1);
} else {
handleClose();
}
},
[dirty, closeRequest, setCloseRequest, handleClose],
);
const toggle = useCallback(() => {
if (opened) {
handleOpenChange(false);
} else if (!dirty) {
setOpened(true);
}
}, [opened, dirty, handleOpenChange]);
return (
<Popover
opened={opened}
onChange={handleOpenChange}
onClose={handleClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
aria-label={t("Column options")}
onClick={toggle}
>
<IconDots size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{view === "menu" ? (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconSettings size={14} />}
label={t("Edit property")}
onClick={() => setView("property")}
/>
<MenuItem
icon={<IconEyeOff size={14} />}
label={t("Hide group")}
onClick={() => {
handleClose();
onHide();
}}
/>
</Stack>
) : (
<PropertyMenuContent
property={property}
opened={opened}
onClose={handleClose}
onDirtyChange={setDirty}
pageId={pageId}
initialPanel={property.pendingType ? "main" : "options"}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,113 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Group, Popover, Text, TextInput, UnstyledButton } from "@mantine/core";
import { IBaseProperty, KanbanColumn, SelectTypeOptions } from "@/ee/base/types/base.types";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnTitleProps = {
column: KanbanColumn;
property: IBaseProperty | undefined;
pageId: string;
canEdit: boolean;
};
export function KanbanColumnTitle({ column, property, pageId, canEdit }: KanbanColumnTitleProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState("");
const updateProperty = useUpdatePropertyMutation();
const commit = useCallback(() => {
setOpened(false);
const name = draft.trim();
const options = property?.typeOptions as SelectTypeOptions | undefined;
if (!property || !options || !name || name === column.name) return;
if (!options.choices.some((c) => c.id === column.key)) return;
updateProperty.mutate({
propertyId: property.id,
pageId,
typeOptions: {
...options,
choices: options.choices.map((c) =>
c.id === column.key ? { ...c, name } : c,
),
},
});
}, [draft, property, column.name, column.key, pageId, updateProperty]);
const toggle = useCallback(() => {
if (opened) {
commit();
} else {
setDraft(column.name);
setOpened(true);
}
}, [opened, commit, column.name]);
const cancel = useCallback(() => setOpened(false), []);
if (!canEdit || column.isNoValue || !property) {
return (
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
);
}
return (
<Popover
opened={opened}
onChange={(next) => {
if (!next) commit();
}}
position="bottom-start"
shadow="md"
width={240}
withinPortal
trapFocus
returnFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<UnstyledButton className={classes.columnTitleButton} onClick={toggle}>
<Text fw={600} size="sm" truncate flex={1} ta="left">
{column.name}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancel();
}
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
flex={1}
value={draft}
data-autofocus
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
/>
<Button size="xs" onClick={commit}>
{t("Done")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { type IBase, type IBaseProperty, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
@@ -19,6 +19,7 @@ type KanbanColumnProps = {
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
groupByProperty: IBaseProperty | undefined;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
@@ -33,6 +34,7 @@ export function KanbanColumn({
column,
viewFilter,
groupByPropertyId,
groupByProperty,
canEdit,
onOpenRow,
onHide,
@@ -139,6 +141,7 @@ export function KanbanColumn({
<KanbanColumnHeader
column={column}
pageId={pageId}
property={groupByProperty}
count={count}
canEdit={canEdit}
onHide={() => onHide(column.key)}
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { Stack, Text, Select, Button } from "@mantine/core";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
@@ -36,9 +36,9 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
);
const handleCreateStatus = useCallback(() => {
const todoId = uuid7();
const inProgressId = uuid7();
const completeId = uuid7();
const todoId = generateBaseChoiceId();
const inProgressId = generateBaseChoiceId();
const completeId = generateBaseChoiceId();
createProperty.mutate(
{
pageId,
@@ -67,14 +67,14 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
if (!editable) {
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
</Stack>
);
}
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
{groupableProperties.length > 0 ? (
<Select
@@ -35,7 +35,7 @@ import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { DefaultValuePicker } from "./default-value-picker";
const CHOICE_COLORS = [
@@ -52,9 +52,9 @@ const STATUS_CATEGORIES = [
// Default choices for a new status property, one per category.
export function defaultStatusChoices(): Choice[] {
return [
{ id: uuid7(), name: "Not started", color: "gray", category: "todo" },
{ id: uuid7(), name: "In progress", color: "blue", category: "inProgress" },
{ id: uuid7(), name: "Done", color: "green", category: "complete" },
{ id: generateBaseChoiceId(), name: "Not started", color: "gray", category: "todo" },
{ id: generateBaseChoiceId(), name: "In progress", color: "blue", category: "inProgress" },
{ id: generateBaseChoiceId(), name: "Done", color: "green", category: "complete" },
];
}
@@ -169,7 +169,7 @@ export function ChoiceEditor({
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = uuid7();
const id = generateBaseChoiceId();
setDraft((prev) => {
const colorIndex = prev.length % CHOICE_COLORS.length;
const newChoice: Choice = {
@@ -18,11 +18,12 @@ import {
TypeOptions,
} from "@/ee/base/types/base.types";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
getDescriptor,
defaultTypeOptionsFor,
propertyTypes,
} from "@/ee/base/property-types/property-type.registry";
import { FormulaEditor } from "../formula/formula-editor";
import classes from "@/ee/base/styles/grid.module.css";
@@ -50,6 +51,10 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<"bottom-start" | "top-start">(
"bottom-start",
);
const createPropertyMutation = useCreatePropertyMutation();
@@ -93,10 +98,20 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
setTypeOptions({});
}, []);
const handleOpen = useCallback(() => {
resetState();
setOpened(true);
}, [resetState]);
const handleOpen = useCallback(
(event?: React.SyntheticEvent) => {
resetState();
const trigger = event?.currentTarget as HTMLElement | undefined;
if (trigger) {
const rect = trigger.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
}
setOpened(true);
},
[resetState],
);
const handleClose = useCallback(() => {
// Don't reset state here: resetting mid-close flashes the type picker.
@@ -215,11 +230,23 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
onChange={(o) => {
if (!o) attemptClose();
}}
position="bottom-start"
position={position}
shadow="md"
closeOnClickOutside
closeOnEscape={false}
withinPortal
hideDetached={false}
middlewares={{
flip: false,
shift: true,
size: {
padding: 8,
apply: ({ availableHeight }) => {
const el = scrollRef.current;
if (el) el.style.maxHeight = `${availableHeight}px`;
},
},
}}
>
<Popover.Target>
{renderTarget ? (
@@ -247,6 +274,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
maxWidth: "calc(100vw - 32px)",
}}
>
<div ref={scrollRef} style={{ overflowY: "auto", overflowX: "hidden" }}>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<ScrollArea.Autosize
@@ -381,6 +409,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
</Group>
</Stack>
)}
</div>
</Popover.Dropdown>
</Popover>
</>
@@ -21,6 +21,8 @@ import {
import {
IBaseProperty,
BasePropertyType,
TypeOptions,
SelectTypeOptions,
} from "@/ee/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
@@ -28,7 +30,7 @@ import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
conversionWarning,
@@ -36,7 +38,11 @@ import {
NON_USER_TARGET_TYPES,
} from "./conversion-warning";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import {
isSystemPropertyType,
propertyTypes,
defaultTypeOptionsFor,
} from "@/ee/base/property-types/property-type.registry";
import cellClasses from "@/ee/base/styles/cells.module.css";
import classes from "@/ee/base/styles/property.module.css";
@@ -47,6 +53,7 @@ type PropertyMenuContentProps = {
onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void;
pageId: string;
initialPanel?: "main" | "options";
};
type MenuPanel =
@@ -58,6 +65,31 @@ type MenuPanel =
| "confirmDelete"
| "confirmDiscard";
const CHOICE_TYPES = new Set<BasePropertyType>([
"select",
"multiSelect",
"status",
]);
function typeOptionsForConversion(
source: IBaseProperty,
target: BasePropertyType,
): TypeOptions {
if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) {
return defaultTypeOptionsFor(target);
}
const opts = source.typeOptions as SelectTypeOptions | undefined;
const choices = opts?.choices ?? [];
const choiceOrder = opts?.choiceOrder?.length
? opts.choiceOrder
: choices.map((c) => c.id);
const carried: SelectTypeOptions = { choices, choiceOrder };
if (target === "status") {
carried.defaultValue = choices[0]?.id ?? null;
}
return carried;
}
export function PropertyMenuContent({
property,
opened,
@@ -65,9 +97,10 @@ export function PropertyMenuContent({
onDirtyChange,
onEditFormula,
pageId,
initialPanel,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>("main");
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
@@ -86,12 +119,12 @@ export function PropertyMenuContent({
useEffect(() => {
if (opened) {
setPanel("main");
setPanel(initialPanel ?? "main");
setRenameValue(property.name);
setOptionsDirty(false);
setPendingTargetType(null);
}
}, [opened, property.name]);
}, [opened, property.name, initialPanel]);
useEffect(() => {
if (panel === "rename") {
@@ -185,16 +218,10 @@ export function PropertyMenuContent({
propertyId: property.id,
pageId: property.pageId,
type: pendingTargetType,
typeOptions: {},
typeOptions: typeOptionsForConversion(property, pendingTargetType),
});
onClose();
}, [
pendingTargetType,
property.id,
property.pageId,
updatePropertyMutation,
onClose,
]);
}, [pendingTargetType, property, updatePropertyMutation, onClose]);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
@@ -438,7 +465,7 @@ export function PropertyMenuContent({
PropertyMenuContent.displayName = "PropertyMenuContent";
function MenuItem({
export function MenuItem({
icon,
label,
rightIcon,
@@ -342,7 +342,6 @@ function NumberOptions({
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "separators", label: t("Number with separators") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
@@ -367,13 +366,40 @@ function NumberOptions({
}
/>
)}
<NumberInput
<Select
size="xs"
label={t("Thousands and decimal separators")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "none", label: t("None") },
{ value: "local", label: t("Local") },
{ value: "comma_period", label: t("Comma, period") },
{ value: "period_comma", label: t("Period, comma") },
{ value: "space_comma", label: t("Space, comma") },
{ value: "space_period", label: t("Space, period") },
]}
value={options.separators ?? "none"}
onChange={(val) => update({ separators: val ?? "none" })}
/>
<Select
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options.precision ?? 0}
onChange={(val) => update({ precision: val })}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "default", label: t("Default") },
...Array.from({ length: 9 }, (_, i) => ({
value: String(i),
label: String(i),
})),
]}
value={options.precision == null ? "default" : String(options.precision)}
onChange={(val) =>
update({ precision: val == null || val === "default" ? undefined : Number(val) })
}
/>
<NumberInput
size="xs"
@@ -1,19 +1,11 @@
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { BasePropertyType } from "@/ee/base/types/base.types";
import {
PROPERTY_PICKER_ORDER,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useState, useRef, useEffect } from "react";
import classes from "@/ee/base/styles/cells.module.css";
const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
const d = getDescriptor(type)!;
return { type, icon: d.icon, labelKey: d.labelKey };
});
type PropertyTypePickerProps = {
onSelect: (type: BasePropertyType) => void;
currentType?: BasePropertyType;
@@ -77,5 +69,3 @@ export function PropertyTypePicker({
</>
);
}
export { propertyTypes };
@@ -66,6 +66,7 @@ export function FieldChoice({ property, value, readOnly, onChange }: FieldProps)
trapFocus
closeOnClickOutside
closeOnEscape={false}
hideDetached={false}
>
<Popover.Target>
<FieldShell
@@ -41,6 +41,7 @@ export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<FieldShell
@@ -1,17 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import { formatNumber } from "@/ee/base/components/cells/cell-number";
import {
formatNumber,
parseNumberDraft,
sanitizeNumberInput,
} from "@/ee/base/components/cells/cell-number";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
@@ -42,7 +41,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
setDraft(toDraft(value));
return;
}
if (parse(draft) !== numValue) onChange(parse(draft));
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
return (
@@ -62,6 +61,17 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -75,6 +75,7 @@ export function PropertyRow({
withinPortal
closeOnClickOutside={false}
closeOnEscape={false}
hideDetached={false}
>
<Popover.Target>
<button
@@ -217,6 +217,7 @@ export function RowDetailModal({
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
removeScrollProps={{ noIsolation: true }}
>
{row ? (
<>
@@ -292,6 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,6 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -16,35 +17,28 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const inputRef = useRef<HTMLInputElement>(null);
const didAutofocusRef = useRef(false);
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
setValue(initial);
}, [initial]);
useEffect(() => {
if (didAutofocusRef.current || !canEdit || initial) return;
didAutofocusRef.current = true;
inputRef.current?.focus();
}, [canEdit, initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
return (
<header className={classes.header}>
{canEdit ? (
<input
ref={inputRef}
type="text"
className={classes.titleInput}
{...(!initial ? { "data-autofocus": true } : {})}
placeholder={t("Untitled")}
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
@@ -57,6 +51,10 @@ export function RowDetailTitle({
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -24,6 +24,7 @@ import {
} from "@/ee/base/property-types/property-type.registry";
import { FilterPersonInput } from "./filter-person-input";
import { FilterDateInput } from "./filter-date-input";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import viewClasses from "@/ee/base/styles/views.module.css";
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
@@ -191,6 +192,7 @@ export function ViewFilterConfigPopover({
children,
}: ViewFilterConfigProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const propertyOptions = properties.map((p) => ({
value: p.id,
@@ -2,8 +2,9 @@ import { useMemo, useCallback } from "react";
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import cellClasses from "@/ee/base/styles/cells.module.css";
import viewClasses from "@/ee/base/styles/views.module.css";
@@ -25,6 +26,7 @@ export function ViewPropertyVisibility({
children,
}: ViewPropertyVisibilityProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const columns = useMemo(() => {
return table
@@ -122,6 +124,9 @@ export function ViewPropertyVisibility({
return (
<UnstyledButton
key={col.id}
role="switch"
aria-checked={isVisible}
aria-disabled={!canHide || undefined}
className={cellClasses.menuItem}
onClick={() => {
if (canHide) {
@@ -140,6 +145,8 @@ export function ViewPropertyVisibility({
size="xs"
checked={isVisible}
disabled={!canHide}
tabIndex={-1}
aria-hidden
onChange={() => {}}
// Clicking the track synthesizes a second click on the hidden input which bubbles
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
@@ -21,7 +21,7 @@ type ViewRendererProps = {
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
@@ -15,6 +15,7 @@ import {
ViewSortConfig,
} from "@/ee/base/types/base.types";
import { useTranslation } from "react-i18next";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import viewClasses from "@/ee/base/styles/views.module.css";
type ViewSortConfigProps = {
@@ -35,6 +36,7 @@ export function ViewSortConfigPopover({
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
useEffect(() => {
@@ -288,15 +288,35 @@ function ViewTab({
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
<div
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 10px",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-xl)",
}}
>
<TextInput
variant="unstyled"
size="xs"
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
styles={{
input: {
height: "auto",
minHeight: 0,
padding: 0,
width: 100,
fontSize: "var(--mantine-font-size-sm)",
lineHeight: 1.2,
},
}}
/>
</div>
);
}
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useStore, type PrimitiveAtom } from "jotai";
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
export type UseEditableTextCellParams = {
value: unknown;
@@ -9,6 +11,8 @@ export type UseEditableTextCellParams = {
toDraft: (value: unknown) => string;
/** draft string -> the value passed to onCommit */
parse: (draft: string) => unknown;
rowId?: string;
propertyId?: string;
};
export type EditableTextCell = {
@@ -26,6 +30,8 @@ export function useEditableTextCell({
onCancel,
toDraft,
parse,
rowId,
propertyId,
}: UseEditableTextCellParams): EditableTextCell {
const [draft, setDraft] = useState(() => toDraft(value));
const inputRef = useRef<HTMLInputElement>(null);
@@ -33,18 +39,35 @@ export function useEditableTextCell({
const wasEditingRef = useRef(false);
const toDraftRef = useRef(toDraft);
toDraftRef.current = toDraft;
const store = useStore();
useEffect(() => {
useLayoutEffect(() => {
if (isEditing && !wasEditingRef.current) {
committedRef.current = false;
setDraft(toDraftRef.current(value));
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
const pending = store.get(pendingTypeInsertAtom);
const seeded =
pending != null &&
pending.rowId === rowId &&
pending.propertyId === propertyId;
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
if (seeded) {
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
}
setDraft(nextDraft);
const el = inputRef.current;
if (el) {
el.value = nextDraft;
el.focus({ preventScroll: true });
try {
el.setSelectionRange(nextDraft.length, nextDraft.length);
} catch {
// email/number inputs reject setSelectionRange
}
el.scrollLeft = el.scrollWidth;
}
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
}, [isEditing, value, rowId, propertyId, store]);
const commitOnce = useCallback(
(val: unknown) => {
@@ -0,0 +1,12 @@
import { useEffect } from "react";
export function useEscapeClose(opened: boolean, onClose: () => void) {
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, onClose]);
}
@@ -1,120 +1,317 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
type UseGridKeyboardNavOptions = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
containerRef: React.RefObject<HTMLDivElement | null>;
focusedCell: FocusedCell;
setFocusedCell: (cell: FocusedCell) => void;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
openEditor: (coord: CellCoord) => void;
clearCell: (coord: CellCoord) => void;
beginTypeToEdit: (coord: CellCoord, char: string) => void;
scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void;
selectionCount: number;
clearSelection: () => void;
deleteSelected: () => void | Promise<void>;
toggleRowSelection: (rowId: string) => void;
expandRow: (rowId: string) => void;
addRow: (afterRowId: string, focusPropertyId: string) => void;
};
const isPrintableKey = (e: KeyboardEvent) =>
e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
const isTextEntry = (el: Element | null) =>
!!el &&
(el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable);
export function useGridKeyboardNav({
table,
properties,
containerRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
containerRef,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
addRow,
}: UseGridKeyboardNavOptions) {
const getNavigableColumns = useCallback(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table]);
const getColIds = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id),
[table],
);
const getRowIds = useCallback(() => {
return table.getRowModel().rows.map((row) => row.id);
}, [table]);
const getNavColIds = useCallback(
() => table.getVisibleLeafColumns().map((col) => col.id),
[table],
);
const navigate = useCallback(
(rowDelta: number, colDelta: number) => {
if (!editingCell) return;
const getRowIds = useCallback(
() => table.getRowModel().rows.map((row) => row.id),
[table],
);
const columns = getNavigableColumns();
const rowIds = getRowIds();
const propertyType = useCallback(
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
[properties],
);
const currentColIndex = columns.indexOf(editingCell.propertyId);
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
const primaryPropertyId = useMemo(
() => properties.find((p) => p.isPrimary)?.id,
[properties],
);
if (currentColIndex === -1 || currentRowIndex === -1) return;
let nextColIndex = currentColIndex + colDelta;
let nextRowIndex = currentRowIndex + rowDelta;
if (nextColIndex < 0) {
nextColIndex = columns.length - 1;
nextRowIndex -= 1;
} else if (nextColIndex >= columns.length) {
nextColIndex = 0;
nextRowIndex += 1;
}
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
// Blur fires onBlur->commit before React unmounts the input
const goEditing = useCallback(
(next: CellCoord) => {
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell({
rowId: rowIds[nextRowIndex],
propertyId: columns[nextColIndex],
});
setEditingCell(next);
setFocusedCell(next);
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
},
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
[setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds],
);
const goFocused = useCallback(
(next: CellCoord) => {
setFocusedCell(next);
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
},
[setFocusedCell, scrollCellIntoView, getRowIds],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!editingCell) return;
if (editingCell) {
const inInput = isTextEntry(e.target as Element);
switch (e.key) {
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight": {
if (inInput) return;
e.preventDefault();
const d =
e.key === "ArrowUp"
? [-1, 0]
: e.key === "ArrowDown"
? [1, 0]
: e.key === "ArrowLeft"
? [0, -1]
: [0, 1];
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
d[0],
d[1],
false,
);
if (next) goEditing(next);
break;
}
case "Tab": {
e.preventDefault();
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
0,
e.shiftKey ? -1 : 1,
true,
);
if (next) goEditing(next);
break;
}
case "Enter": {
e.preventDefault();
if (e.shiftKey && editingCell.propertyId === primaryPropertyId) {
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(null);
addRow(editingCell.rowId, editingCell.propertyId);
break;
}
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
1,
0,
false,
);
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(null);
if (next) goFocused(next);
else setFocusedCell(editingCell);
break;
}
case "Escape": {
e.preventDefault();
setEditingCell(null);
setFocusedCell(editingCell);
break;
}
}
return;
}
const target = e.target as HTMLElement;
const isInputActive =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (e.target !== containerRef.current) return;
if (isTextEntry(document.activeElement)) return;
if (e.key === "Escape") {
if (selectionCount > 0) {
e.preventDefault();
clearSelection();
} else if (focusedCell) {
e.preventDefault();
setFocusedCell(null);
}
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (selectionCount > 0) {
e.preventDefault();
void deleteSelected();
} else if (focusedCell) {
e.preventDefault();
clearCell(focusedCell);
}
return;
}
if (!focusedCell) return;
switch (e.key) {
case "ArrowUp":
if (!isInputActive) {
e.preventDefault();
navigate(-1, 0);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowDown":
if (!isInputActive) {
e.preventDefault();
navigate(1, 0);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowLeft":
if (!isInputActive) {
e.preventDefault();
navigate(0, -1);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
if (next) goFocused(next);
}
break;
case "ArrowRight":
if (!isInputActive) {
e.preventDefault();
navigate(0, 1);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
if (next) goFocused(next);
}
break;
case "Tab":
e.preventDefault();
navigate(0, e.shiftKey ? -1 : 1);
case "Tab": {
const next = computeNextCell(
getRowIds(),
getNavColIds(),
focusedCell,
0,
e.shiftKey ? -1 : 1,
true,
);
if (next) {
e.preventDefault();
goFocused(next);
}
break;
case "Escape":
}
case "Enter":
case "F2":
e.preventDefault();
setEditingCell(null);
if (
e.key === "Enter" &&
e.shiftKey &&
focusedCell.propertyId === primaryPropertyId
) {
addRow(focusedCell.rowId, focusedCell.propertyId);
} else if (focusedCell.propertyId === "__row_number") {
toggleRowSelection(focusedCell.rowId);
} else {
openEditor(focusedCell);
}
break;
default: {
if (e.key === " ") {
e.preventDefault();
if (focusedCell.propertyId === "__row_number") {
toggleRowSelection(focusedCell.rowId);
} else if (propertyType(focusedCell.propertyId) === "checkbox") {
openEditor(focusedCell);
} else {
expandRow(focusedCell.rowId);
}
} else if (isPrintableKey(e)) {
e.preventDefault();
beginTypeToEdit(focusedCell, e.key);
}
}
}
},
[editingCell, navigate, setEditingCell],
[
containerRef,
editingCell,
focusedCell,
getRowIds,
getColIds,
getNavColIds,
goEditing,
goFocused,
setEditingCell,
setFocusedCell,
openEditor,
clearCell,
beginTypeToEdit,
propertyType,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
primaryPropertyId,
addRow,
],
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
const el = containerRef.current;
if (!el) return;
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleKeyDown]);
}
@@ -38,7 +38,15 @@ export function useHorizontalScrollSync<
body.scrollLeft += e.deltaY;
};
const onHeaderScroll = () => {
if (rafId !== 0) return;
if (body.scrollLeft !== header.scrollLeft) {
body.scrollLeft = header.scrollLeft;
}
};
body.addEventListener("scroll", onBodyScroll, { passive: true });
header.addEventListener("scroll", onHeaderScroll, { passive: true });
header.addEventListener("wheel", onHeaderWheel, { passive: false });
// Initial sync in case the body is already scrolled when the hook mounts.
@@ -46,6 +54,7 @@ export function useHorizontalScrollSync<
return () => {
body.removeEventListener("scroll", onBodyScroll);
header.removeEventListener("scroll", onHeaderScroll);
header.removeEventListener("wheel", onHeaderWheel);
if (rafId !== 0) cancelAnimationFrame(rafId);
};
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
import { IBase, IBaseProperty, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
export type KanbanGroup = KanbanColumn & { hidden: boolean };
@@ -8,6 +8,7 @@ export function useKanbanColumns(
view: IBaseView | undefined,
): {
groupByPropertyId: string | undefined;
groupByProperty: IBaseProperty | undefined;
columns: KanbanColumn[];
allGroups: KanbanGroup[];
hasValidGroupBy: boolean;
@@ -18,7 +19,7 @@ export function useKanbanColumns(
const groupable = prop && (prop.type === "select" || prop.type === "status");
if (!groupable || !prop || !view) {
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
return { groupByPropertyId, groupByProperty: undefined, columns: [], allGroups: [], hasValidGroupBy: false };
}
const typeOptions = prop.typeOptions as SelectTypeOptions;
@@ -47,6 +48,6 @@ export function useKanbanColumns(
});
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
return { groupByPropertyId, groupByProperty: prop, columns, allGroups, hasValidGroupBy: true };
}, [base, view]);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default function BasePage() {
{base.icon ? `${base.icon} ` : ""}{base.name}
</Title>
)}
<BaseView pageId={pageId} editable={hasBases && (base?.canEdit ?? false)} />
<BaseView pageId={pageId} editable={hasBases && (base?.permissions?.canEdit ?? false)} />
</Container>
);
}
@@ -17,6 +17,7 @@ export type CellComponentProps = {
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
export type FilterInputKind =
@@ -74,6 +74,7 @@ export const PROPERTY_TYPE_REGISTRY: Record<
filterInput: "number",
isSystem: false,
hasOptions: true,
defaultTypeOptions: () => ({ separators: "local" }),
},
select: {
type: "select",
@@ -96,7 +97,11 @@ export const PROPERTY_TYPE_REGISTRY: Record<
hasOptions: true,
defaultTypeOptions: () => {
const choices = defaultStatusChoices();
return { choices, choiceOrder: choices.map((c) => c.id) };
return {
choices,
choiceOrder: choices.map((c) => c.id),
defaultValue: choices[0].id,
};
},
},
multiSelect: {
@@ -244,6 +249,11 @@ export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
"createdAt", "lastEditedAt", "lastEditedBy",
];
export const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
const d = getDescriptor(type)!;
return { type, icon: d.icon, labelKey: d.labelKey };
});
export function systemAccessorFor(type: string) {
return getDescriptor(type)?.systemAccessor;
}
@@ -18,6 +18,7 @@ import {
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { IPagination } from "@/lib/types";
export function useCreatePropertyMutation() {
@@ -36,9 +37,9 @@ export function useCreatePropertyMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create property"),
message: getApiErrorMessage(error, t("Failed to create property")),
color: "red",
});
},
@@ -69,9 +70,9 @@ export function useUpdatePropertyMutation() {
});
}
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to update property"),
message: getApiErrorMessage(error, t("Failed to update property")),
color: "red",
});
},
@@ -114,9 +115,9 @@ export function useDeletePropertyMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete property"),
message: getApiErrorMessage(error, t("Failed to delete property")),
color: "red",
});
},
@@ -154,7 +155,7 @@ export function useReorderPropertyMutation() {
return { previous };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.pageId],
@@ -162,7 +163,7 @@ export function useReorderPropertyMutation() {
);
}
notifications.show({
message: t("Failed to reorder property"),
message: getApiErrorMessage(error, t("Failed to reorder property")),
color: "red",
});
},
+15 -9
View File
@@ -15,9 +15,11 @@ import {
CreateBaseInput,
UpdateBaseInput,
} from "@/ee/base/types/base.types";
import { IPage } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -44,9 +46,9 @@ export function useCreateBaseMutation() {
queryKey: ["bases", "list", data.spaceId],
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create base"),
message: getApiErrorMessage(error, t("Failed to create base")),
color: "red",
});
},
@@ -61,6 +63,10 @@ export function useConvertPageToBaseMutation() {
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
onSuccess: (base) => {
const markAsBase = (old?: IPage) => (old ? { ...old, isBase: true } : old);
queryClient.setQueryData<IPage>(["pages", base.id], markAsBase);
queryClient.setQueryData<IPage>(["pages", base.slugId], markAsBase);
queryClient.invalidateQueries({ queryKey: ["pages"] });
queryClient.invalidateQueries({
queryKey: ["root-sidebar-pages", base.spaceId],
@@ -74,12 +80,12 @@ export function useConvertPageToBaseMutation() {
spaceId: base.spaceId,
entity: ["pages"],
id: base.id,
payload: { isBase: true },
payload: { isBase: true, slugId: base.slugId },
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create base"),
message: getApiErrorMessage(error, t("Failed to create base")),
color: "red",
});
},
@@ -96,9 +102,9 @@ export function useUpdateBaseMutation() {
return { ...old, ...data };
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to update base"),
message: getApiErrorMessage(error, t("Failed to update base")),
color: "red",
});
},
@@ -116,9 +122,9 @@ export function useDeleteBaseMutation() {
});
notifications.show({ message: t("Base deleted") });
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete base"),
message: getApiErrorMessage(error, t("Failed to delete base")),
color: "red",
});
},
@@ -31,6 +31,7 @@ import {
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
import { v7 as uuid7 } from "uuid";
@@ -146,12 +147,15 @@ export function useCreateRowMutation() {
);
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
invalidateBaseRows(newRow.pageId);
queryClient.invalidateQueries({
queryKey: ["base-rows", newRow.pageId],
refetchType: "none",
});
}
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create row"),
message: getApiErrorMessage(error, t("Failed to create row")),
color: "red",
});
},
@@ -221,7 +225,7 @@ export function useUpdateRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
@@ -231,7 +235,7 @@ export function useUpdateRowMutation() {
queryKey: ["base-row", variables.pageId, variables.rowId],
});
notifications.show({
message: t("Failed to update row"),
message: getApiErrorMessage(error, t("Failed to update row")),
color: "red",
});
},
@@ -305,14 +309,14 @@ export function useDeleteRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete row"),
message: getApiErrorMessage(error, t("Failed to delete row")),
color: "red",
});
},
@@ -349,14 +353,14 @@ export function useDeleteRowsMutation() {
return { snapshots };
},
onError: (_, __, context) => {
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete rows"),
message: getApiErrorMessage(error, t("Failed to delete rows")),
color: "red",
});
},
@@ -399,14 +403,14 @@ export function useReorderRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to reorder row"),
message: getApiErrorMessage(error, t("Failed to reorder row")),
color: "red",
});
},
@@ -504,14 +508,14 @@ export function useKanbanMoveCardMutation() {
return { snapshots };
},
onError: (_, __, context) => {
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to move card"),
message: getApiErrorMessage(error, t("Failed to move card")),
color: "red",
});
},
@@ -551,10 +555,14 @@ export function useKanbanCreateCardMutation() {
),
};
});
queryClient.setQueryData<IBaseRow>(
["base-row", newRow.pageId, newRow.id],
newRow,
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to add card"),
message: getApiErrorMessage(error, t("Failed to add card")),
color: "red",
});
},
@@ -28,6 +28,7 @@ function applyConfigPatch(
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
export function useCreateViewMutation() {
const { t } = useTranslation();
@@ -45,9 +46,9 @@ export function useCreateViewMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create view"),
message: getApiErrorMessage(error, t("Failed to create view")),
color: "red",
});
},
@@ -99,7 +100,7 @@ export function useUpdateViewMutation() {
return { previous };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.pageId],
@@ -107,7 +108,7 @@ export function useUpdateViewMutation() {
);
}
notifications.show({
message: t("Failed to update view"),
message: getApiErrorMessage(error, t("Failed to update view")),
color: "red",
});
},
@@ -144,9 +145,9 @@ export function useDeleteViewMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete view"),
message: getApiErrorMessage(error, t("Failed to delete view")),
color: "red",
});
},
+86 -21
View File
@@ -36,6 +36,11 @@
scrollbar-width: thin;
}
.bodyGrid:focus,
.bodyGrid:focus-visible {
outline: none;
}
.bodyGrid::-webkit-scrollbar {
height: 8px;
}
@@ -179,11 +184,13 @@
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
@media (hover: hover) {
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
}
.cell {
@@ -191,6 +198,7 @@
align-items: center;
min-height: 36px;
padding: 0 8px;
touch-action: manipulation;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
background-color: light-dark(
@@ -222,11 +230,13 @@
);
}
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
@media (hover: hover) {
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
}
.cellEditing {
@@ -236,6 +246,26 @@
padding: 0;
}
.bodyGrid:focus .cellFocused {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
@media (hover: none) {
.cellFocused {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
}
.headerCell:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
.cellContent {
flex: 1;
min-width: 0;
@@ -453,6 +483,7 @@
cursor: pointer;
opacity: 0;
pointer-events: none;
touch-action: manipulation;
transition: opacity 80ms ease, color 80ms ease;
}
@@ -462,17 +493,28 @@
pointer-events: auto;
}
.rowExpandButton:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
@media (hover: none) {
.rowExpandButton {
opacity: 1;
pointer-events: auto;
}
}
.row:hover .rowNumberIndex {
display: none;
@media (hover: hover) {
.rowExpandButton:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
@media (hover: hover) {
.row:hover .rowNumberIndex {
display: none;
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
}
}
.rowSelected .rowNumberIndex {
@@ -481,13 +523,29 @@
.rowSelected .rowNumberCheckbox {
display: inline-flex;
}
.bodyGrid:focus .cellFocused .rowNumberIndex {
display: none;
}
.bodyGrid:focus .cellFocused .rowNumberCheckbox {
display: inline-flex;
}
@media (hover: none) {
.cellFocused .rowNumberIndex {
display: none;
}
.cellFocused .rowNumberCheckbox {
display: inline-flex;
}
}
.rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
@media (hover: hover) {
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
}
}
.rowNumberHeaderInner {
@@ -517,6 +575,13 @@
display: inline-flex;
}
.headerCell:focus-visible .rowNumberHeaderHash {
display: none;
}
.headerCell:focus-visible .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper {
position: fixed;
left: 50%;
@@ -76,6 +76,21 @@
cursor: grabbing;
}
.columnTitleButton {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 6px;
cursor: pointer;
}
.columnTitleButton:hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.cardList {
display: flex;
flex-direction: column;
+21 -5
View File
@@ -41,8 +41,17 @@ export type SelectTypeOptions = {
defaultValue?: string | string[] | null;
};
export type NumberSeparatorStyle =
| 'none'
| 'local'
| 'comma_period'
| 'period_comma'
| 'space_comma'
| 'space_period';
export type NumberTypeOptions = {
format?: 'plain' | 'separators' | 'currency' | 'percent' | 'progress';
format?: 'plain' | 'currency' | 'percent' | 'progress';
separators?: NumberSeparatorStyle;
precision?: number;
currencyCode?: string;
currencySymbol?: string;
@@ -226,6 +235,7 @@ export type IBaseView = {
export type IBase = {
id: string;
slugId: string;
name: string;
description?: string;
icon?: string;
@@ -237,15 +247,21 @@ export type IBase = {
views: IBaseView[];
createdAt: string;
updatedAt: string;
/** Effective edit permission for the current user (page-restrictions included). */
canEdit?: boolean;
permissions?: {
canEdit: boolean;
hasRestriction: boolean;
};
baseSchemaVersion: number;
};
export type EditingCell = {
export type CellCoord = {
rowId: string;
propertyId: string;
} | null;
};
export type EditingCell = CellCoord | null;
export type FocusedCell = CellCoord | null;
export type CreateBaseInput = {
name: string;
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const baseIdSuffix = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 9);
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
@@ -0,0 +1,33 @@
import { CellCoord } from "@/ee/base/types/base.types";
export function computeNextCell(
rowIds: string[],
colIds: string[],
current: CellCoord,
rowDelta: number,
colDelta: number,
wrap: boolean,
): CellCoord | null {
const colIndex = colIds.indexOf(current.propertyId);
const rowIndex = rowIds.indexOf(current.rowId);
if (colIndex === -1 || rowIndex === -1) return null;
let nextCol = colIndex + colDelta;
let nextRow = rowIndex + rowDelta;
if (wrap) {
if (nextCol < 0) {
nextCol = colIds.length - 1;
nextRow -= 1;
} else if (nextCol >= colIds.length) {
nextCol = 0;
nextRow += 1;
}
} else if (nextCol < 0 || nextCol >= colIds.length) {
return null;
}
if (nextRow < 0 || nextRow >= rowIds.length) return null;
return { rowId: rowIds[nextRow], propertyId: colIds[nextCol] };
}
@@ -103,7 +103,7 @@ export default function BillingPlans() {
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
onChange={(value) => setSelectedTierValue(value)}
data={selectData}
w={250}
size="md"
@@ -3,6 +3,7 @@ import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
interface ResolveCommentProps {
editor: Editor;
@@ -31,7 +32,7 @@ function ResolveComment({
resolved: !isResolved,
});
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
+2
View File
@@ -19,5 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
} as const;
@@ -17,7 +17,10 @@ const enterpriseFeatures = [
"Resolve Comments",
"Confluence Import",
"PDF & DOCX Import",
"Bases",
"Kanban",
"Templates",
"Personal Spaces"
];
export default function OssDetails() {
@@ -47,7 +47,6 @@ interface MfaSetupModalProps {
}
interface SetupData {
secret: string;
qrCode: string;
manualKey: string;
}
@@ -101,7 +100,6 @@ export function MfaSetupModal({
const enableMutation = useMutation({
mutationFn: (verificationCode: string) =>
enableMfa({
secret: setupData!.secret,
verificationCode,
}),
onSuccess: (data) => {
@@ -193,7 +191,7 @@ export function MfaSetupModal({
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Collapse expanded={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
@@ -31,12 +31,10 @@ export interface MfaSetupRequest {
export interface MfaSetupResponse {
method: string;
qrCode: string;
secret: string;
manualKey: string;
}
export interface MfaEnableRequest {
secret: string;
verificationCode: string;
}
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
title={data.title}
content={data.content}
pageId={data.pageId}
printMode
/>
</Container>
);

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