Compare commits

..

7 Commits

Author SHA1 Message Date
Philipinho e4c3d06a08 fixes 2026-05-04 20:55:13 +01:00
Philipinho 8aa45815f4 action label 2026-05-04 20:36:25 +01:00
Philipinho 660eb4a944 fix contrast 2026-05-04 20:35:55 +01:00
Philipinho 0fdd7ef6d6 aria fixes 2026-05-04 20:15:53 +01:00
Philipinho 1586f4be13 enhance a11y 2026-05-04 19:46:52 +01:00
Philipinho 6fdab5fe70 accessibility 2026-04-20 19:45:55 +01:00
Philipinho fadeeaa59d feat(client): improve accessibility with ARIA labels and semantics
- Add aria-label to icon-only ActionIcon triggers across tree, comments,
  share, group, breadcrumbs, templates, AI chat, API keys, and spaces
- Give custom clickable divs/spans (color swatches, status swatches,
  load-more, PDF error, comment selection) role="button", tabIndex,
  and keyboard handlers
- Add listbox/combobox semantics (role, aria-selected, aria-activedescendant)
  to slash menu, emoji menu, mention list, and link editor results
- Add aria-haspopup/aria-expanded to bubble-menu triggers (node, text
  align, color) and status badge
- Add aria-label to Modal.Root/Dialog instances that lack a title prop
  (page history, template preview, trash preview, drawio, comment,
  find-and-replace, page verification)
- Add en-US translations for new aria-label strings
2026-04-18 13:07:16 +01:00
629 changed files with 5560 additions and 46106 deletions
+1 -13
View File
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379
# options: local | s3 | azure
# options: local | s3
STORAGE_DRIVER=local
# S3 driver config
@@ -21,11 +21,6 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE=
# Azure Blob Storage driver config
AZURE_STORAGE_ACCOUNT_NAME=
AZURE_STORAGE_ACCOUNT_KEY=
AZURE_STORAGE_CONTAINER=
# default: 50mb
FILE_UPLOAD_SIZE_LIMIT=
@@ -53,13 +48,6 @@ GOTENBERG_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
IFRAME_EMBED_ALLOWED=false
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
-2
View File
@@ -28,8 +28,6 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
# Copy packages
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
+60 -72
View File
@@ -1,97 +1,85 @@
{
"name": "client",
"private": true,
"version": "0.90.1",
"version": "0.80.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/base-formula": "workspace:*",
"@casl/react": "^5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@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",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.2",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"alfaaz": "^1.1.0",
"axios": "1.15.0",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.13.1",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.94.4",
"@types/blueimp-load-image": "^5.16.6",
"@types/file-saver": "^2.0.7",
"@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",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.5.2",
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vitest": "4.1.6"
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "8.0.5"
}
}
@@ -71,7 +71,6 @@
"Export": "Exportieren",
"Failed to create page": "Erstellung der Seite fehlgeschlagen",
"Failed to delete page": "Löschen der Seite fehlgeschlagen",
"Failed to restore page": "Seite konnte nicht wiederhergestellt werden",
"Failed to fetch recent pages": "Fehler beim Abrufen der letzten Seiten",
"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.",
@@ -112,7 +111,7 @@
"Member": "Mitglied",
"members": "Mitglieder",
"Members": "Mitglieder",
"My preferences": "Meine Einstellungen",
"My preferences": "Meine Voreinstellungen",
"My Profile": "Mein Profil",
"My profile": "Mein Profil",
"Name": "Name",
@@ -140,7 +139,7 @@
"People": "Personen",
"Pending": "Ausstehend",
"Please confirm your action": "Bitte bestätigen Sie Ihre Aktion",
"Preferences": "Einstellungen",
"Preferences": "Vorlieben",
"Print PDF": "PDF drucken",
"Profile": "Profil",
"Recently updated": "Kürzlich aktualisiert",
@@ -277,9 +276,6 @@
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
"Align center": "Zentrieren",
"Alt text": "Alternativtext",
"Describe this for accessibility.": "Beschreiben Sie dies für die Barrierefreiheit.",
"Add a description": "Beschreibung hinzufügen",
"Justify": "Blocksatz",
"Merge cells": "Zellen zusammenführen",
"Split cell": "Zelle teilen",
@@ -290,19 +286,6 @@
"Add row above": "Zeile oben hinzufügen",
"Add row below": "Zeile unten hinzufügen",
"Delete table": "Tabelle löschen",
"Add column left": "Spalte links hinzufügen",
"Add column right": "Spalte rechts hinzufügen",
"Clear cell": "Zelle leeren",
"Clear cells": "Zellen leeren",
"Toggle header cell": "Kopfzelle umschalten",
"Toggle header column": "Kopfspalte umschalten",
"Toggle header row": "Kopfzeile umschalten",
"Move column left": "Spalte nach links verschieben",
"Move column right": "Spalte nach rechts verschieben",
"Move row down": "Zeile nach unten verschieben",
"Move row up": "Zeile nach oben verschieben",
"Sort A → Z": "A → Z sortieren",
"Sort Z → A": "Z → A sortieren",
"Info": "Info",
"Note": "Hinweis",
"Success": "Erfolg",
@@ -365,8 +348,6 @@
"Create block quote.": "Erstellen Sie ein Blockzitat.",
"Insert code snippet.": "Code-Snippet einfügen.",
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Page break": "Seitenumbruch",
"Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
@@ -411,10 +392,6 @@
"Write...": "\"Schreiben...\"",
"Column count": "Spaltenanzahl",
"{{count}} Columns": "{{count}} Spalten",
"{{count}} command available_one": "1 Befehl verfügbar",
"{{count}} command available_other": "{{count}} Befehle verfügbar",
"{{count}} result available_one": "1 Ergebnis verfügbar",
"{{count}} result available_other": "{{count}} Ergebnisse verfügbar",
"Equal columns": "Gleich breite Spalten",
"Left sidebar": "Linke Seitenleiste",
"Right sidebar": "Rechte Seitenleiste",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
"Default page edit mode": "Standard-Bearbeitungsmodus für Seiten",
"Choose your preferred page edit mode. Avoid accidental edits.": "Wählen Sie Ihren bevorzugten Seitenbearbeitungsmodus. Vermeiden Sie versehentliche Bearbeitungen.",
"Choose {{format}} file": "{{format}}-Datei auswählen",
"Reading": "Lesen",
"Delete member": "Mitglied löschen",
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
@@ -589,8 +565,6 @@
"Move to trash": "In den Papierkorb verschieben",
"Move this page to trash?": "Diese Seite in den Papierkorb verschieben?",
"Restore page": "Seite wiederherstellen",
"Permanently delete": "Endgültig löschen",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> hat diese Seite {{time}} in den Papierkorb verschoben.",
"Page moved to trash": "Seite in den Papierkorb verschoben",
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
"Deleted by": "Gelöscht von",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update": "Aktualisieren",
"Update {{credential}}": "{{credential}} aktualisieren",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"Restrict API key creation to admins": "API-Schlüsselerstellung auf Administratoren beschränken",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Nur Administratoren und Eigentümer können neue API-Schlüssel erstellen. Bestehende Mitgliederschlüssel funktionieren weiterhin.",
@@ -880,12 +858,9 @@
"AI Chat": "KI-Chat",
"Analyze for insights": "Für Erkenntnisse analysieren",
"Ask anything...": "Fragen Sie irgendetwas...",
"Assistant said:": "Assistent sagte:",
"Chat history": "Chatverlauf",
"Chat name": "Chatname",
"Chat transcript": "Chatprotokoll",
"Close": "Schließen",
"Copy assistant response": "Antwort des Assistenten kopieren",
"Docmost AI": "Docmost KI",
"Failed to load chat. An error occurred.": "Chat konnte nicht geladen werden. Ein Fehler ist aufgetreten.",
"Failed to render this message.": "Diese Nachricht konnte nicht dargestellt werden.",
@@ -895,17 +870,9 @@
"No chats found": "Keine Chats gefunden",
"No conversations yet": "Noch keine Unterhaltungen",
"Open full page": "Ganze Seite öffnen",
"Scroll to bottom": "Nach unten scrollen",
"You said:": "Sie sagten:",
"Previous 7 days": "Letzte 7 Tage",
"Previous 30 days": "Letzte 30 Tage",
"Search chats...": "Chats durchsuchen...",
"Search chats": "Chats durchsuchen",
"Ask anything... Use @ to mention pages": "Frag etwas ... Verwende @, um Seiten zu erwähnen",
"Ask anything or search your workspace": "Fragen Sie etwas oder durchsuchen Sie Ihren Workspace",
"Welcome to {{name}}": "Willkommen bei {{name}}",
"Add files": "Dateien hinzufügen",
"Mention a page": "Eine Seite einfügen",
"Start a new chat to see it here.": "Starten Sie einen neuen Chat, damit er hier angezeigt wird.",
"Summarize this page": "Diese Seite zusammenfassen",
"Toggle AI Chat": "KI-Chat umschalten",
@@ -913,176 +880,5 @@
"Try a different search term.": "Versuchen Sie einen anderen Suchbegriff.",
"Try again": "Erneut versuchen",
"Untitled chat": "Chat ohne Titel",
"What can I help you with?": "Womit kann ich Ihnen helfen?",
"Are you sure you want to revoke this {{credential}}": "Sind Sie sicher, dass Sie diese(n) {{credential}} widerrufen möchten?",
"Automatically provision users and groups from your identity provider via SCIM.": "Stellen Sie Benutzer und Gruppen automatisch über SCIM von Ihrem Identitätsanbieter bereit.",
"Configure your identity provider with this URL to provision users and groups.": "Konfigurieren Sie Ihren Identitätsanbieter mit dieser URL, um Benutzer und Gruppen bereitzustellen.",
"Create {{credential}}": "{{credential}} erstellen",
"{{credential}} created": "{{credential}} erstellt",
"{{credential}} created successfully": "{{credential}} erfolgreich erstellt",
"Created by": "Erstellt von",
"Custom": "Benutzerdefiniert",
"Enable SCIM": "SCIM aktivieren",
"Enter a descriptive name": "Geben Sie einen beschreibenden Namen ein",
"I've saved my {{credential}}": "Ich habe meine(n) {{credential}} gespeichert",
"Important": "Wichtig",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Stellen Sie sicher, dass Sie Ihre(n) {{credential}} jetzt kopieren. Sie können sie/ihn später nicht erneut anzeigen!",
"Never": "Nie",
"Revoke {{credential}}": "{{credential}} widerrufen",
"SCIM endpoint URL": "SCIM-Endpunkt-URL",
"SCIM provisioning": "SCIM-Bereitstellung",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM hat Vorrang vor der SSO-Gruppensynchronisierung, solange es aktiviert ist.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Sie haben die maximale Anzahl von {{max}} SCIM-Token erreicht. Löschen Sie ein vorhandenes Token, um ein neues zu erstellen.",
"SCIM token": "SCIM-Token",
"SCIM tokens": "SCIM-Token",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Diese Aktion kann nicht rückgängig gemacht werden. Ihr Identitätsanbieter wird die Synchronisierung sofort beenden.",
"Toggle SCIM provisioning": "SCIM-Bereitstellung umschalten",
"Token": "Token",
"Page menu": "Seitenmenü",
"Expand": "Erweitern",
"Collapse": "Reduzieren",
"Comment menu": "Kommentarmenü",
"Group menu": "Gruppenmenü",
"Show hidden breadcrumbs": "Ausgeblendete Breadcrumbs anzeigen",
"Breadcrumbs": "Navigationspfade",
"Page actions": "Seitenaktionen",
"Pick emoji": "Emoji auswählen",
"Template menu": "Vorlagenmenü",
"Use": "Verwenden",
"Use template": "Vorlage verwenden",
"Preview template: {{title}}": "Vorlage anzeigen: {{title}}",
"Use a template": "Eine Vorlage verwenden",
"Search templates...": "Vorlagen suchen...",
"Search spaces...": "Bereiche suchen...",
"No templates found": "Keine Vorlagen gefunden",
"No spaces found": "Keine Bereiche gefunden",
"Browse all templates": "Alle Vorlagen durchsuchen",
"This space": "Dieser Bereich",
"All templates": "Alle Vorlagen",
"Global": "Global",
"New template": "Neue Vorlage",
"Edit template": "Vorlage bearbeiten",
"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",
"Scope": "Bereich",
"Select scope": "Bereich auswählen",
"Title": "Titel",
"Saving...": "Wird gespeichert...",
"Saved": "Gespeichert",
"Save failed. Retry": "Speichern fehlgeschlagen. Erneut versuchen",
"By {{name}}": "Von {{name}}",
"Updated {{time}}": "Aktualisiert {{time}}",
"Choose destination": "Ziel auswählen",
"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ü",
"API key menu": "API-Schlüssel-Menü",
"Jump to comment selection": "Zur Kommentarauswahl springen",
"Slash commands": "Slash-Befehle",
"Mention suggestions": "Erwähnungsvorschläge",
"Link suggestions": "Linkvorschläge",
"Diagram editor": "Diagrammeditor",
"Add comment": "Kommentar hinzufügen",
"Find and replace": "Suchen und ersetzen",
"Main navigation": "Hauptnavigation",
"Space navigation": "Bereichsnavigation",
"Settings navigation": "Einstellungsnavigation",
"AI navigation": "KI-Navigation",
"Breadcrumb": "Navigationspfad",
"Synced block": "Synchronisierter Block",
"Create a block that stays in sync across pages.": "Erstellt einen Block der über mehrere Seiten synchronisiert wird",
"Editing original": "Original bearbeiten",
"Copy synced block": "Synchronisierten Block kopieren",
"Unsync": "Synchronisierung aufheben",
"Delete synced block": "Synchronisierten Block löschen",
"Synced to {{count}} other page_one": "Mit {{count}} anderer Seite synchronisiert",
"Synced to {{count}} other page_other": "Mit {{count}} anderen Seiten synchronisiert",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "DIESE SEITE",
"No pages": "Keine Seiten",
"The original synced block no longer exists": "Der originale synchronisierte Block existiert nicht mehr",
"You don't have access to this synced block": "Sie haben keinen Zugriff auf diesen synchronisierten Block",
"Failed to load this synced block": "Dieser synchronisierte Block konnte nicht geladen werden",
"Fixed editor toolbar": "Fixierte Editor-Symbolleiste",
"Show a formatting toolbar above the editor with quick access to common actions.": "Anzeige einer Formatierungs-Symbolleiste über dem Editor für schnellen Zugriff auf Aktionen.",
"Toggle fixed editor toolbar": "Fixierte Editor-Symbolleiste ein/aus",
"Normal text": "Normaler Text",
"More inline formatting": "Weitere Formatierung",
"Subscript": "Tiefgestellt",
"Superscript": "Hochgestellt",
"Inline code": "Inline-Code",
"Insert media": "Medien einfügen",
"Mention": "Erwähnung",
"Emoji": "Emoji",
"Columns": "Spalten",
"More inserts": "Weiteren Inhalt einfügen",
"Embeds": "Einbettungen",
"Diagrams": "Diagramme",
"Advanced": "Erweitert",
"Utility": "Dienstprogramme",
"Decrease indent": "Einzug verkleinern",
"Increase indent": "Einzug vergrößern",
"Clear formatting": "Formatierung zurücksetzen",
"Code block": "Codeblock",
"Experimental": "Experimentell",
"Strikethrough": "Durchgestrichen",
"Undo": "Rückgängig",
"Redo": "Wiederholen",
"Backlinks": "Rückverweise",
"Last updated by": "Zuletzt aktualisiert von",
"Last updated": "Zuletzt aktualisiert",
"Stats": "Statistiken",
"Word count": "Wörter",
"Characters": "Zeichen",
"Incoming links": "Eingehende Links",
"Outgoing links": "Ausgehende Links",
"Incoming links ({{count}})": "Eingehende Links ({{count}})",
"Outgoing links ({{count}})": "Ausgehende Links ({{count}})",
"No pages link here yet.": "Aktuell verlinken keine Seiten hierher.",
"This page doesn't link to other pages yet.": "Diese Seite verlinkt noch nicht auf andere Seiten.",
"Verified until {{date}}": "Verifiziert bis zum {{date}}",
"Labels": "Beschriftungen",
"Add label": "Beschriftung hinzufügen",
"No labels yet": "Noch keine Beschriftungen",
"Already added": "Bereits hinzugefügt",
"Invalid label name": "Ungültiger Beschriftungsname",
"No matches": "Keine Treffer",
"Search or create…": "Suchen oder erstellen…",
"Remove label {{name}}": "Beschriftung {{name}} entfernen",
"Failed to add label": "Beschriftung konnte nicht hinzugefügt werden",
"Failed to remove label": "Beschriftung konnte nicht entfernt werden",
"No pages with this label": "Keine Seiten mit dieser Beschriftung",
"Pages tagged with this label will appear here.": "Hier werden Seiten angezeigt, die mit dieser Beschriftung versehen sind.",
"No pages match your search.": "Es konnten keine Seiten gefunden werden, die mit Ihrer Suche übereinstimmen.",
"Updated {{date}}": "Aktualisiert am {{date}}",
"Cell actions": "Zellaktionen",
"Column actions": "Spaltenaktionen",
"Row actions": "Zeilenaktionen",
"Filter": "Filter",
"Page title": "Seitentitel",
"Page content": "Seiteninhalt",
"Member actions": "Mitgliederaktionen",
"Toggle password visibility": "Passwortsichtbarkeit umschalten",
"Send comment": "Kommentar senden",
"Token actions": "Token-Aktionen",
"Template settings": "Vorlageneinstellungen",
"Edit diagram": "Diagramm bearbeiten",
"Edit embed": "Einbettung bearbeiten",
"Edit drawing": "Zeichnung bearbeiten",
"Delete equation": "Gleichung löschen",
"Invite actions": "Einladungsaktionen",
"Get started": "Erste Schritte",
"* indicates required fields": "* kennzeichnet Pflichtfelder",
"List of spaces in this workspace": "Liste der Bereiche in diesem Workspace",
"Active sessions": "Aktive Sitzungen",
"Add {{name}} to favorites": "{{name}} zu Favoriten hinzufügen",
"Remove {{name}} from favorites": "{{name}} aus Favoriten entfernen",
"Added to favorites": "Zu Favoriten hinzugefügt",
"Removed from favorites": "Aus Favoriten entfernt",
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
"What can I help you with?": "Womit kann ich Ihnen helfen?"
}
@@ -41,8 +41,6 @@
"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",
@@ -73,29 +71,10 @@
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"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",
@@ -297,9 +276,6 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
"Alt text": "Alt text",
"Describe this for accessibility.": "Describe this for accessibility.",
"Add a description": "Add a description",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
@@ -310,19 +286,6 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -385,8 +348,6 @@
"Create block quote.": "Create block quote.",
"Insert code snippet.": "Insert code snippet.",
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
@@ -431,10 +392,6 @@
"Write...": "Write...",
"Column count": "Column count",
"{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"Equal columns": "Equal columns",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -444,7 +401,6 @@
"Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}",
"now": "now",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
@@ -610,15 +566,11 @@
"Move to trash": "Move to trash",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
"Page moved to trash": "Page moved to trash",
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
"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",
@@ -903,12 +855,9 @@
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
"Ask anything...": "Ask anything...",
"Assistant said:": "Assistant said:",
"Chat history": "Chat history",
"Chat name": "Chat name",
"Chat transcript": "Chat transcript",
"Close": "Close",
"Copy assistant response": "Copy assistant response",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Failed to load chat. An error occurred.",
"Failed to render this message.": "Failed to render this message.",
@@ -918,17 +867,9 @@
"No chats found": "No chats found",
"No conversations yet": "No conversations yet",
"Open full page": "Open full page",
"Scroll to bottom": "Scroll to bottom",
"You said:": "You said:",
"Previous 7 days": "Previous 7 days",
"Previous 30 days": "Previous 30 days",
"Search chats...": "Search chats...",
"Search chats": "Search chats",
"Ask anything... Use @ to mention pages": "Ask anything... Use @ to mention pages",
"Ask anything or search your workspace": "Ask anything or search your workspace",
"Welcome to {{name}}": "Welcome to {{name}}",
"Add files": "Add files",
"Mention a page": "Mention a page",
"Start a new chat to see it here.": "Start a new chat to see it here.",
"Summarize this page": "Summarize this page",
"Toggle AI Chat": "Toggle AI Chat",
@@ -971,35 +912,6 @@
"Page actions": "Page actions",
"Pick emoji": "Pick emoji",
"Template menu": "Template menu",
"Use": "Use",
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",
"No spaces found": "No spaces found",
"Browse all templates": "Browse all templates",
"This space": "This space",
"All templates": "All templates",
"Global": "Global",
"New template": "New template",
"Edit template": "Edit template",
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
"Template scope updated": "Template scope updated",
"Choose which space this template belongs to": "Choose which space this template belongs to",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
"Saving...": "Saving...",
"Saved": "Saved",
"Save failed. Retry": "Save failed. Retry",
"By {{name}}": "By {{name}}",
"Updated {{time}}": "Updated {{time}}",
"Choose destination": "Choose destination",
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
@@ -1014,134 +926,5 @@
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Synced block": "Synced block",
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
"Editing original": "Editing original",
"Copy synced block": "Copy synced block",
"Unsync": "Unsync",
"Delete synced block": "Delete synced block",
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "THIS PAGE",
"No pages": "No pages",
"The original synced block no longer exists": "The original synced block no longer exists",
"You don't have access to this synced block": "You don't have access to this synced block",
"Failed to load this synced block": "Failed to load this synced block",
"Fixed editor toolbar": "Fixed editor toolbar",
"Show a formatting toolbar above the editor with quick access to common actions.": "Show a formatting toolbar above the editor with quick access to common actions.",
"Toggle fixed editor toolbar": "Toggle fixed editor toolbar",
"Normal text": "Normal text",
"More inline formatting": "More inline formatting",
"Subscript": "Subscript",
"Superscript": "Superscript",
"Inline code": "Inline code",
"Insert media": "Insert media",
"Mention": "Mention",
"Emoji": "Emoji",
"Columns": "Columns",
"More inserts": "More inserts",
"Embeds": "Embeds",
"Diagrams": "Diagrams",
"Advanced": "Advanced",
"Utility": "Utility",
"Decrease indent": "Decrease indent",
"Increase indent": "Increase indent",
"Clear formatting": "Clear formatting",
"Code block": "Code block",
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
"Word count": "Word count",
"Characters": "Characters",
"Incoming links": "Incoming links",
"Outgoing links": "Outgoing links",
"Incoming links ({{count}})": "Incoming links ({{count}})",
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions",
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
"Template settings": "Template settings",
"Edit diagram": "Edit diagram",
"Edit embed": "Edit embed",
"Edit drawing": "Edit drawing",
"Delete equation": "Delete equation",
"Invite actions": "Invite actions",
"Get started": "Get started",
"* indicates required fields": "* indicates required fields",
"List of spaces in this workspace": "List of spaces in this workspace",
"Active sessions": "Active sessions",
"Add {{name}} to favorites": "Add {{name}} to favorites",
"Remove {{name}} from favorites": "Remove {{name}} from favorites",
"Added to favorites": "Added to favorites",
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.",
"Previous record": "Previous record",
"Next record": "Next record",
"Record actions": "Record actions",
"Delete record": "Delete record",
"Delete record?": "Delete record?",
"This action cannot be undone.": "This action cannot be undone.",
"to navigate": "to navigate",
"to close": "to close",
"Expand row {{number}}": "Expand row {{number}}",
"Saving…": "Saving…",
"Read-only": "Read-only",
"Loading…": "Loading…",
"Updated {{when}}": "Updated {{when}}",
"Add property": "Add property",
"Create property": "Create property",
"Hide properties": "Hide properties",
"Find a property type": "Find a property type",
"Properties": "Properties"
"Skip to main content": "Skip to main content"
}
@@ -71,7 +71,6 @@
"Export": "Exportar",
"Failed to create page": "No se pudo crear la página",
"Failed to delete page": "No se pudo eliminar la página",
"Failed to restore page": "No se pudo restaurar la página",
"Failed to fetch recent pages": "Error al obtener las páginas recientes",
"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.",
@@ -277,9 +276,6 @@
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Align center": "Alinear al centro",
"Alt text": "Texto alternativo",
"Describe this for accessibility.": "Describe esto para la accesibilidad.",
"Add a description": "Agregar una descripción",
"Justify": "Justificar",
"Merge cells": "Combinar celdas",
"Split cell": "Dividir celda",
@@ -290,19 +286,6 @@
"Add row above": "Agregar fila arriba",
"Add row below": "Agregar fila debajo",
"Delete table": "Eliminar tabla",
"Add column left": "Agregar columna a la izquierda",
"Add column right": "Agregar columna a la derecha",
"Clear cell": "Borrar celda",
"Clear cells": "Borrar celdas",
"Toggle header cell": "Alternar celda de encabezado",
"Toggle header column": "Alternar columna de encabezado",
"Toggle header row": "Alternar fila de encabezado",
"Move column left": "Mover columna a la izquierda",
"Move column right": "Mover columna a la derecha",
"Move row down": "Mover fila hacia abajo",
"Move row up": "Mover fila hacia arriba",
"Sort A → Z": "Ordenar de A → Z",
"Sort Z → A": "Ordenar de Z → A",
"Info": "Información",
"Note": "Nota",
"Success": "Satisfactorio",
@@ -365,8 +348,6 @@
"Create block quote.": "Crear una cita en bloque.",
"Insert code snippet.": "Insertar fragmento de código.",
"Insert horizontal rule divider": "Insertar regla horizontal",
"Page break": "Salto de página",
"Insert a page break for printing.": "Inserta un salto de página para imprimir.",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
@@ -411,10 +392,6 @@
"Write...": "Escribe...",
"Column count": "Número de columnas",
"{{count}} Columns": "{count, plural, one {# columna} other {# columnas}}",
"{{count}} command available_one": "1 comando disponible",
"{{count}} command available_other": "{{count}} comandos disponibles",
"{{count}} result available_one": "1 resultado disponible",
"{{count}} result available_other": "{{count}} resultados disponibles",
"Equal columns": "Columnas iguales",
"Left sidebar": "Barra lateral izquierda",
"Right sidebar": "Barra lateral derecha",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
"Default page edit mode": "Modo de edición predeterminado de la página",
"Choose your preferred page edit mode. Avoid accidental edits.": "Elige tu modo de edición de página preferido. Evita ediciones accidentales.",
"Choose {{format}} file": "Elegir archivo {{format}}",
"Reading": "Lectura",
"Delete member": "Eliminar miembro",
"Member deleted successfully": "Miembro eliminado correctamente",
@@ -589,8 +565,6 @@
"Move to trash": "Mover a la papelera",
"Move this page to trash?": "¿Mover esta página a la papelera?",
"Restore page": "Restaurar página",
"Permanently delete": "Eliminar permanentemente",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> movió esta página a la Papelera {{time}}.",
"Page moved to trash": "Página movida a la papelera",
"Page restored successfully": "Página restaurada correctamente",
"Deleted by": "Eliminado por",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update": "Actualizar",
"Update {{credential}}": "Actualizar {{credential}}",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"Restrict API key creation to admins": "Restringir la creación de claves API a administradores",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Solo los administradores y propietarios pueden crear nuevas claves API. Las claves de miembros existentes seguirán funcionando.",
@@ -880,12 +858,9 @@
"AI Chat": "Chat de IA",
"Analyze for insights": "Analizar para obtener información",
"Ask anything...": "Pregunta lo que quieras...",
"Assistant said:": "El asistente dijo:",
"Chat history": "Historial de chat",
"Chat name": "Nombre del chat",
"Chat transcript": "Transcripción del chat",
"Close": "Cerrar",
"Copy assistant response": "Copiar respuesta del asistente",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "No se pudo cargar el chat. Se produjo un error.",
"Failed to render this message.": "No se pudo mostrar este mensaje.",
@@ -895,17 +870,9 @@
"No chats found": "No se encontraron chats",
"No conversations yet": "Aún no hay conversaciones",
"Open full page": "Abrir página completa",
"Scroll to bottom": "Desplazarse hasta abajo",
"You said:": "Dijiste:",
"Previous 7 days": "Últimos 7 días",
"Previous 30 days": "Últimos 30 días",
"Search chats...": "Buscar chats...",
"Search chats": "Buscar chats",
"Ask anything... Use @ to mention pages": "Pregunta lo que sea... Usa @ para mencionar páginas",
"Ask anything or search your workspace": "Pregunta cualquier cosa o busca en tu espacio de trabajo",
"Welcome to {{name}}": "Te damos la bienvenida a {{name}}",
"Add files": "Agregar archivos",
"Mention a page": "Mencionar una página",
"Start a new chat to see it here.": "Inicia un nuevo chat para verlo aquí.",
"Summarize this page": "Resumir esta página",
"Toggle AI Chat": "Alternar chat de IA",
@@ -913,176 +880,5 @@
"Try a different search term.": "Prueba con otro término de búsqueda.",
"Try again": "Intentar de nuevo",
"Untitled chat": "Chat sin título",
"What can I help you with?": "¿En qué puedo ayudarte?",
"Are you sure you want to revoke this {{credential}}": "¿Está seguro de que desea revocar esta {{credential}}?",
"Automatically provision users and groups from your identity provider via SCIM.": "Aprovisione automáticamente usuarios y grupos desde su proveedor de identidad mediante SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure su proveedor de identidad con esta URL para aprovisionar usuarios y grupos.",
"Create {{credential}}": "Crear {{credential}}",
"{{credential}} created": "{{credential}} creada",
"{{credential}} created successfully": "{{credential}} creada con éxito",
"Created by": "Creado por",
"Custom": "Personalizado",
"Enable SCIM": "Habilitar SCIM",
"Enter a descriptive name": "Introduzca un nombre descriptivo",
"I've saved my {{credential}}": "He guardado mi {{credential}}",
"Important": "Importante",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Asegúrese de copiar su {{credential}} ahora. ¡No podrá volver a verla!",
"Never": "Nunca",
"Revoke {{credential}}": "Revocar {{credential}}",
"SCIM endpoint URL": "URL del endpoint de SCIM",
"SCIM provisioning": "Aprovisionamiento SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM tiene prioridad sobre la sincronización de grupos de SSO mientras esté habilitado.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Ha alcanzado el máximo de {{max}} tokens SCIM. Elimine un token existente para crear uno nuevo.",
"SCIM token": "Token SCIM",
"SCIM tokens": "Tokens SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Esta acción no se puede deshacer. Su proveedor de identidad dejará de sincronizarse inmediatamente.",
"Toggle SCIM provisioning": "Activar o desactivar el aprovisionamiento SCIM",
"Token": "Token",
"Page menu": "Menú de la página",
"Expand": "Expandir",
"Collapse": "Contraer",
"Comment menu": "Menú de comentarios",
"Group menu": "Menú del grupo",
"Show hidden breadcrumbs": "Mostrar rutas de navegación ocultas",
"Breadcrumbs": "Rutas de navegación",
"Page actions": "Acciones de la página",
"Pick emoji": "Elegir emoji",
"Template menu": "Menú de plantillas",
"Use": "Usar",
"Use template": "Usar plantilla",
"Preview template: {{title}}": "Vista previa de la plantilla: {{title}}",
"Use a template": "Usar una plantilla",
"Search templates...": "Buscar plantillas...",
"Search spaces...": "Buscar espacios...",
"No templates found": "No se encontraron plantillas",
"No spaces found": "No se encontraron espacios",
"Browse all templates": "Ver todas las plantillas",
"This space": "Este espacio",
"All templates": "Todas las plantillas",
"Global": "Global",
"New template": "Nueva plantilla",
"Edit template": "Editar plantilla",
"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",
"Scope": "Alcance",
"Select scope": "Seleccionar alcance",
"Title": "Título",
"Saving...": "Guardando...",
"Saved": "Guardado",
"Save failed. Retry": "Error al guardar. Reintentar",
"By {{name}}": "Por {{name}}",
"Updated {{time}}": "Actualizado {{time}}",
"Choose destination": "Elegir destino",
"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",
"API key menu": "Menú de la clave API",
"Jump to comment selection": "Ir a la selección de comentarios",
"Slash commands": "Comandos de barra",
"Mention suggestions": "Sugerencias de menciones",
"Link suggestions": "Sugerencias de enlaces",
"Diagram editor": "Editor de diagramas",
"Add comment": "Agregar comentario",
"Find and replace": "Buscar y reemplazar",
"Main navigation": "Navegación principal",
"Space navigation": "Navegación del espacio",
"Settings navigation": "Navegación de configuración",
"AI navigation": "Navegación de IA",
"Breadcrumb": "Ruta de navegación",
"Synced block": "Bloque sincronizado",
"Create a block that stays in sync across pages.": "Crea un bloque que se mantenga sincronizado entre páginas.",
"Editing original": "Editando original",
"Copy synced block": "Copiar bloque sincronizado",
"Unsync": "Desincronizar",
"Delete synced block": "Eliminar bloque sincronizado",
"Synced to {{count}} other page_one": "Sincronizado con {{count}} página más",
"Synced to {{count}} other page_other": "Sincronizado con {{count}} páginas más",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "ESTA PÁGINA",
"No pages": "No hay páginas",
"The original synced block no longer exists": "El bloque sincronizado original ya no existe",
"You don't have access to this synced block": "No tienes acceso a este bloque sincronizado",
"Failed to load this synced block": "No se pudo cargar este bloque sincronizado",
"Fixed editor toolbar": "Barra de herramientas fija del editor",
"Show a formatting toolbar above the editor with quick access to common actions.": "Muestra una barra de herramientas de formato sobre el editor con acceso rápido a acciones comunes.",
"Toggle fixed editor toolbar": "Alternar barra de herramientas fija del editor",
"Normal text": "Texto normal",
"More inline formatting": "Más formato en línea",
"Subscript": "Subíndice",
"Superscript": "Superíndice",
"Inline code": "Código en línea",
"Insert media": "Insertar contenido multimedia",
"Mention": "Mención",
"Emoji": "Emojis",
"Columns": "Columnas",
"More inserts": "Más inserciones",
"Embeds": "Integraciones",
"Diagrams": "Diagramas",
"Advanced": "Avanzado",
"Utility": "Utilidad",
"Decrease indent": "Disminuir sangría",
"Increase indent": "Aumentar sangría",
"Clear formatting": "Borrar formato",
"Code block": "Bloque de código",
"Experimental": "Experimental",
"Strikethrough": "Tachado",
"Undo": "Deshacer",
"Redo": "Rehacer",
"Backlinks": "Enlaces entrantes",
"Last updated by": "Última actualización por",
"Last updated": "Última actualización",
"Stats": "Estadísticas",
"Word count": "Recuento de palabras",
"Characters": "Caracteres",
"Incoming links": "Enlaces entrantes",
"Outgoing links": "Enlaces salientes",
"Incoming links ({{count}})": "Enlaces entrantes ({{count}})",
"Outgoing links ({{count}})": "Enlaces salientes ({{count}})",
"No pages link here yet.": "Todavía no hay páginas que enlacen aquí.",
"This page doesn't link to other pages yet.": "Esta página todavía no enlaza a otras páginas.",
"Verified until {{date}}": "Verificado hasta {{date}}",
"Labels": "Etiquetas",
"Add label": "Agregar etiqueta",
"No labels yet": "Todavía no hay etiquetas",
"Already added": "Ya agregado",
"Invalid label name": "Nombre de etiqueta no válido",
"No matches": "Sin coincidencias",
"Search or create…": "Buscar o crear…",
"Remove label {{name}}": "Eliminar etiqueta {{name}}",
"Failed to add label": "No se pudo agregar la etiqueta",
"Failed to remove label": "No se pudo eliminar la etiqueta",
"No pages with this label": "No hay páginas con esta etiqueta",
"Pages tagged with this label will appear here.": "Las páginas etiquetadas con esta etiqueta aparecerán aquí.",
"No pages match your search.": "Ninguna página coincide con tu búsqueda.",
"Updated {{date}}": "Actualizado el {{date}}",
"Cell actions": "Acciones de celda",
"Column actions": "Acciones de columna",
"Row actions": "Acciones de fila",
"Filter": "Filtrar",
"Page title": "Título de la página",
"Page content": "Contenido de la página",
"Member actions": "Acciones de miembro",
"Toggle password visibility": "Alternar visibilidad de la contraseña",
"Send comment": "Enviar comentario",
"Token actions": "Acciones de token",
"Template settings": "Configuración de la plantilla",
"Edit diagram": "Editar diagrama",
"Edit embed": "Editar contenido integrado",
"Edit drawing": "Editar dibujo",
"Delete equation": "Eliminar ecuación",
"Invite actions": "Acciones de invitación",
"Get started": "Comenzar",
"* indicates required fields": "* indica los campos obligatorios",
"List of spaces in this workspace": "Lista de espacios en este espacio de trabajo",
"Active sessions": "Sesiones activas",
"Add {{name}} to favorites": "Agregar {{name}} a favoritos",
"Remove {{name}} from favorites": "Quitar {{name}} de favoritos",
"Added to favorites": "Agregado a favoritos",
"Removed from favorites": "Quitado de favoritos",
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
"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}}"
"What can I help you with?": "¿En qué puedo ayudarte?"
}
@@ -71,7 +71,6 @@
"Export": "Exporter",
"Failed to create page": "Échec de la création de la page",
"Failed to delete page": "Échec de la suppression de la page",
"Failed to restore page": "Échec de la restauration de la page",
"Failed to fetch recent pages": "Échec de la récupération des pages récentes",
"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.",
@@ -277,9 +276,6 @@
"Align left": "Aligner à gauche",
"Align right": "Aligner à droite",
"Align center": "Aligner au centre",
"Alt text": "Texte alternatif",
"Describe this for accessibility.": "Décrivez ceci pour laccessibilité.",
"Add a description": "Ajouter une description",
"Justify": "Justifier",
"Merge cells": "Fusionner les cellules",
"Split cell": "Diviser la cellule",
@@ -290,19 +286,6 @@
"Add row above": "Ajouter une ligne au-dessus",
"Add row below": "Ajouter une ligne en dessous",
"Delete table": "Supprimer le tableau",
"Add column left": "Ajouter une colonne à gauche",
"Add column right": "Ajouter une colonne à droite",
"Clear cell": "Effacer la cellule",
"Clear cells": "Effacer les cellules",
"Toggle header cell": "Activer/désactiver la cellule den-tête",
"Toggle header column": "Activer/désactiver la colonne den-tête",
"Toggle header row": "Activer/désactiver la ligne den-tête",
"Move column left": "Déplacer la colonne vers la gauche",
"Move column right": "Déplacer la colonne vers la droite",
"Move row down": "Déplacer la ligne vers le bas",
"Move row up": "Déplacer la ligne vers le haut",
"Sort A → Z": "Trier de A à Z",
"Sort Z → A": "Trier de Z à A",
"Info": "Info",
"Note": "Remarque",
"Success": "Succès",
@@ -365,8 +348,6 @@
"Create block quote.": "Créez un bloc de citation.",
"Insert code snippet.": "Insérez un extrait de code.",
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Page break": "Saut de page",
"Insert a page break for printing.": "Insérer un saut de page pour limpression.",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
@@ -411,10 +392,6 @@
"Write...": "Écrire...",
"Column count": "Nombre de colonnes",
"{{count}} Columns": "{count, plural, one {# colonne} other {# colonnes}}",
"{{count}} command available_one": "1 commande disponible",
"{{count}} command available_other": "{{count}} commandes disponibles",
"{{count}} result available_one": "1 résultat disponible",
"{{count}} result available_other": "{{count}} résultats disponibles",
"Equal columns": "Colonnes égales",
"Left sidebar": "Barre latérale gauche",
"Right sidebar": "Barre latérale droite",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
"Default page edit mode": "Mode d’édition par défaut de la page",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choisissez votre mode d'édition de page préféré. Évitez les modifications accidentelles.",
"Choose {{format}} file": "Choisir un fichier {{format}}",
"Reading": "Lecture",
"Delete member": "Supprimer le membre",
"Member deleted successfully": "Membre supprimé avec succès",
@@ -589,8 +565,6 @@
"Move to trash": "Déplacer vers la corbeille",
"Move this page to trash?": "Déplacer cette page vers la corbeille ?",
"Restore page": "Restaurer la page",
"Permanently delete": "Supprimer définitivement",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> a déplacé cette page vers la corbeille {{time}}.",
"Page moved to trash": "Page déplacée vers la corbeille",
"Page restored successfully": "Page restaurée avec succès",
"Deleted by": "Supprimé par",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update": "Mettre à jour",
"Update {{credential}}": "Mettre à jour {{credential}}",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"Restrict API key creation to admins": "Restreindre la création de clés API aux administrateurs",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Seuls les administrateurs et les propriétaires peuvent créer de nouvelles clés API. Les clés des membres existants continueront de fonctionner.",
@@ -880,12 +858,9 @@
"AI Chat": "Chat IA",
"Analyze for insights": "Analyser pour obtenir des informations",
"Ask anything...": "Posez nimporte quelle question...",
"Assistant said:": "Lassistant a dit :",
"Chat history": "Historique des discussions",
"Chat name": "Nom de la discussion",
"Chat transcript": "Transcription du chat",
"Close": "Fermer",
"Copy assistant response": "Copier la réponse de lassistant",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Échec du chargement de la discussion. Une erreur sest produite.",
"Failed to render this message.": "Échec de laffichage de ce message.",
@@ -895,17 +870,9 @@
"No chats found": "Aucune discussion trouvée",
"No conversations yet": "Aucune conversation pour le moment",
"Open full page": "Ouvrir la page complète",
"Scroll to bottom": "Faire défiler jusquen bas",
"You said:": "Vous avez dit :",
"Previous 7 days": "7 derniers jours",
"Previous 30 days": "30 derniers jours",
"Search chats...": "Rechercher des discussions...",
"Search chats": "Rechercher des discussions",
"Ask anything... Use @ to mention pages": "Demandez nimporte quoi… Utilisez @ pour mentionner des pages",
"Ask anything or search your workspace": "Posez nimporte quelle question ou recherchez dans votre espace de travail",
"Welcome to {{name}}": "Bienvenue sur {{name}}",
"Add files": "Ajouter des fichiers",
"Mention a page": "Mentionner une page",
"Start a new chat to see it here.": "Commencez une nouvelle discussion pour la voir ici.",
"Summarize this page": "Résumer cette page",
"Toggle AI Chat": "Basculer le chat IA",
@@ -913,176 +880,5 @@
"Try a different search term.": "Essayez un autre terme de recherche.",
"Try again": "Réessayer",
"Untitled chat": "Discussion sans titre",
"What can I help you with?": "Que puis-je faire pour vous aider ?",
"Are you sure you want to revoke this {{credential}}": "Êtes-vous sûr de vouloir révoquer ce/cette {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Provisionnez automatiquement les utilisateurs et les groupes depuis votre fournisseur didentité via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configurez votre fournisseur didentité avec cette URL pour provisionner les utilisateurs et les groupes.",
"Create {{credential}}": "Créer {{credential}}",
"{{credential}} created": "{{credential}} créé",
"{{credential}} created successfully": "{{credential}} créé avec succès",
"Created by": "Créé par",
"Custom": "Personnalisé",
"Enable SCIM": "Activer SCIM",
"Enter a descriptive name": "Saisissez un nom descriptif",
"I've saved my {{credential}}": "Jai enregistré mon/ma {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Assurez-vous de copier votre {{credential}} maintenant. Vous ne pourrez plus le/la voir ensuite !",
"Never": "Jamais",
"Revoke {{credential}}": "Révoquer {{credential}}",
"SCIM endpoint URL": "URL du point de terminaison SCIM",
"SCIM provisioning": "Provisionnement SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM a priorité sur la synchronisation des groupes SSO lorsquil est activé.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Vous avez atteint le maximum de {{max}} jetons SCIM. Supprimez un jeton existant pour en créer un nouveau.",
"SCIM token": "Jeton SCIM",
"SCIM tokens": "Jetons SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Cette action est irréversible. Votre fournisseur didentité cessera immédiatement la synchronisation.",
"Toggle SCIM provisioning": "Activer/désactiver le provisionnement SCIM",
"Token": "Jeton",
"Page menu": "Menu de la page",
"Expand": "Développer",
"Collapse": "Réduire",
"Comment menu": "Menu du commentaire",
"Group menu": "Menu du groupe",
"Show hidden breadcrumbs": "Afficher les fils dAriane masqués",
"Breadcrumbs": "Fils dAriane",
"Page actions": "Actions de la page",
"Pick emoji": "Choisir un emoji",
"Template menu": "Menu du modèle",
"Use": "Utiliser",
"Use template": "Utiliser le modèle",
"Preview template: {{title}}": "Aperçu du modèle : {{title}}",
"Use a template": "Utiliser un modèle",
"Search templates...": "Rechercher des modèles...",
"Search spaces...": "Rechercher des espaces...",
"No templates found": "Aucun modèle trouvé",
"No spaces found": "Aucun espace trouvé",
"Browse all templates": "Parcourir tous les modèles",
"This space": "Cet espace",
"All templates": "Tous les modèles",
"Global": "Global",
"New template": "Nouveau modèle",
"Edit template": "Modifier le modèle",
"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",
"Scope": "Portée",
"Select scope": "Sélectionner la portée",
"Title": "Titre",
"Saving...": "Enregistrement...",
"Saved": "Enregistré",
"Save failed. Retry": "Échec de lenregistrement. Réessayer",
"By {{name}}": "Par {{name}}",
"Updated {{time}}": "Mis à jour {{time}}",
"Choose destination": "Choisir la destination",
"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",
"API key menu": "Menu de la clé API",
"Jump to comment selection": "Aller à la sélection de commentaires",
"Slash commands": "Commandes slash",
"Mention suggestions": "Suggestions de mention",
"Link suggestions": "Suggestions de liens",
"Diagram editor": "Éditeur de diagrammes",
"Add comment": "Ajouter un commentaire",
"Find and replace": "Rechercher et remplacer",
"Main navigation": "Navigation principale",
"Space navigation": "Navigation de lespace",
"Settings navigation": "Navigation des paramètres",
"AI navigation": "Navigation IA",
"Breadcrumb": "Fil dAriane",
"Synced block": "Bloc synchronisé",
"Create a block that stays in sync across pages.": "Créez un bloc qui reste synchronisé entre les pages.",
"Editing original": "Modification de loriginal",
"Copy synced block": "Copier le bloc synchronisé",
"Unsync": "Désynchroniser",
"Delete synced block": "Supprimer le bloc synchronisé",
"Synced to {{count}} other page_one": "Synchronisée avec {{count}} autre page",
"Synced to {{count}} other page_other": "Synchronisée avec {{count}} autres pages",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "CETTE PAGE",
"No pages": "Aucune page",
"The original synced block no longer exists": "Le bloc synchronisé dorigine nexiste plus",
"You don't have access to this synced block": "Vous navez pas accès à ce bloc synchronisé",
"Failed to load this synced block": "Échec du chargement de ce bloc synchronisé",
"Fixed editor toolbar": "Barre doutils de l’éditeur fixe",
"Show a formatting toolbar above the editor with quick access to common actions.": "Afficher une barre doutils de mise en forme au-dessus de l’éditeur avec un accès rapide aux actions courantes.",
"Toggle fixed editor toolbar": "Activer/désactiver la barre doutils de l’éditeur fixe",
"Normal text": "Texte normal",
"More inline formatting": "Plus de mise en forme en ligne",
"Subscript": "Indice",
"Superscript": "Exposant",
"Inline code": "Code en ligne",
"Insert media": "Insérer un média",
"Mention": "Mention",
"Emoji": "Emoji",
"Columns": "Colonnes",
"More inserts": "Plus dinsertions",
"Embeds": "Intégrations",
"Diagrams": "Diagrammes",
"Advanced": "Avancé",
"Utility": "Utilitaire",
"Decrease indent": "Réduire le retrait",
"Increase indent": "Augmenter le retrait",
"Clear formatting": "Effacer la mise en forme",
"Code block": "Bloc de code",
"Experimental": "Expérimental",
"Strikethrough": "Barré",
"Undo": "Annuler",
"Redo": "Rétablir",
"Backlinks": "Liens retour",
"Last updated by": "Dernière mise à jour par",
"Last updated": "Dernière mise à jour",
"Stats": "Statistiques",
"Word count": "Nombre de mots",
"Characters": "Caractères",
"Incoming links": "Liens entrants",
"Outgoing links": "Liens sortants",
"Incoming links ({{count}})": "Liens entrants ({{count}})",
"Outgoing links ({{count}})": "Liens sortants ({{count}})",
"No pages link here yet.": "Aucune page ne pointe encore ici.",
"This page doesn't link to other pages yet.": "Cette page ne renvoie pas encore vers dautres pages.",
"Verified until {{date}}": "Vérifié jusquau {{date}}",
"Labels": "Étiquettes",
"Add label": "Ajouter une étiquette",
"No labels yet": "Aucune étiquette pour linstant",
"Already added": "Déjà ajouté",
"Invalid label name": "Nom d’étiquette invalide",
"No matches": "Aucune correspondance",
"Search or create…": "Rechercher ou créer…",
"Remove label {{name}}": "Supprimer l’étiquette {{name}}",
"Failed to add label": "Échec de lajout de l’étiquette",
"Failed to remove label": "Échec de la suppression de l’étiquette",
"No pages with this label": "Aucune page avec cette étiquette",
"Pages tagged with this label will appear here.": "Les pages portant cette étiquette apparaîtront ici.",
"No pages match your search.": "Aucune page ne correspond à votre recherche.",
"Updated {{date}}": "Mis à jour le {{date}}",
"Cell actions": "Actions de cellule",
"Column actions": "Actions de colonne",
"Row actions": "Actions de ligne",
"Filter": "Filtrer",
"Page title": "Titre de la page",
"Page content": "Contenu de la page",
"Member actions": "Actions des membres",
"Toggle password visibility": "Afficher/masquer le mot de passe",
"Send comment": "Envoyer le commentaire",
"Token actions": "Actions du jeton",
"Template settings": "Paramètres du modèle",
"Edit diagram": "Modifier le diagramme",
"Edit embed": "Modifier lintégration",
"Edit drawing": "Modifier le dessin",
"Delete equation": "Supprimer l’équation",
"Invite actions": "Actions dinvitation",
"Get started": "Commencer",
"* indicates required fields": "* indique les champs obligatoires",
"List of spaces in this workspace": "Liste des espaces de cet espace de travail",
"Active sessions": "Sessions actives",
"Add {{name}} to favorites": "Ajouter {{name}} aux favoris",
"Remove {{name}} from favorites": "Retirer {{name}} des favoris",
"Added to favorites": "Ajouté aux favoris",
"Removed from favorites": "Retiré des favoris",
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
"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}}"
"What can I help you with?": "Que puis-je faire pour vous aider ?"
}
@@ -71,7 +71,6 @@
"Export": "Esporta",
"Failed to create page": "Impossibile creare la pagina",
"Failed to delete page": "Impossibile eliminare la pagina",
"Failed to restore page": "Impossibile ripristinare la pagina",
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
"Failed to import pages": "Impossibile importare le pagine",
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
@@ -277,9 +276,6 @@
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
"Align center": "Allinea al centro",
"Alt text": "Testo alternativo",
"Describe this for accessibility.": "Descrivi questo contenuto per l'accessibilità.",
"Add a description": "Aggiungi una descrizione",
"Justify": "Giustifica",
"Merge cells": "Unisci celle",
"Split cell": "Dividi cella",
@@ -290,19 +286,6 @@
"Add row above": "Aggiungi riga sopra",
"Add row below": "Aggiungi riga sotto",
"Delete table": "Elimina tabella",
"Add column left": "Aggiungi colonna a sinistra",
"Add column right": "Aggiungi colonna a destra",
"Clear cell": "Cancella cella",
"Clear cells": "Cancella celle",
"Toggle header cell": "Attiva/disattiva cella di intestazione",
"Toggle header column": "Attiva/disattiva colonna di intestazione",
"Toggle header row": "Attiva/disattiva riga di intestazione",
"Move column left": "Sposta colonna a sinistra",
"Move column right": "Sposta colonna a destra",
"Move row down": "Sposta riga in basso",
"Move row up": "Sposta riga in alto",
"Sort A → Z": "Ordina A → Z",
"Sort Z → A": "Ordina Z → A",
"Info": "Informazioni",
"Note": "Nota",
"Success": "Successo",
@@ -365,8 +348,6 @@
"Create block quote.": "Crea blocco citazione.",
"Insert code snippet.": "Inserisci frammento di codice.",
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Page break": "Interruzione di pagina",
"Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
@@ -411,10 +392,6 @@
"Write...": "Scrivi...",
"Column count": "Numero di colonne",
"{{count}} Columns": "{{count}} colonne",
"{{count}} command available_one": "1 comando disponibile",
"{{count}} command available_other": "{{count}} comandi disponibili",
"{{count}} result available_one": "1 risultato disponibile",
"{{count}} result available_other": "{{count}} risultati disponibili",
"Equal columns": "Colonne uguali",
"Left sidebar": "Barra laterale sinistra",
"Right sidebar": "Barra laterale destra",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
"Default page edit mode": "Modalità di modifica predefinita della pagina",
"Choose your preferred page edit mode. Avoid accidental edits.": "Scegli la tua modalità di modifica della pagina preferita. Evita modifiche accidentali.",
"Choose {{format}} file": "Scegli file {{format}}",
"Reading": "Lettura",
"Delete member": "Elimina membro",
"Member deleted successfully": "Membro eliminato con successo",
@@ -589,8 +565,6 @@
"Move to trash": "Sposta nel cestino",
"Move this page to trash?": "Spostare questa pagina nel cestino?",
"Restore page": "Ripristina pagina",
"Permanently delete": "Elimina definitivamente",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> ha spostato questa pagina nel Cestino {{time}}.",
"Page moved to trash": "Pagina spostata nel cestino",
"Page restored successfully": "Pagina ripristinata con successo",
"Deleted by": "Eliminato da",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update": "Aggiorna",
"Update {{credential}}": "Aggiorna {{credential}}",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"Restrict API key creation to admins": "Limita la creazione delle chiavi API agli amministratori",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Solo gli amministratori e i proprietari possono creare nuove chiavi API. Le chiavi dei membri esistenti continueranno a funzionare.",
@@ -880,12 +858,9 @@
"AI Chat": "Chat IA",
"Analyze for insights": "Analizza per ottenere approfondimenti",
"Ask anything...": "Chiedi qualsiasi cosa...",
"Assistant said:": "L'assistente ha detto:",
"Chat history": "Cronologia chat",
"Chat name": "Nome chat",
"Chat transcript": "Trascrizione della chat",
"Close": "Chiudi",
"Copy assistant response": "Copia risposta dell'assistente",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Caricamento della chat non riuscito. Si è verificato un errore.",
"Failed to render this message.": "Impossibile visualizzare questo messaggio.",
@@ -895,17 +870,9 @@
"No chats found": "Nessuna chat trovata",
"No conversations yet": "Nessuna conversazione al momento",
"Open full page": "Apri pagina completa",
"Scroll to bottom": "Scorri in basso",
"You said:": "Hai detto:",
"Previous 7 days": "Ultimi 7 giorni",
"Previous 30 days": "Ultimi 30 giorni",
"Search chats...": "Cerca nelle chat...",
"Search chats": "Cerca nelle chat",
"Ask anything... Use @ to mention pages": "Chiedi qualsiasi cosa... Usa @ per menzionare le pagine",
"Ask anything or search your workspace": "Chiedi qualsiasi cosa o cerca nel tuo spazio di lavoro",
"Welcome to {{name}}": "Benvenuto in {{name}}",
"Add files": "Aggiungi file",
"Mention a page": "Menziona una pagina",
"Start a new chat to see it here.": "Avvia una nuova chat per vederla qui.",
"Summarize this page": "Riassumi questa pagina",
"Toggle AI Chat": "Attiva/disattiva Chat IA",
@@ -913,176 +880,5 @@
"Try a different search term.": "Prova un termine di ricerca diverso.",
"Try again": "Riprova",
"Untitled chat": "Chat senza titolo",
"What can I help you with?": "Con cosa posso aiutarti?",
"Are you sure you want to revoke this {{credential}}": "Sei sicuro di voler revocare questa {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Esegui automaticamente il provisioning di utenti e gruppi dal tuo provider di identità tramite SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configura il tuo provider di identità con questo URL per eseguire il provisioning di utenti e gruppi.",
"Create {{credential}}": "Crea {{credential}}",
"{{credential}} created": "{{credential}} creata",
"{{credential}} created successfully": "{{credential}} creata con successo",
"Created by": "Creata da",
"Custom": "Personalizzato",
"Enable SCIM": "Abilita SCIM",
"Enter a descriptive name": "Inserisci un nome descrittivo",
"I've saved my {{credential}}": "Ho salvato la mia {{credential}}",
"Important": "Importante",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Assicurati di copiare subito la tua {{credential}}. Non potrai più visualizzarla!",
"Never": "Mai",
"Revoke {{credential}}": "Revoca {{credential}}",
"SCIM endpoint URL": "URL dell'endpoint SCIM",
"SCIM provisioning": "Provisioning SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM ha la precedenza sulla sincronizzazione dei gruppi SSO quando è abilitato.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Hai raggiunto il numero massimo di {{max}} token SCIM. Elimina un token esistente per crearne uno nuovo.",
"SCIM token": "Token SCIM",
"SCIM tokens": "Token SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Questa azione non può essere annullata. Il tuo provider di identità smetterà di sincronizzarsi immediatamente.",
"Toggle SCIM provisioning": "Attiva/disattiva il provisioning SCIM",
"Token": "Token",
"Page menu": "Menu della pagina",
"Expand": "Espandi",
"Collapse": "Comprimi",
"Comment menu": "Menu dei commenti",
"Group menu": "Menu del gruppo",
"Show hidden breadcrumbs": "Mostra breadcrumb nascosti",
"Breadcrumbs": "Breadcrumb",
"Page actions": "Azioni della pagina",
"Pick emoji": "Scegli emoji",
"Template menu": "Menu del modello",
"Use": "Usa",
"Use template": "Usa modello",
"Preview template: {{title}}": "Anteprima modello: {{title}}",
"Use a template": "Usa un modello",
"Search templates...": "Cerca modelli...",
"Search spaces...": "Cerca spazi...",
"No templates found": "Nessun modello trovato",
"No spaces found": "Nessuno spazio trovato",
"Browse all templates": "Sfoglia tutti i modelli",
"This space": "Questo spazio",
"All templates": "Tutti i modelli",
"Global": "Globale",
"New template": "Nuovo modello",
"Edit template": "Modifica modello",
"Are you sure you want to delete this template?": "Sei sicuro di voler eliminare questo modello?",
"Template scope updated": "Ambito del modello aggiornato",
"Choose which space this template belongs to": "Scegli a quale spazio appartiene questo modello",
"Scope": "Ambito",
"Select scope": "Seleziona ambito",
"Title": "Titolo",
"Saving...": "Salvataggio...",
"Saved": "Salvato",
"Save failed. Retry": "Salvataggio non riuscito. Riprova",
"By {{name}}": "Di {{name}}",
"Updated {{time}}": "Aggiornato {{time}}",
"Choose destination": "Scegli destinazione",
"Search pages and spaces...": "Cerca pagine e spazi...",
"No results found": "Nessun risultato trovato",
"You don't have permission to create pages here": "Non hai l'autorizzazione per creare pagine qui",
"Chat menu": "Menu della chat",
"API key menu": "Menu della chiave API",
"Jump to comment selection": "Vai alla selezione dei commenti",
"Slash commands": "Comandi slash",
"Mention suggestions": "Suggerimenti di menzione",
"Link suggestions": "Suggerimenti di link",
"Diagram editor": "Editor di diagrammi",
"Add comment": "Aggiungi commento",
"Find and replace": "Trova e sostituisci",
"Main navigation": "Navigazione principale",
"Space navigation": "Navigazione dello spazio",
"Settings navigation": "Navigazione delle impostazioni",
"AI navigation": "Navigazione AI",
"Breadcrumb": "Percorso di navigazione",
"Synced block": "Blocco sincronizzato",
"Create a block that stays in sync across pages.": "Crea un blocco che rimanga sincronizzato tra le pagine.",
"Editing original": "Modifica originale",
"Copy synced block": "Copia blocco sincronizzato",
"Unsync": "Annulla sincronizzazione",
"Delete synced block": "Elimina blocco sincronizzato",
"Synced to {{count}} other page_one": "Sincronizzato con {{count}} altra pagina",
"Synced to {{count}} other page_other": "Sincronizzato con {{count}} altre pagine",
"ORIGINAL": "ORIGINALE",
"THIS PAGE": "QUESTA PAGINA",
"No pages": "Nessuna pagina",
"The original synced block no longer exists": "Il blocco sincronizzato originale non esiste più",
"You don't have access to this synced block": "Non hai accesso a questo blocco sincronizzato",
"Failed to load this synced block": "Impossibile caricare questo blocco sincronizzato",
"Fixed editor toolbar": "Barra degli strumenti dell'editor fissa",
"Show a formatting toolbar above the editor with quick access to common actions.": "Mostra una barra degli strumenti di formattazione sopra l'editor con accesso rapido alle azioni comuni.",
"Toggle fixed editor toolbar": "Attiva/disattiva barra degli strumenti dell'editor fissa",
"Normal text": "Testo normale",
"More inline formatting": "Altra formattazione in linea",
"Subscript": "Pedice",
"Superscript": "Apice",
"Inline code": "Codice in linea",
"Insert media": "Inserisci contenuti multimediali",
"Mention": "Menzione",
"Emoji": "Emoji",
"Columns": "Colonne",
"More inserts": "Altri inserimenti",
"Embeds": "Incorporamenti",
"Diagrams": "Diagrammi",
"Advanced": "Avanzate",
"Utility": "Utilità",
"Decrease indent": "Riduci rientro",
"Increase indent": "Aumenta rientro",
"Clear formatting": "Cancella formattazione",
"Code block": "Blocco di codice",
"Experimental": "Sperimentale",
"Strikethrough": "Barrato",
"Undo": "Annulla",
"Redo": "Ripeti",
"Backlinks": "Backlink",
"Last updated by": "Ultimo aggiornamento di",
"Last updated": "Ultimo aggiornamento",
"Stats": "Statistiche",
"Word count": "Conteggio parole",
"Characters": "Caratteri",
"Incoming links": "Link in entrata",
"Outgoing links": "Link in uscita",
"Incoming links ({{count}})": "Link in entrata ({{count}})",
"Outgoing links ({{count}})": "Link in uscita ({{count}})",
"No pages link here yet.": "Nessuna pagina rimanda ancora qui.",
"This page doesn't link to other pages yet.": "Questa pagina non rimanda ancora ad altre pagine.",
"Verified until {{date}}": "Verificato fino al {{date}}",
"Labels": "Etichette",
"Add label": "Aggiungi etichetta",
"No labels yet": "Nessuna etichetta per ora",
"Already added": "Già aggiunto",
"Invalid label name": "Nome etichetta non valido",
"No matches": "Nessuna corrispondenza",
"Search or create…": "Cerca o crea…",
"Remove label {{name}}": "Rimuovi etichetta {{name}}",
"Failed to add label": "Impossibile aggiungere l'etichetta",
"Failed to remove label": "Impossibile rimuovere l'etichetta",
"No pages with this label": "Nessuna pagina con questa etichetta",
"Pages tagged with this label will appear here.": "Le pagine contrassegnate con questa etichetta appariranno qui.",
"No pages match your search.": "Nessuna pagina corrisponde alla tua ricerca.",
"Updated {{date}}": "Aggiornato il {{date}}",
"Cell actions": "Azioni cella",
"Column actions": "Azioni colonna",
"Row actions": "Azioni riga",
"Filter": "Filtro",
"Page title": "Titolo pagina",
"Page content": "Contenuto della pagina",
"Member actions": "Azioni membro",
"Toggle password visibility": "Attiva/disattiva visibilità password",
"Send comment": "Invia commento",
"Token actions": "Azioni token",
"Template settings": "Impostazioni modello",
"Edit diagram": "Modifica diagramma",
"Edit embed": "Modifica incorporamento",
"Edit drawing": "Modifica disegno",
"Delete equation": "Elimina equazione",
"Invite actions": "Azioni invito",
"Get started": "Inizia",
"* indicates required fields": "* indica i campi obbligatori",
"List of spaces in this workspace": "Elenco degli spazi in questo spazio di lavoro",
"Active sessions": "Sessioni attive",
"Add {{name}} to favorites": "Aggiungi {{name}} ai preferiti",
"Remove {{name}} from favorites": "Rimuovi {{name}} dai preferiti",
"Added to favorites": "Aggiunto ai preferiti",
"Removed from favorites": "Rimosso dai preferiti",
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
"Page menu for {{name}}": "Menu della pagina per {{name}}",
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
"What can I help you with?": "Con cosa posso aiutarti?"
}
@@ -71,7 +71,6 @@
"Export": "エクスポート",
"Failed to create page": "ページの作成に失敗しました",
"Failed to delete page": "ページの削除に失敗しました",
"Failed to restore page": "ページの復元に失敗しました",
"Failed to fetch recent pages": "最近のページを取得できませんでした",
"Failed to import pages": "ページのインポートに失敗しました",
"Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。",
@@ -277,9 +276,6 @@
"Align left": "左揃え",
"Align right": "右揃え",
"Align center": "中央揃え",
"Alt text": "代替テキスト",
"Describe this for accessibility.": "アクセシビリティのために説明を追加してください。",
"Add a description": "説明を追加",
"Justify": "両端揃え",
"Merge cells": "セルを結合",
"Split cell": "セルを分割",
@@ -290,19 +286,6 @@
"Add row above": "上に行を追加",
"Add row below": "下に行を追加",
"Delete table": "テーブルを削除",
"Add column left": "左に列を追加",
"Add column right": "右に列を追加",
"Clear cell": "セルをクリア",
"Clear cells": "セルをクリア",
"Toggle header cell": "ヘッダーセルを切り替え",
"Toggle header column": "ヘッダー列を切り替え",
"Toggle header row": "ヘッダー行を切り替え",
"Move column left": "列を左に移動",
"Move column right": "列を右に移動",
"Move row down": "行を下に移動",
"Move row up": "行を上に移動",
"Sort A → Z": "A → Z で並べ替え",
"Sort Z → A": "Z → A で並べ替え",
"Info": "情報",
"Note": "ノート",
"Success": "成功",
@@ -365,8 +348,6 @@
"Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを挿入します",
"Insert horizontal rule divider": "区切り線を挿入します",
"Page break": "改ページ",
"Insert a page break for printing.": "印刷用に改ページを挿入します。",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
@@ -411,10 +392,6 @@
"Write...": "ここに入力...",
"Column count": "列数",
"{{count}} Columns": "{{count}}列",
"{{count}} command available_one": "利用可能なコマンドが1件あります",
"{{count}} command available_other": "利用可能なコマンドが{{count}}件あります",
"{{count}} result available_one": "結果が1件あります",
"{{count}} result available_other": "結果が{{count}}件あります",
"Equal columns": "均等な列",
"Left sidebar": "左サイドバー",
"Right sidebar": "右サイドバー",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} が利用可能です",
"Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します)",
"Choose {{format}} file": "{{format}} ファイルを選択",
"Reading": "閲覧",
"Delete member": "メンバーを削除",
"Member deleted successfully": "メンバーが正常に削除されました",
@@ -589,8 +565,6 @@
"Move to trash": "ゴミ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元",
"Permanently delete": "完全に削除",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> が {{time}} にこのページをゴミ箱に移動しました。",
"Page moved to trash": "ページをゴミ箱に移動しました",
"Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました",
"API key": "APIキー",
"API key created successfully": "APIキーを作成しました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"Update": "更新",
"Update {{credential}}": "{{credential}}を更新",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"Restrict API key creation to admins": "APIキーの作成を管理者のみに制限する",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "新しいAPIキーを作成できるのは管理者とオーナーのみです。既存のメンバーキーは引き続き有効です。",
@@ -880,12 +858,9 @@
"AI Chat": "AI チャット",
"Analyze for insights": "分析してインサイトを得る",
"Ask anything...": "何でも聞いてください...",
"Assistant said:": "アシスタントの回答:",
"Chat history": "チャット履歴",
"Chat name": "チャット名",
"Chat transcript": "チャットの記録",
"Close": "閉じる",
"Copy assistant response": "アシスタントの回答をコピー",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "チャットの読み込みに失敗しました。エラーが発生しました。",
"Failed to render this message.": "このメッセージの表示に失敗しました。",
@@ -895,17 +870,9 @@
"No chats found": "チャットが見つかりません",
"No conversations yet": "会話はまだありません",
"Open full page": "全ページで開く",
"Scroll to bottom": "一番下までスクロール",
"You said:": "あなたの発言:",
"Previous 7 days": "過去 7 日間",
"Previous 30 days": "過去 30 日間",
"Search chats...": "チャットを検索...",
"Search chats": "チャットを検索",
"Ask anything... Use @ to mention pages": "何でも質問してください… @ を使ってページにメンションできます",
"Ask anything or search your workspace": "何でも質問するか、ワークスペースを検索",
"Welcome to {{name}}": "{{name}} へようこそ",
"Add files": "ファイルを追加",
"Mention a page": "ページにメンション",
"Start a new chat to see it here.": "ここに表示するには新しいチャットを開始してください。",
"Summarize this page": "このページを要約",
"Toggle AI Chat": "AI チャットを切り替え",
@@ -913,176 +880,5 @@
"Try a different search term.": "別の検索語を試してください。",
"Try again": "再試行",
"Untitled chat": "無題のチャット",
"What can I help you with?": "何をお手伝いしましょうか?",
"Are you sure you want to revoke this {{credential}}": "この{{credential}}を無効にしてもよろしいですか",
"Automatically provision users and groups from your identity provider via SCIM.": "SCIM を介して、ID プロバイダーからユーザーとグループを自動的にプロビジョニングします。",
"Configure your identity provider with this URL to provision users and groups.": "この URL を使用して ID プロバイダーを設定し、ユーザーとグループをプロビジョニングします。",
"Create {{credential}}": "{{credential}}を作成",
"{{credential}} created": "{{credential}}を作成しました",
"{{credential}} created successfully": "{{credential}}を正常に作成しました",
"Created by": "作成者",
"Custom": "カスタム",
"Enable SCIM": "SCIM を有効にする",
"Enter a descriptive name": "説明的な名前を入力してください",
"I've saved my {{credential}}": "{{credential}}を保存しました",
"Important": "重要",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "今すぐ {{credential}} をコピーしてください。後でもう一度表示することはできません!",
"Never": "なし",
"Revoke {{credential}}": "{{credential}}を無効にする",
"SCIM endpoint URL": "SCIM エンドポイント URL",
"SCIM provisioning": "SCIM プロビジョニング",
"SCIM takes precedence over SSO group sync while enabled.": "有効になっている間は、SCIM が SSO グループ同期より優先されます。",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "SCIM トークンの上限 {{max}} に達しました。新しいトークンを作成するには、既存のトークンを削除してください。",
"SCIM token": "SCIM トークン",
"SCIM tokens": "SCIM トークン",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "この操作は元に戻せません。ID プロバイダーは直ちに同期を停止します。",
"Toggle SCIM provisioning": "SCIM プロビジョニングを切り替える",
"Token": "トークン",
"Page menu": "ページメニュー",
"Expand": "展開",
"Collapse": "折りたたむ",
"Comment menu": "コメントメニュー",
"Group menu": "グループメニュー",
"Show hidden breadcrumbs": "非表示のパンくずリストを表示",
"Breadcrumbs": "パンくずリスト",
"Page actions": "ページアクション",
"Pick emoji": "絵文字を選択",
"Template menu": "テンプレートメニュー",
"Use": "使用",
"Use template": "テンプレートを使用",
"Preview template: {{title}}": "テンプレートをプレビュー: {{title}}",
"Use a template": "テンプレートを使用",
"Search templates...": "テンプレートを検索…",
"Search spaces...": "スペースを検索…",
"No templates found": "テンプレートが見つかりません",
"No spaces found": "スペースが見つかりません",
"Browse all templates": "すべてのテンプレートを表示",
"This space": "このスペース",
"All templates": "すべてのテンプレート",
"Global": "グローバル",
"New template": "新しいテンプレート",
"Edit template": "テンプレートを編集",
"Are you sure you want to delete this template?": "このテンプレートを削除してもよろしいですか?",
"Template scope updated": "テンプレートのスコープを更新しました",
"Choose which space this template belongs to": "このテンプレートを所属させるスペースを選択",
"Scope": "スコープ",
"Select scope": "スコープを選択",
"Title": "タイトル",
"Saving...": "保存中…",
"Saved": "保存しました",
"Save failed. Retry": "保存に失敗しました。再試行",
"By {{name}}": "{{name}} 作成",
"Updated {{time}}": "{{time}} に更新",
"Choose destination": "保存先を選択",
"Search pages and spaces...": "ページとスペースを検索…",
"No results found": "結果が見つかりません",
"You don't have permission to create pages here": "ここにページを作成する権限がありません",
"Chat menu": "チャットメニュー",
"API key menu": "API キーメニュー",
"Jump to comment selection": "コメント選択に移動",
"Slash commands": "スラッシュコマンド",
"Mention suggestions": "メンション候補",
"Link suggestions": "リンク候補",
"Diagram editor": "ダイアグラムエディター",
"Add comment": "コメントを追加",
"Find and replace": "検索と置換",
"Main navigation": "メインナビゲーション",
"Space navigation": "スペースナビゲーション",
"Settings navigation": "設定ナビゲーション",
"AI navigation": "AI ナビゲーション",
"Breadcrumb": "パンくずリスト",
"Synced block": "同期ブロック",
"Create a block that stays in sync across pages.": "ページ間で同期されたままになるブロックを作成します。",
"Editing original": "オリジナルを編集中",
"Copy synced block": "同期ブロックをコピー",
"Unsync": "同期解除",
"Delete synced block": "同期ブロックを削除",
"Synced to {{count}} other page_one": "他の{{count}}ページに同期済みです",
"Synced to {{count}} other page_other": "他の{{count}}ページに同期済みです",
"ORIGINAL": "オリジナル",
"THIS PAGE": "このページ",
"No pages": "ページがありません",
"The original synced block no longer exists": "元の同期ブロックは存在しなくなりました",
"You don't have access to this synced block": "この同期ブロックにアクセスできません",
"Failed to load this synced block": "この同期ブロックの読み込みに失敗しました",
"Fixed editor toolbar": "固定エディターツールバー",
"Show a formatting toolbar above the editor with quick access to common actions.": "一般的な操作にすばやくアクセスできる書式設定ツールバーをエディターの上に表示します。",
"Toggle fixed editor toolbar": "固定エディターツールバーを切り替え",
"Normal text": "通常のテキスト",
"More inline formatting": "その他のインライン書式",
"Subscript": "下付き",
"Superscript": "上付き",
"Inline code": "インラインコード",
"Insert media": "メディアを挿入",
"Mention": "メンション",
"Emoji": "絵文字",
"Columns": "列",
"More inserts": "その他の挿入",
"Embeds": "埋め込み",
"Diagrams": "ダイアグラム",
"Advanced": "詳細",
"Utility": "ユーティリティ",
"Decrease indent": "インデントを減らす",
"Increase indent": "インデントを増やす",
"Clear formatting": "書式をクリア",
"Code block": "コードブロック",
"Experimental": "実験的",
"Strikethrough": "取り消し線",
"Undo": "元に戻す",
"Redo": "やり直す",
"Backlinks": "バックリンク",
"Last updated by": "最終更新者",
"Last updated": "最終更新",
"Stats": "統計",
"Word count": "単語数",
"Characters": "文字数",
"Incoming links": "被リンク",
"Outgoing links": "発リンク",
"Incoming links ({{count}})": "被リンク ({{count}})",
"Outgoing links ({{count}})": "発リンク ({{count}})",
"No pages link here yet.": "まだこのページにリンクしているページはありません。",
"This page doesn't link to other pages yet.": "このページはまだ他のページにリンクしていません。",
"Verified until {{date}}": "{{date}} まで検証済み",
"Labels": "ラベル",
"Add label": "ラベルを追加",
"No labels yet": "まだラベルはありません",
"Already added": "追加済み",
"Invalid label name": "無効なラベル名",
"No matches": "一致するものがありません",
"Search or create…": "検索または作成…",
"Remove label {{name}}": "ラベル {{name}} を削除",
"Failed to add label": "ラベルの追加に失敗しました",
"Failed to remove label": "ラベルの削除に失敗しました",
"No pages with this label": "このラベルが付いたページはありません",
"Pages tagged with this label will appear here.": "このラベルが付いたページがここに表示されます。",
"No pages match your search.": "検索に一致するページはありません。",
"Updated {{date}}": "{{date}} に更新",
"Cell actions": "セルの操作",
"Column actions": "列の操作",
"Row actions": "行の操作",
"Filter": "フィルター",
"Page title": "ページタイトル",
"Page content": "ページ内容",
"Member actions": "メンバーの操作",
"Toggle password visibility": "パスワードの表示を切り替え",
"Send comment": "コメントを送信",
"Token actions": "トークンの操作",
"Template settings": "テンプレート設定",
"Edit diagram": "ダイアグラムを編集",
"Edit embed": "埋め込みを編集",
"Edit drawing": "図を編集",
"Delete equation": "数式を削除",
"Invite actions": "招待の操作",
"Get started": "始める",
"* indicates required fields": "* は必須項目を示します",
"List of spaces in this workspace": "このワークスペース内のスペース一覧",
"Active sessions": "アクティブなセッション",
"Add {{name}} to favorites": "{{name}} をお気に入りに追加",
"Remove {{name}} from favorites": "{{name}} をお気に入りから削除",
"Added to favorites": "お気に入りに追加しました",
"Removed from favorites": "お気に入りから削除しました",
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成"
"What can I help you with?": "何をお手伝いしましょうか?"
}
@@ -71,7 +71,6 @@
"Export": "내보내기",
"Failed to create page": "페이지 생성 실패",
"Failed to delete page": "페이지 삭제 실패",
"Failed to restore page": "페이지를 복원하지 못했습니다",
"Failed to fetch recent pages": "최근 페이지 불러오기 실패",
"Failed to import pages": "페이지 가져오기 실패",
"Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.",
@@ -277,9 +276,6 @@
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
"Alt text": "대체 텍스트",
"Describe this for accessibility.": "접근성을 위해 이를 설명하세요.",
"Add a description": "설명 추가",
"Justify": "양쪽 정렬",
"Merge cells": "셀 병합",
"Split cell": "셀 분할",
@@ -290,19 +286,6 @@
"Add row above": "위에 행 추가",
"Add row below": "아래에 행 추가",
"Delete table": "테이블 삭제",
"Add column left": "왼쪽에 열 추가",
"Add column right": "오른쪽에 열 추가",
"Clear cell": "셀 지우기",
"Clear cells": "셀 지우기",
"Toggle header cell": "헤더 셀 전환",
"Toggle header column": "헤더 열 전환",
"Toggle header row": "헤더 행 전환",
"Move column left": "열 왼쪽으로 이동",
"Move column right": "열 오른쪽으로 이동",
"Move row down": "행 아래로 이동",
"Move row up": "행 위로 이동",
"Sort A → Z": "A → Z 정렬",
"Sort Z → A": "Z → A 정렬",
"Info": "정보",
"Note": "참고",
"Success": "완료",
@@ -365,8 +348,6 @@
"Create block quote.": "인용구 만들기.",
"Insert code snippet.": "코드 블록 삽입.",
"Insert horizontal rule divider": "가로 구분선 삽입",
"Page break": "페이지 나누기",
"Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
@@ -411,10 +392,6 @@
"Write...": "작성...",
"Column count": "열 개수",
"{{count}} Columns": "{{count}}열",
"{{count}} command available_one": "사용 가능한 명령 1개",
"{{count}} command available_other": "사용 가능한 명령 {{count}}개",
"{{count}} result available_one": "사용 가능한 결과 1개",
"{{count}} result available_other": "사용 가능한 결과 {{count}}개",
"Equal columns": "열 너비 균등",
"Left sidebar": "왼쪽 사이드바",
"Right sidebar": "오른쪽 사이드바",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} 버전을 사용할 수 있습니다",
"Default page edit mode": "기본 페이지 편집 모드",
"Choose your preferred page edit mode. Avoid accidental edits.": "선호하는 페이지 편집 모드를 선택하세요. 실수로 인한 편집을 방지하세요.",
"Choose {{format}} file": "{{format}} 파일 선택",
"Reading": "읽기",
"Delete member": "멤버 삭제",
"Member deleted successfully": "멤버가 성공적으로 삭제되었습니다",
@@ -589,8 +565,6 @@
"Move to trash": "휴지통으로 이동",
"Move this page to trash?": "이 페이지를 휴지통으로 이동하시겠습니까?",
"Restore page": "페이지 복원",
"Permanently delete": "영구 삭제",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b>님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.",
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
"Page restored successfully": "페이지가 성공적으로 복원되었습니다",
"Deleted by": "삭제한 사람",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update": "업데이트",
"Update {{credential}}": "{{credential}} 업데이트",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"Restrict API key creation to admins": "API 키 생성 권한을 관리자에게만 제한합니다",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "새로운 API 키는 관리자와 소유자만 생성할 수 있습니다. 기존 멤버 키는 계속 사용할 수 있습니다.",
@@ -880,12 +858,9 @@
"AI Chat": "AI 채팅",
"Analyze for insights": "인사이트 분석",
"Ask anything...": "무엇이든 물어보세요...",
"Assistant said:": "어시스턴트의 답변:",
"Chat history": "채팅 기록",
"Chat name": "채팅 이름",
"Chat transcript": "채팅 기록",
"Close": "닫기",
"Copy assistant response": "어시스턴트 응답 복사",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "채팅을 불러오지 못했습니다. 오류가 발생했습니다.",
"Failed to render this message.": "이 메시지를 렌더링하지 못했습니다.",
@@ -895,17 +870,9 @@
"No chats found": "채팅을 찾을 수 없습니다",
"No conversations yet": "아직 대화가 없습니다",
"Open full page": "전체 페이지 열기",
"Scroll to bottom": "맨 아래로 스크롤",
"You said:": "내가 한 말:",
"Previous 7 days": "지난 7일",
"Previous 30 days": "지난 30일",
"Search chats...": "채팅 검색...",
"Search chats": "채팅 검색",
"Ask anything... Use @ to mention pages": "무엇이든 물어보세요... 페이지를 언급하려면 @를 사용하세요",
"Ask anything or search your workspace": "무엇이든 물어보거나 워크스페이스를 검색하세요",
"Welcome to {{name}}": "{{name}}에 오신 것을 환영합니다",
"Add files": "파일 추가",
"Mention a page": "페이지 멘션",
"Start a new chat to see it here.": "여기에 표시하려면 새 채팅을 시작하세요.",
"Summarize this page": "이 페이지 요약",
"Toggle AI Chat": "AI 채팅 전환",
@@ -913,176 +880,5 @@
"Try a different search term.": "다른 검색어를 사용해 보세요.",
"Try again": "다시 시도",
"Untitled chat": "제목 없는 채팅",
"What can I help you with?": "무엇을 도와드릴까요?",
"Are you sure you want to revoke this {{credential}}": "이 {{credential}}을 취소하시겠습니까?",
"Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자에서 사용자와 그룹을 자동으로 프로비저닝합니다.",
"Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.",
"Create {{credential}}": "{{credential}} 만들기",
"{{credential}} created": "{{credential}} 생성됨",
"{{credential}} created successfully": "{{credential}} 생성 완료",
"Created by": "생성한 사람",
"Custom": "사용자 지정",
"Enable SCIM": "SCIM 활성화",
"Enter a descriptive name": "설명적인 이름을 입력하세요",
"I've saved my {{credential}}": "내 {{credential}}를 저장했습니다",
"Important": "중요",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!",
"Never": "안 함",
"Revoke {{credential}}": "{{credential}} 취소",
"SCIM endpoint URL": "SCIM 엔드포인트 URL",
"SCIM provisioning": "SCIM 프로비저닝",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "SCIM 토큰은 최대 {{max}}개까지 만들 수 있습니다. 새 토큰을 만들려면 기존 토큰을 삭제하세요.",
"SCIM token": "SCIM 토큰",
"SCIM tokens": "SCIM 토큰",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "이 작업은 되돌릴 수 없습니다. ID 공급자가 즉시 동기화를 중지합니다.",
"Toggle SCIM provisioning": "SCIM 프로비저닝 전환",
"Token": "토큰",
"Page menu": "페이지 메뉴",
"Expand": "펼치기",
"Collapse": "접기",
"Comment menu": "댓글 메뉴",
"Group menu": "그룹 메뉴",
"Show hidden breadcrumbs": "숨겨진 이동 경로 표시",
"Breadcrumbs": "이동 경로",
"Page actions": "페이지 작업",
"Pick emoji": "이모지 선택",
"Template menu": "템플릿 메뉴",
"Use": "사용",
"Use template": "템플릿 사용",
"Preview template: {{title}}": "템플릿 미리보기: {{title}}",
"Use a template": "템플릿 사용",
"Search templates...": "템플릿 검색...",
"Search spaces...": "스페이스 검색...",
"No templates found": "템플릿을 찾을 수 없습니다",
"No spaces found": "스페이스를 찾을 수 없습니다",
"Browse all templates": "모든 템플릿 보기",
"This space": "이 스페이스",
"All templates": "모든 템플릿",
"Global": "전역",
"New template": "새 템플릿",
"Edit template": "템플릿 편집",
"Are you sure you want to delete this template?": "이 템플릿을 삭제하시겠습니까?",
"Template scope updated": "템플릿 범위가 업데이트되었습니다",
"Choose which space this template belongs to": "이 템플릿이 속할 스페이스를 선택하세요",
"Scope": "범위",
"Select scope": "범위 선택",
"Title": "제목",
"Saving...": "저장 중...",
"Saved": "저장됨",
"Save failed. Retry": "저장에 실패했습니다. 다시 시도하세요",
"By {{name}}": "작성자 {{name}}",
"Updated {{time}}": "{{time}}에 업데이트됨",
"Choose destination": "대상 선택",
"Search pages and spaces...": "페이지와 스페이스 검색...",
"No results found": "결과를 찾을 수 없습니다",
"You don't have permission to create pages here": "여기에서 페이지를 만들 권한이 없습니다",
"Chat menu": "채팅 메뉴",
"API key menu": "API 키 메뉴",
"Jump to comment selection": "댓글 선택으로 이동",
"Slash commands": "슬래시 명령어",
"Mention suggestions": "멘션 추천",
"Link suggestions": "링크 추천",
"Diagram editor": "다이어그램 편집기",
"Add comment": "댓글 추가",
"Find and replace": "찾기 및 바꾸기",
"Main navigation": "기본 탐색",
"Space navigation": "스페이스 탐색",
"Settings navigation": "설정 탐색",
"AI navigation": "AI 탐색",
"Breadcrumb": "이동 경로",
"Synced block": "동기화된 블록",
"Create a block that stays in sync across pages.": "페이지 간에 동기화된 상태로 유지되는 블록을 만드세요.",
"Editing original": "원본 편집 중",
"Copy synced block": "동기화된 블록 복사",
"Unsync": "동기화 해제",
"Delete synced block": "동기화된 블록 삭제",
"Synced to {{count}} other page_one": "다른 페이지 {{count}}개에 동기화됨",
"Synced to {{count}} other page_other": "다른 페이지 {{count}}개에 동기화됨",
"ORIGINAL": "원본",
"THIS PAGE": "이 페이지",
"No pages": "페이지 없음",
"The original synced block no longer exists": "원본 동기화 블록이 더 이상 존재하지 않습니다",
"You don't have access to this synced block": "이 동기화된 블록에 접근할 수 없습니다",
"Failed to load this synced block": "이 동기화된 블록을 불러오지 못했습니다",
"Fixed editor toolbar": "고정된 편집기 도구 모음",
"Show a formatting toolbar above the editor with quick access to common actions.": "일반적인 작업에 빠르게 접근할 수 있도록 편집기 위에 서식 도구 모음을 표시합니다.",
"Toggle fixed editor toolbar": "고정된 편집기 도구 모음 전환",
"Normal text": "일반 텍스트",
"More inline formatting": "추가 인라인 서식",
"Subscript": "아래 첨자",
"Superscript": "위 첨자",
"Inline code": "인라인 코드",
"Insert media": "미디어 삽입",
"Mention": "멘션",
"Emoji": "이모지",
"Columns": "열",
"More inserts": "더 많은 삽입",
"Embeds": "임베드",
"Diagrams": "다이어그램",
"Advanced": "고급",
"Utility": "유틸리티",
"Decrease indent": "들여쓰기 줄이기",
"Increase indent": "들여쓰기 늘리기",
"Clear formatting": "서식 지우기",
"Code block": "코드 블록",
"Experimental": "실험 기능",
"Strikethrough": "취소선",
"Undo": "실행 취소",
"Redo": "다시 실행",
"Backlinks": "백링크",
"Last updated by": "마지막 업데이트한 사람",
"Last updated": "마지막 업데이트",
"Stats": "통계",
"Word count": "단어 수",
"Characters": "문자 수",
"Incoming links": "들어오는 링크",
"Outgoing links": "나가는 링크",
"Incoming links ({{count}})": "들어오는 링크 ({{count}})",
"Outgoing links ({{count}})": "나가는 링크 ({{count}})",
"No pages link here yet.": "아직 여기에 링크된 페이지가 없습니다.",
"This page doesn't link to other pages yet.": "이 페이지는 아직 다른 페이지에 링크되어 있지 않습니다.",
"Verified until {{date}}": "{{date}}까지 검증됨",
"Labels": "라벨",
"Add label": "라벨 추가",
"No labels yet": "아직 라벨이 없습니다",
"Already added": "이미 추가됨",
"Invalid label name": "유효하지 않은 라벨 이름",
"No matches": "일치하는 항목 없음",
"Search or create…": "검색하거나 만들기…",
"Remove label {{name}}": "라벨 {{name}} 제거",
"Failed to add label": "라벨 추가 실패",
"Failed to remove label": "라벨 제거 실패",
"No pages with this label": "이 라벨이 지정된 페이지가 없습니다",
"Pages tagged with this label will appear here.": "이 라벨이 지정된 페이지가 여기에 표시됩니다.",
"No pages match your search.": "검색과 일치하는 페이지가 없습니다.",
"Updated {{date}}": "{{date}}에 업데이트됨",
"Cell actions": "셀 작업",
"Column actions": "열 작업",
"Row actions": "행 작업",
"Filter": "필터",
"Page title": "페이지 제목",
"Page content": "페이지 내용",
"Member actions": "멤버 작업",
"Toggle password visibility": "비밀번호 표시 전환",
"Send comment": "댓글 보내기",
"Token actions": "토큰 작업",
"Template settings": "템플릿 설정",
"Edit diagram": "다이어그램 편집",
"Edit embed": "임베드 편집",
"Edit drawing": "드로잉 편집",
"Delete equation": "수식 삭제",
"Invite actions": "초대 작업",
"Get started": "시작하기",
"* indicates required fields": "* 는 필수 입력 항목을 나타냅니다",
"List of spaces in this workspace": "이 워크스페이스의 스페이스 목록",
"Active sessions": "활성 세션",
"Add {{name}} to favorites": "{{name}} 즐겨찾기에 추가",
"Remove {{name}} from favorites": "{{name}} 즐겨찾기에서 제거",
"Added to favorites": "즐겨찾기에 추가됨",
"Removed from favorites": "즐겨찾기에서 제거됨",
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
"What can I help you with?": "무엇을 도와드릴까요?"
}
@@ -71,7 +71,6 @@
"Export": "Exporteer",
"Failed to create page": "Pagina aanmaken mislukt",
"Failed to delete page": "Verwijderen van pagina mislukt",
"Failed to restore page": "Pagina herstellen mislukt",
"Failed to fetch recent pages": "Kan recente pagina's niet ophalen",
"Failed to import pages": "Pagina's importeren mislukt",
"Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.",
@@ -277,9 +276,6 @@
"Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen",
"Align center": "Centreren",
"Alt text": "Alternatieve tekst",
"Describe this for accessibility.": "Beschrijf dit voor toegankelijkheid.",
"Add a description": "Een beschrijving toevoegen",
"Justify": "Uitvullen",
"Merge cells": "Cellen samenvoegen",
"Split cell": "Cel splitsen",
@@ -290,19 +286,6 @@
"Add row above": "Rij hierboven toevoegen",
"Add row below": "Rij hieronder toevoegen",
"Delete table": "Verwijder tabel",
"Add column left": "Kolom links toevoegen",
"Add column right": "Kolom rechts toevoegen",
"Clear cell": "Cel wissen",
"Clear cells": "Cellen wissen",
"Toggle header cell": "Kopcel in-/uitschakelen",
"Toggle header column": "Kopkolom in-/uitschakelen",
"Toggle header row": "Koprij in-/uitschakelen",
"Move column left": "Kolom naar links verplaatsen",
"Move column right": "Kolom naar rechts verplaatsen",
"Move row down": "Rij omlaag verplaatsen",
"Move row up": "Rij omhoog verplaatsen",
"Sort A → Z": "Sorteren A → Z",
"Sort Z → A": "Sorteren Z → A",
"Info": "Info",
"Note": "Opmerking",
"Success": "Geslaagd",
@@ -365,8 +348,6 @@
"Create block quote.": "Maak een block quote.",
"Insert code snippet.": "Codefragment invoegen.",
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Page break": "Pagina-einde",
"Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
@@ -411,10 +392,6 @@
"Write...": "Typ...",
"Column count": "Aantal kolommen",
"{{count}} Columns": "{{count}} kolommen",
"{{count}} command available_one": "1 opdracht beschikbaar",
"{{count}} command available_other": "{{count}} opdrachten beschikbaar",
"{{count}} result available_one": "1 resultaat beschikbaar",
"{{count}} result available_other": "{{count}} resultaten beschikbaar",
"Equal columns": "Gelijke kolommen",
"Left sidebar": "Linker zijbalk",
"Right sidebar": "Rechter zijbalk",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
"Default page edit mode": "Standaard bewerkingsmodus voor pagina",
"Choose your preferred page edit mode. Avoid accidental edits.": "Kies uw voorkeurs bewerkmodus voor pagina's. Vermijd per ongeluk bewerken.",
"Choose {{format}} file": "Kies {{format}}-bestand",
"Reading": "Lezen",
"Delete member": "Lid verwijderen",
"Member deleted successfully": "Lid succesvol verwijderd",
@@ -589,8 +565,6 @@
"Move to trash": "Verplaatsen naar prullenbak",
"Move this page to trash?": "Deze pagina naar de prullenbak verplaatsen?",
"Restore page": "Pagina herstellen",
"Permanently delete": "Permanent verwijderen",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> heeft deze pagina {{time}} naar de prullenbak verplaatst.",
"Page moved to trash": "Pagina verplaatst naar prullenbak",
"Page restored successfully": "Pagina succesvol hersteld",
"Deleted by": "Verwijderd door",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update": "Bijwerken",
"Update {{credential}}": "{{credential}} bijwerken",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"Restrict API key creation to admins": "Beperk het aanmaken van API-sleutels tot beheerders.",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Alleen beheerders en eigenaren kunnen nieuwe API-sleutels aanmaken. Bestaande leden-sleutels blijven werken.",
@@ -880,12 +858,9 @@
"AI Chat": "AI-chat",
"Analyze for insights": "Analyseren voor inzichten",
"Ask anything...": "Vraag iets...",
"Assistant said:": "Assistent zei:",
"Chat history": "Chatgeschiedenis",
"Chat name": "Chatnaam",
"Chat transcript": "Chattranscript",
"Close": "Sluiten",
"Copy assistant response": "Reactie van assistent kopiëren",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Chat laden mislukt. Er is een fout opgetreden.",
"Failed to render this message.": "Dit bericht kon niet worden weergegeven.",
@@ -895,17 +870,9 @@
"No chats found": "Geen chats gevonden",
"No conversations yet": "Nog geen gesprekken",
"Open full page": "Volledige pagina openen",
"Scroll to bottom": "Naar beneden scrollen",
"You said:": "Jij zei:",
"Previous 7 days": "Afgelopen 7 dagen",
"Previous 30 days": "Afgelopen 30 dagen",
"Search chats...": "Chats zoeken...",
"Search chats": "Chats zoeken",
"Ask anything... Use @ to mention pages": "Vraag iets... Gebruik @ om pagina's te vermelden",
"Ask anything or search your workspace": "Vraag iets of doorzoek je werkruimte",
"Welcome to {{name}}": "Welkom bij {{name}}",
"Add files": "Bestanden toevoegen",
"Mention a page": "Een pagina vermelden",
"Start a new chat to see it here.": "Start een nieuwe chat om die hier te zien.",
"Summarize this page": "Vat deze pagina samen",
"Toggle AI Chat": "AI-chat in-/uitschakelen",
@@ -913,176 +880,5 @@
"Try a different search term.": "Probeer een andere zoekterm.",
"Try again": "Probeer opnieuw",
"Untitled chat": "Chat zonder titel",
"What can I help you with?": "Waar kan ik je mee helpen?",
"Are you sure you want to revoke this {{credential}}": "Weet u zeker dat u deze {{credential}} wilt intrekken",
"Automatically provision users and groups from your identity provider via SCIM.": "Voorzie gebruikers en groepen automatisch vanuit uw identiteitsprovider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configureer uw identiteitsprovider met deze URL om gebruikers en groepen te provisioneren.",
"Create {{credential}}": "{{credential}} maken",
"{{credential}} created": "{{credential}} aangemaakt",
"{{credential}} created successfully": "{{credential}} succesvol aangemaakt",
"Created by": "Aangemaakt door",
"Custom": "Aangepast",
"Enable SCIM": "SCIM inschakelen",
"Enter a descriptive name": "Voer een beschrijvende naam in",
"I've saved my {{credential}}": "Ik heb mijn {{credential}} opgeslagen",
"Important": "Belangrijk",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Zorg ervoor dat u uw {{credential}} nu kopieert. U kunt deze niet meer bekijken!",
"Never": "Nooit",
"Revoke {{credential}}": "{{credential}} intrekken",
"SCIM endpoint URL": "SCIM-eindpunt-URL",
"SCIM provisioning": "SCIM-provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM heeft voorrang op SSO-groepssynchronisatie zolang het is ingeschakeld.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "U heeft het maximum van {{max}} SCIM-tokens bereikt. Verwijder een bestaand token om een nieuw token aan te maken.",
"SCIM token": "SCIM-token",
"SCIM tokens": "SCIM-tokens",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Deze actie kan niet ongedaan worden gemaakt. Uw identiteitsprovider stopt onmiddellijk met synchroniseren.",
"Toggle SCIM provisioning": "SCIM-provisioning in-/uitschakelen",
"Token": "Token",
"Page menu": "Paginamenu",
"Expand": "Uitvouwen",
"Collapse": "Samenvouwen",
"Comment menu": "Reactiemenu",
"Group menu": "Groepsmenu",
"Show hidden breadcrumbs": "Verborgen broodkruimels weergeven",
"Breadcrumbs": "Broodkruimels",
"Page actions": "Pagina-acties",
"Pick emoji": "Emoji kiezen",
"Template menu": "Sjabloonmenu",
"Use": "Gebruiken",
"Use template": "Sjabloon gebruiken",
"Preview template: {{title}}": "Voorbeeld van sjabloon: {{title}}",
"Use a template": "Een sjabloon gebruiken",
"Search templates...": "Sjablonen zoeken...",
"Search spaces...": "Werkruimtes zoeken...",
"No templates found": "Geen sjablonen gevonden",
"No spaces found": "Geen werkruimtes gevonden",
"Browse all templates": "Alle sjablonen bekijken",
"This space": "Deze werkruimte",
"All templates": "Alle sjablonen",
"Global": "Globaal",
"New template": "Nieuw sjabloon",
"Edit template": "Sjabloon bewerken",
"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",
"Scope": "Bereik",
"Select scope": "Bereik selecteren",
"Title": "Titel",
"Saving...": "Opslaan...",
"Saved": "Opgeslagen",
"Save failed. Retry": "Opslaan mislukt. Opnieuw proberen",
"By {{name}}": "Door {{name}}",
"Updated {{time}}": "Bijgewerkt {{time}}",
"Choose destination": "Bestemming kiezen",
"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",
"API key menu": "API-sleutelmenu",
"Jump to comment selection": "Naar reactieselectie springen",
"Slash commands": "Slash-opdrachten",
"Mention suggestions": "Vermeldingssuggesties",
"Link suggestions": "Linksuggesties",
"Diagram editor": "Diagrameditor",
"Add comment": "Reactie toevoegen",
"Find and replace": "Zoeken en vervangen",
"Main navigation": "Hoofdnavigatie",
"Space navigation": "Ruimtenavigatie",
"Settings navigation": "Instellingennavigatie",
"AI navigation": "AI-navigatie",
"Breadcrumb": "Broodkruimel",
"Synced block": "Gesynchroniseerd blok",
"Create a block that stays in sync across pages.": "Maak een blok dat gesynchroniseerd blijft op meerdere pagina's.",
"Editing original": "Origineel bewerken",
"Copy synced block": "Gesynchroniseerd blok kopiëren",
"Unsync": "Synchronisatie opheffen",
"Delete synced block": "Gesynchroniseerd blok verwijderen",
"Synced to {{count}} other page_one": "Gesynchroniseerd met {{count}} andere pagina",
"Synced to {{count}} other page_other": "Gesynchroniseerd met {{count}} andere pagina's",
"ORIGINAL": "ORIGINEEL",
"THIS PAGE": "DEZE PAGINA",
"No pages": "Geen pagina's",
"The original synced block no longer exists": "Het oorspronkelijke gesynchroniseerde blok bestaat niet meer",
"You don't have access to this synced block": "Je hebt geen toegang tot dit gesynchroniseerde blok",
"Failed to load this synced block": "Dit gesynchroniseerde blok kon niet worden geladen",
"Fixed editor toolbar": "Vaste editorwerkbalk",
"Show a formatting toolbar above the editor with quick access to common actions.": "Toon een opmaakwerkbalk boven de editor met snelle toegang tot veelgebruikte acties.",
"Toggle fixed editor toolbar": "Vaste editorwerkbalk in-/uitschakelen",
"Normal text": "Normale tekst",
"More inline formatting": "Meer inline-opmaak",
"Subscript": "Subscript",
"Superscript": "Superscript",
"Inline code": "Inline-code",
"Insert media": "Media invoegen",
"Mention": "Vermelding",
"Emoji": "Emoji",
"Columns": "Kolommen",
"More inserts": "Meer invoegingen",
"Embeds": "Insluitingen",
"Diagrams": "Diagrammen",
"Advanced": "Geavanceerd",
"Utility": "Hulpprogramma's",
"Decrease indent": "Inspringing verkleinen",
"Increase indent": "Inspringing vergroten",
"Clear formatting": "Opmaak wissen",
"Code block": "Codeblok",
"Experimental": "Experimenteel",
"Strikethrough": "Doorhalen",
"Undo": "Ongedaan maken",
"Redo": "Opnieuw",
"Backlinks": "Terugkoppelingen",
"Last updated by": "Laatst bijgewerkt door",
"Last updated": "Laatst bijgewerkt",
"Stats": "Statistieken",
"Word count": "Aantal woorden",
"Characters": "Tekens",
"Incoming links": "Inkomende links",
"Outgoing links": "Uitgaande links",
"Incoming links ({{count}})": "Inkomende links ({{count}})",
"Outgoing links ({{count}})": "Uitgaande links ({{count}})",
"No pages link here yet.": "Er linken nog geen pagina's hiernaartoe.",
"This page doesn't link to other pages yet.": "Deze pagina linkt nog niet naar andere pagina's.",
"Verified until {{date}}": "Geverifieerd tot {{date}}",
"Labels": "Labels",
"Add label": "Label toevoegen",
"No labels yet": "Nog geen labels",
"Already added": "Al toegevoegd",
"Invalid label name": "Ongeldige labelnaam",
"No matches": "Geen overeenkomsten",
"Search or create…": "Zoeken of maken…",
"Remove label {{name}}": "Label {{name}} verwijderen",
"Failed to add label": "Label toevoegen mislukt",
"Failed to remove label": "Label verwijderen mislukt",
"No pages with this label": "Geen pagina's met dit label",
"Pages tagged with this label will appear here.": "Pagina's met dit label worden hier weergegeven.",
"No pages match your search.": "Geen pagina's komen overeen met je zoekopdracht.",
"Updated {{date}}": "Bijgewerkt op {{date}}",
"Cell actions": "Celacties",
"Column actions": "Kolomacties",
"Row actions": "Rijacties",
"Filter": "Filter",
"Page title": "Paginatitel",
"Page content": "Pagina-inhoud",
"Member actions": "Lidacties",
"Toggle password visibility": "Wachtwoordzichtbaarheid in-/uitschakelen",
"Send comment": "Reactie verzenden",
"Token actions": "Tokenacties",
"Template settings": "Sjablooninstellingen",
"Edit diagram": "Diagram bewerken",
"Edit embed": "Insluiting bewerken",
"Edit drawing": "Tekening bewerken",
"Delete equation": "Vergelijking verwijderen",
"Invite actions": "Uitnodigingsacties",
"Get started": "Aan de slag",
"* indicates required fields": "* geeft verplichte velden aan",
"List of spaces in this workspace": "Lijst met werkruimtes in deze workspace",
"Active sessions": "Actieve sessies",
"Add {{name}} to favorites": "{{name}} aan favorieten toevoegen",
"Remove {{name}} from favorites": "{{name}} uit favorieten verwijderen",
"Added to favorites": "Toegevoegd aan favorieten",
"Removed from favorites": "Verwijderd uit favorieten",
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
"What can I help you with?": "Waar kan ik je mee helpen?"
}
@@ -71,7 +71,6 @@
"Export": "Exportar",
"Failed to create page": "Falha ao criar página",
"Failed to delete page": "Falha ao excluir página",
"Failed to restore page": "Falha ao restaurar página",
"Failed to fetch recent pages": "Falha ao buscar páginas recentes",
"Failed to import pages": "Falha ao importar páginas",
"Failed to load page. An error occurred.": "Falha ao carregar página. Ocorreu um erro.",
@@ -277,9 +276,6 @@
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
"Alt text": "Texto alternativo",
"Describe this for accessibility.": "Descreva isto para acessibilidade.",
"Add a description": "Adicionar uma descrição",
"Justify": "Justificar",
"Merge cells": "Mesclar células",
"Split cell": "Dividir célula",
@@ -290,19 +286,6 @@
"Add row above": "Adicionar linha acima",
"Add row below": "Adicionar linha abaixo",
"Delete table": "Excluir tabela",
"Add column left": "Adicionar coluna à esquerda",
"Add column right": "Adicionar coluna à direita",
"Clear cell": "Limpar célula",
"Clear cells": "Limpar células",
"Toggle header cell": "Alternar célula de cabeçalho",
"Toggle header column": "Alternar coluna de cabeçalho",
"Toggle header row": "Alternar linha de cabeçalho",
"Move column left": "Mover coluna para a esquerda",
"Move column right": "Mover coluna para a direita",
"Move row down": "Mover linha para baixo",
"Move row up": "Mover linha para cima",
"Sort A → Z": "Ordenar A → Z",
"Sort Z → A": "Ordenar Z → A",
"Info": "Informação",
"Note": "Observação",
"Success": "Sucesso",
@@ -365,8 +348,6 @@
"Create block quote.": "Crie uma citação em bloco.",
"Insert code snippet.": "Insira um trecho de código.",
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Page break": "Quebra de página",
"Insert a page break for printing.": "Insira uma quebra de página para impressão.",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
@@ -411,10 +392,6 @@
"Write...": "Escreva...",
"Column count": "Número de colunas",
"{{count}} Columns": "{{count}} colunas",
"{{count}} command available_one": "1 comando disponível",
"{{count}} command available_other": "{{count}} comandos disponíveis",
"{{count}} result available_one": "1 resultado disponível",
"{{count}} result available_other": "{{count}} resultados disponíveis",
"Equal columns": "Colunas iguais",
"Left sidebar": "Barra lateral esquerda",
"Right sidebar": "Barra lateral direita",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
"Default page edit mode": "Modo padrão de edição da página",
"Choose your preferred page edit mode. Avoid accidental edits.": "Escolha o modo de edição de página preferido. Evite edições acidentais.",
"Choose {{format}} file": "Escolher arquivo {{format}}",
"Reading": "Leitura",
"Delete member": "Excluir membro",
"Member deleted successfully": "Membro excluído com sucesso",
@@ -589,8 +565,6 @@
"Move to trash": "Mover para a lixeira",
"Move this page to trash?": "Mover esta página para a lixeira?",
"Restore page": "Restaurar página",
"Permanently delete": "Excluir permanentemente",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moveu esta página para a Lixeira {{time}}.",
"Page moved to trash": "Página movida para a lixeira",
"Page restored successfully": "Página restaurada com sucesso",
"Deleted by": "Excluído por",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update": "Atualizar",
"Update {{credential}}": "Atualizar {{credential}}",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"Restrict API key creation to admins": "Restringir a criação de chave de API aos administradores",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Somente administradores e proprietários podem criar novas chaves de API. As chaves de membros já existentes continuarão funcionando.",
@@ -880,12 +858,9 @@
"AI Chat": "Chat com IA",
"Analyze for insights": "Analisar para obter insights",
"Ask anything...": "Pergunte qualquer coisa...",
"Assistant said:": "O assistente disse:",
"Chat history": "Histórico de chats",
"Chat name": "Nome do chat",
"Chat transcript": "Transcrição do chat",
"Close": "Fechar",
"Copy assistant response": "Copiar resposta do assistente",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Falha ao carregar o chat. Ocorreu um erro.",
"Failed to render this message.": "Falha ao renderizar esta mensagem.",
@@ -895,17 +870,9 @@
"No chats found": "Nenhum chat encontrado",
"No conversations yet": "Ainda não há conversas",
"Open full page": "Abrir página inteira",
"Scroll to bottom": "Rolar até o fim",
"You said:": "Você disse:",
"Previous 7 days": "Últimos 7 dias",
"Previous 30 days": "Últimos 30 dias",
"Search chats...": "Pesquisar chats...",
"Search chats": "Pesquisar chats",
"Ask anything... Use @ to mention pages": "Pergunte qualquer coisa... Use @ para mencionar páginas",
"Ask anything or search your workspace": "Pergunte qualquer coisa ou pesquise no seu workspace",
"Welcome to {{name}}": "Boas-vindas a {{name}}",
"Add files": "Adicionar arquivos",
"Mention a page": "Mencionar uma página",
"Start a new chat to see it here.": "Inicie um novo chat para vê-lo aqui.",
"Summarize this page": "Resumir esta página",
"Toggle AI Chat": "Alternar chat com IA",
@@ -913,176 +880,5 @@
"Try a different search term.": "Tente um termo de pesquisa diferente.",
"Try again": "Tentar novamente",
"Untitled chat": "Chat sem título",
"What can I help you with?": "Com o que posso ajudar você?",
"Are you sure you want to revoke this {{credential}}": "Tem certeza de que deseja revogar esta {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Provisione automaticamente usuários e grupos do seu provedor de identidade via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure seu provedor de identidade com esta URL para provisionar usuários e grupos.",
"Create {{credential}}": "Criar {{credential}}",
"{{credential}} created": "{{credential}} criada",
"{{credential}} created successfully": "{{credential}} criada com sucesso",
"Created by": "Criado por",
"Custom": "Personalizado",
"Enable SCIM": "Ativar SCIM",
"Enter a descriptive name": "Insira um nome descritivo",
"I've saved my {{credential}}": "Salvei minha {{credential}}",
"Important": "Importante",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Copie sua {{credential}} agora. Você não poderá vê-la novamente!",
"Never": "Nunca",
"Revoke {{credential}}": "Revogar {{credential}}",
"SCIM endpoint URL": "URL do endpoint SCIM",
"SCIM provisioning": "Provisionamento SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "O SCIM tem precedência sobre a sincronização de grupos por SSO enquanto estiver ativado.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Você atingiu o máximo de {{max}} tokens SCIM. Exclua um token existente para criar um novo.",
"SCIM token": "Token SCIM",
"SCIM tokens": "Tokens SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Esta ação não pode ser desfeita. Seu provedor de identidade deixará de sincronizar imediatamente.",
"Toggle SCIM provisioning": "Alternar provisionamento SCIM",
"Token": "Token",
"Page menu": "Menu da página",
"Expand": "Expandir",
"Collapse": "Recolher",
"Comment menu": "Menu de comentários",
"Group menu": "Menu do grupo",
"Show hidden breadcrumbs": "Mostrar breadcrumbs ocultos",
"Breadcrumbs": "Trilhas de navegação",
"Page actions": "Ações da página",
"Pick emoji": "Escolher emoji",
"Template menu": "Menu do modelo",
"Use": "Usar",
"Use template": "Usar modelo",
"Preview template: {{title}}": "Visualizar modelo: {{title}}",
"Use a template": "Usar um modelo",
"Search templates...": "Pesquisar modelos...",
"Search spaces...": "Pesquisar espaços...",
"No templates found": "Nenhum modelo encontrado",
"No spaces found": "Nenhum espaço encontrado",
"Browse all templates": "Ver todos os modelos",
"This space": "Este espaço",
"All templates": "Todos os modelos",
"Global": "Global",
"New template": "Novo modelo",
"Edit template": "Editar modelo",
"Are you sure you want to delete this template?": "Tem certeza de que deseja excluir este modelo?",
"Template scope updated": "Escopo do modelo atualizado",
"Choose which space this template belongs to": "Escolha a qual espaço este modelo pertence",
"Scope": "Escopo",
"Select scope": "Selecionar escopo",
"Title": "Título",
"Saving...": "Salvando...",
"Saved": "Salvo",
"Save failed. Retry": "Falha ao salvar. Tentar novamente",
"By {{name}}": "Por {{name}}",
"Updated {{time}}": "Atualizado {{time}}",
"Choose destination": "Escolher destino",
"Search pages and spaces...": "Pesquisar páginas e espaços...",
"No results found": "Nenhum resultado encontrado",
"You don't have permission to create pages here": "Você não tem permissão para criar páginas aqui",
"Chat menu": "Menu do chat",
"API key menu": "Menu da chave de API",
"Jump to comment selection": "Ir para a seleção de comentários",
"Slash commands": "Comandos de barra",
"Mention suggestions": "Sugestões de menção",
"Link suggestions": "Sugestões de links",
"Diagram editor": "Editor de diagramas",
"Add comment": "Adicionar comentário",
"Find and replace": "Localizar e substituir",
"Main navigation": "Navegação principal",
"Space navigation": "Navegação do espaço",
"Settings navigation": "Navegação de configurações",
"AI navigation": "Navegação de IA",
"Breadcrumb": "Trilha de navegação",
"Synced block": "Bloco sincronizado",
"Create a block that stays in sync across pages.": "Crie um bloco que permaneça sincronizado entre páginas.",
"Editing original": "Editando original",
"Copy synced block": "Copiar bloco sincronizado",
"Unsync": "Desfazer sincronização",
"Delete synced block": "Excluir bloco sincronizado",
"Synced to {{count}} other page_one": "Sincronizado com {{count}} outra página",
"Synced to {{count}} other page_other": "Sincronizado com {{count}} outras páginas",
"ORIGINAL": "ORIGINAL",
"THIS PAGE": "ESTA PÁGINA",
"No pages": "Nenhuma página",
"The original synced block no longer exists": "O bloco sincronizado original não existe mais",
"You don't have access to this synced block": "Você não tem acesso a este bloco sincronizado",
"Failed to load this synced block": "Falha ao carregar este bloco sincronizado",
"Fixed editor toolbar": "Barra de ferramentas fixa do editor",
"Show a formatting toolbar above the editor with quick access to common actions.": "Mostre uma barra de ferramentas de formatação acima do editor com acesso rápido a ações comuns.",
"Toggle fixed editor toolbar": "Alternar barra de ferramentas fixa do editor",
"Normal text": "Texto normal",
"More inline formatting": "Mais formatação em linha",
"Subscript": "Subscrito",
"Superscript": "Sobrescrito",
"Inline code": "Código em linha",
"Insert media": "Inserir mídia",
"Mention": "Menção",
"Emoji": "Emoji",
"Columns": "Colunas",
"More inserts": "Mais inserções",
"Embeds": "Incorporações",
"Diagrams": "Diagramas",
"Advanced": "Avançado",
"Utility": "Utilitário",
"Decrease indent": "Diminuir recuo",
"Increase indent": "Aumentar recuo",
"Clear formatting": "Limpar formatação",
"Code block": "Bloco de código",
"Experimental": "Experimental",
"Strikethrough": "Tachado",
"Undo": "Desfazer",
"Redo": "Refazer",
"Backlinks": "Links de retorno",
"Last updated by": "Última atualização por",
"Last updated": "Última atualização",
"Stats": "Estatísticas",
"Word count": "Contagem de palavras",
"Characters": "Caracteres",
"Incoming links": "Links recebidos",
"Outgoing links": "Links de saída",
"Incoming links ({{count}})": "Links recebidos ({{count}})",
"Outgoing links ({{count}})": "Links de saída ({{count}})",
"No pages link here yet.": "Nenhuma página tem link para cá ainda.",
"This page doesn't link to other pages yet.": "Esta página ainda não tem links para outras páginas.",
"Verified until {{date}}": "Verificado até {{date}}",
"Labels": "Rótulos",
"Add label": "Adicionar rótulo",
"No labels yet": "Ainda não há rótulos",
"Already added": "Já adicionado",
"Invalid label name": "Nome de rótulo inválido",
"No matches": "Sem correspondências",
"Search or create…": "Pesquisar ou criar…",
"Remove label {{name}}": "Remover rótulo {{name}}",
"Failed to add label": "Falha ao adicionar rótulo",
"Failed to remove label": "Falha ao remover rótulo",
"No pages with this label": "Nenhuma página com este rótulo",
"Pages tagged with this label will appear here.": "As páginas marcadas com este rótulo aparecerão aqui.",
"No pages match your search.": "Nenhuma página corresponde à sua pesquisa.",
"Updated {{date}}": "Atualizado em {{date}}",
"Cell actions": "Ações da célula",
"Column actions": "Ações da coluna",
"Row actions": "Ações da linha",
"Filter": "Filtrar",
"Page title": "Título da página",
"Page content": "Conteúdo da página",
"Member actions": "Ações do membro",
"Toggle password visibility": "Alternar visibilidade da senha",
"Send comment": "Enviar comentário",
"Token actions": "Ações do token",
"Template settings": "Configurações do modelo",
"Edit diagram": "Editar diagrama",
"Edit embed": "Editar incorporação",
"Edit drawing": "Editar desenho",
"Delete equation": "Excluir equação",
"Invite actions": "Ações do convite",
"Get started": "Começar",
"* indicates required fields": "* indica campos obrigatórios",
"List of spaces in this workspace": "Lista de espaços neste workspace",
"Active sessions": "Sessões ativas",
"Add {{name}} to favorites": "Adicionar {{name}} aos favoritos",
"Remove {{name}} from favorites": "Remover {{name}} dos favoritos",
"Added to favorites": "Adicionado aos favoritos",
"Removed from favorites": "Removido dos favoritos",
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
"Page menu for {{name}}": "Menu da página de {{name}}",
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
"What can I help you with?": "Com o que posso ajudar você?"
}
@@ -71,7 +71,6 @@
"Export": "Экспорт",
"Failed to create page": "Не удалось создать страницу",
"Failed to delete page": "Не удалось удалить страницу",
"Failed to restore page": "Не удалось восстановить страницу",
"Failed to fetch recent pages": "Не удалось получить недавние страницы",
"Failed to import pages": "Не удалось импортировать страницы",
"Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.",
@@ -277,9 +276,6 @@
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Alt text": "Альтернативный текст",
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
"Add a description": "Добавить описание",
"Justify": "По ширине",
"Merge cells": "Объединить ячейки",
"Split cell": "Разделить ячейку",
@@ -290,19 +286,6 @@
"Add row above": "Добавить строку выше",
"Add row below": "Добавить строку ниже",
"Delete table": "Удалить таблицу",
"Add column left": "Добавить столбец слева",
"Add column right": "Добавить столбец справа",
"Clear cell": "Очистить ячейку",
"Clear cells": "Очистить ячейки",
"Toggle header cell": "Переключить ячейку заголовка",
"Toggle header column": "Переключить столбец заголовка",
"Toggle header row": "Переключить строку заголовка",
"Move column left": "Переместить столбец влево",
"Move column right": "Переместить столбец вправо",
"Move row down": "Переместить строку вниз",
"Move row up": "Переместить строку вверх",
"Sort A → Z": "Сортировать A → Я",
"Sort Z → A": "Сортировать Я → A",
"Info": "Информация",
"Note": "Примечание",
"Success": "Успешно",
@@ -365,8 +348,6 @@
"Create block quote.": "Создать блок цитирования.",
"Insert code snippet.": "Вставить фрагмент кода.",
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Page break": "Разрыв страницы",
"Insert a page break for printing.": "Вставить разрыв страницы для печати.",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
@@ -411,10 +392,6 @@
"Write...": "Напишите...",
"Column count": "Количество столбцов",
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
"{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд",
"{{count}} result available_one": "Доступен 1 результат",
"{{count}} result available_other": "Доступно {{count}} результатов",
"Equal columns": "Равные столбцы",
"Left sidebar": "Левая боковая панель",
"Right sidebar": "Правая боковая панель",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "Доступна версия {{latestVersion}}",
"Default page edit mode": "Режим редактирования страницы по умолчанию",
"Choose your preferred page edit mode. Avoid accidental edits.": "Выберите предпочитаемый режим редактирования страницы. Избегайте случайных изменений.",
"Choose {{format}} file": "Выберите файл {{format}}",
"Reading": "Чтение",
"Delete member": "Удалить участника",
"Member deleted successfully": "Участник успешно удалён",
@@ -589,8 +565,6 @@
"Move to trash": "Переместить в корзину",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Permanently delete": "Удалить навсегда",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> переместил(а) эту страницу в корзину {{time}}.",
"Page moved to trash": "Страница перемещена в корзину",
"Page restored successfully": "Страница успешно восстановлена",
"Deleted by": "Удалено пользователем",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update": "Обновить",
"Update {{credential}}": "Обновить {{credential}}",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"Restrict API key creation to admins": "Ограничить создание API-ключей только администраторами.",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Только администраторы и владельцы могут создавать новые API-ключи. Существующие ключи участников продолжат работать.",
@@ -880,12 +858,9 @@
"AI Chat": "Чат с ИИ",
"Analyze for insights": "Проанализировать и получить выводы",
"Ask anything...": "Спросите что угодно...",
"Assistant said:": "Ассистент ответил:",
"Chat history": "История чатов",
"Chat name": "Название чата",
"Chat transcript": "Расшифровка чата",
"Close": "Закрыть",
"Copy assistant response": "Скопировать ответ ассистента",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Не удалось загрузить чат. Произошла ошибка.",
"Failed to render this message.": "Не удалось отобразить это сообщение.",
@@ -895,17 +870,9 @@
"No chats found": "Чаты не найдены",
"No conversations yet": "Пока нет разговоров",
"Open full page": "Открыть полную страницу",
"Scroll to bottom": "Прокрутить вниз",
"You said:": "Вы сказали:",
"Previous 7 days": "Предыдущие 7 дней",
"Previous 30 days": "Предыдущие 30 дней",
"Search chats...": "Поиск чатов...",
"Search chats": "Поиск чатов",
"Ask anything... Use @ to mention pages": "Спросите что угодно... Используйте @, чтобы упомянуть страницы",
"Ask anything or search your workspace": "Спросите что угодно или выполните поиск по рабочему пространству",
"Welcome to {{name}}": "Добро пожаловать в {{name}}",
"Add files": "Добавить файлы",
"Mention a page": "Упомянуть страницу",
"Start a new chat to see it here.": "Начните новый чат, чтобы увидеть его здесь.",
"Summarize this page": "Суммировать эту страницу",
"Toggle AI Chat": "Переключить чат с ИИ",
@@ -913,176 +880,5 @@
"Try a different search term.": "Попробуйте другой поисковый запрос.",
"Try again": "Попробовать снова",
"Untitled chat": "Чат без названия",
"What can I help you with?": "Чем я могу вам помочь?",
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Настройте ваш провайдер удостоверений с этим URL для предоставления доступа пользователям и группам.",
"Create {{credential}}": "Создать {{credential}}",
"{{credential}} created": "{{credential}} создан",
"{{credential}} created successfully": "{{credential}} успешно создан",
"Created by": "Создан",
"Custom": "Пользовательский",
"Enable SCIM": "Включить SCIM",
"Enter a descriptive name": "Введите понятное имя",
"I've saved my {{credential}}": "Я сохранил свой {{credential}}",
"Important": "Важно",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Обязательно скопируйте ваш {{credential}} сейчас. Позже вы не сможете увидеть его снова!",
"Never": "Никогда",
"Revoke {{credential}}": "Отозвать {{credential}}",
"SCIM endpoint URL": "URL конечной точки SCIM",
"SCIM provisioning": "SCIM-подготовка учетных записей",
"SCIM takes precedence over SSO group sync while enabled.": "Пока SCIM включен, он имеет приоритет над синхронизацией групп через SSO.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Вы достигли максимального количества токенов SCIM: {{max}}. Удалите существующий токен, чтобы создать новый.",
"SCIM token": "Токен SCIM",
"SCIM tokens": "Токены SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Это действие нельзя отменить. Ваш провайдер удостоверений немедленно прекратит синхронизацию.",
"Toggle SCIM provisioning": "Переключить подготовку учетных записей SCIM",
"Token": "Токен",
"Page menu": "Меню страницы",
"Expand": "Развернуть",
"Collapse": "Свернуть",
"Comment menu": "Меню комментария",
"Group menu": "Меню группы",
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
"Breadcrumbs": "Хлебные крошки",
"Page actions": "Действия со страницей",
"Pick emoji": "Выбрать эмодзи",
"Template menu": "Меню шаблона",
"Use": "Использовать",
"Use template": "Использовать шаблон",
"Preview template: {{title}}": "Предпросмотр шаблона: {{title}}",
"Use a template": "Использовать шаблон",
"Search templates...": "Поиск шаблонов...",
"Search spaces...": "Поиск пространств...",
"No templates found": "Шаблоны не найдены",
"No spaces found": "Пространства не найдены",
"Browse all templates": "Просмотреть все шаблоны",
"This space": "Это пространство",
"All templates": "Все шаблоны",
"Global": "Глобально",
"New template": "Новый шаблон",
"Edit template": "Редактировать шаблон",
"Are you sure you want to delete this template?": "Вы уверены, что хотите удалить этот шаблон?",
"Template scope updated": "Область действия шаблона обновлена",
"Choose which space this template belongs to": "Выберите, к какому пространству относится этот шаблон",
"Scope": "Область действия",
"Select scope": "Выберите область действия",
"Title": "Заголовок",
"Saving...": "Сохранение...",
"Saved": "Сохранено",
"Save failed. Retry": "Не удалось сохранить. Повторите попытку",
"By {{name}}": "От {{name}}",
"Updated {{time}}": "Обновлено {{time}}",
"Choose destination": "Выберите место назначения",
"Search pages and spaces...": "Поиск страниц и пространств...",
"No results found": "Результаты не найдены",
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
"Chat menu": "Меню чата",
"API key menu": "Меню API-ключа",
"Jump to comment selection": "Перейти к выбору комментария",
"Slash commands": "Команды со слешем",
"Mention suggestions": "Подсказки упоминаний",
"Link suggestions": "Подсказки ссылок",
"Diagram editor": "Редактор диаграмм",
"Add comment": "Добавить комментарий",
"Find and replace": "Найти и заменить",
"Main navigation": "Основная навигация",
"Space navigation": "Навигация по пространству",
"Settings navigation": "Навигация по настройкам",
"AI navigation": "Навигация ИИ",
"Breadcrumb": "Хлебная крошка",
"Synced block": "Синхронизированный блок",
"Create a block that stays in sync across pages.": "Создайте блок, который будет синхронизироваться между страницами.",
"Editing original": "Редактирование оригинала",
"Copy synced block": "Скопировать синхронизированный блок",
"Unsync": "Не синхронизировать",
"Delete synced block": "Удалить синхронизированный блок",
"Synced to {{count}} other page_one": "Синхронизировано с {{count}} с другой страницей",
"Synced to {{count}} other page_other": "Синхронизировано с {{count}} с другими страницами",
"ORIGINAL": "ОРИГИНАЛ",
"THIS PAGE": "ЭТА СТРАНИЦА",
"No pages": "Нет страниц",
"The original synced block no longer exists": "Исходный синхронизированный блок больше не существует",
"You don't have access to this synced block": "У вас нет доступа к этому синхронизированному блоку",
"Failed to load this synced block": "Не удалось загрузить этот синхронизированный блок",
"Fixed editor toolbar": "Закреплённая панель инструментов редактора",
"Show a formatting toolbar above the editor with quick access to common actions.": "Показывать панель форматирования над редактором для быстрого доступа к часто используемым действиям.",
"Toggle fixed editor toolbar": "Переключить закреплённую панель инструментов редактора",
"Normal text": "Обычный текст",
"More inline formatting": "Больше вариантов встроенного форматирования",
"Subscript": "Подстрочный",
"Superscript": "Надстрочный",
"Inline code": "Встроенный код",
"Insert media": "Вставить медиа",
"Mention": "Упоминание",
"Emoji": "Эмодзи",
"Columns": "Столбцы",
"More inserts": "Больше вариантов вставки",
"Embeds": "Встраивания",
"Diagrams": "Диаграммы",
"Advanced": "Дополнительно",
"Utility": "Служебное",
"Decrease indent": "Уменьшить отступ",
"Increase indent": "Увеличить отступ",
"Clear formatting": "Очистить форматирование",
"Code block": "Блок кода",
"Experimental": "Экспериментальное",
"Strikethrough": "Зачеркивание",
"Undo": "Отменить",
"Redo": "Повторить",
"Backlinks": "Обратные ссылки",
"Last updated by": "Последний изменивший",
"Last updated": "Последнее обновление",
"Stats": "Статистика",
"Word count": "Количество слов",
"Characters": "Символы",
"Incoming links": "Входящие ссылки",
"Outgoing links": "Исходящие ссылки",
"Incoming links ({{count}})": "Входящие ссылки ({{count}})",
"Outgoing links ({{count}})": "Исходящие ссылки ({{count}})",
"No pages link here yet.": "На эту страницу пока что нет ссылок.",
"This page doesn't link to other pages yet.": "Эта страница пока не содержит ссылок на другие страницы.",
"Verified until {{date}}": "Подтверждено до: {{date}}",
"Labels": "Метки",
"Add label": "Добавить метку",
"No labels yet": "Меток пока нет",
"Already added": "Уже добавлено",
"Invalid label name": "Недопустимое имя метки",
"No matches": "Совпадений нет",
"Search or create…": "Найти или создать…",
"Remove label {{name}}": "Удалить метку {{name}}",
"Failed to add label": "Не удалось добавить метку",
"Failed to remove label": "Не удалось удалить метку",
"No pages with this label": "Нет страниц с этой меткой",
"Pages tagged with this label will appear here.": "Здесь будут отображаться страницы с этой меткой.",
"No pages match your search.": "Нет страниц, соответствующих вашему запросу.",
"Updated {{date}}": "Обновлено {{date}}",
"Cell actions": "Действия с ячейкой",
"Column actions": "Действия со столбцом",
"Row actions": "Действия со строкой",
"Filter": "Фильтр",
"Page title": "Заголовок страницы",
"Page content": "Содержимое страницы",
"Member actions": "Действия с участником",
"Toggle password visibility": "Переключить видимость пароля",
"Send comment": "Отправить комментарий",
"Token actions": "Действия с токеном",
"Template settings": "Настройки шаблона",
"Edit diagram": "Редактировать диаграмму",
"Edit embed": "Редактировать встраивание",
"Edit drawing": "Редактировать рисунок",
"Delete equation": "Удалить уравнение",
"Invite actions": "Действия с приглашением",
"Get started": "Начать",
"* indicates required fields": "* обозначает обязательные поля",
"List of spaces in this workspace": "Список пространств в этом рабочем пространстве",
"Active sessions": "Активные сеансы",
"Add {{name}} to favorites": "Добавить {{name}} в избранное",
"Remove {{name}} from favorites": "Удалить {{name}} из избранного",
"Added to favorites": "Добавлено в избранное",
"Removed from favorites": "Удалено из избранного",
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
"What can I help you with?": "Чем я могу вам помочь?"
}
@@ -71,7 +71,6 @@
"Export": "Експорт",
"Failed to create page": "Не вдалося створити сторінку",
"Failed to delete page": "Не вдалося видалити сторінку",
"Failed to restore page": "Не вдалося відновити сторінку",
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
"Failed to import pages": "Не вдалося імпортувати сторінки",
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
@@ -277,9 +276,6 @@
"Align left": "По лівому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Alt text": "Альтернативний текст",
"Describe this for accessibility.": "Опишіть це для доступності.",
"Add a description": "Додати опис",
"Justify": "По ширині",
"Merge cells": "Об'єднати комірки",
"Split cell": "Розділити комірку",
@@ -290,19 +286,6 @@
"Add row above": "Додати рядок вище",
"Add row below": "Додати рядок нижче",
"Delete table": "Видалити таблицю",
"Add column left": "Додати стовпець ліворуч",
"Add column right": "Додати стовпець праворуч",
"Clear cell": "Очистити комірку",
"Clear cells": "Очистити комірки",
"Toggle header cell": "Перемкнути комірку заголовка",
"Toggle header column": "Перемкнути стовпець заголовка",
"Toggle header row": "Перемкнути рядок заголовка",
"Move column left": "Перемістити стовпець ліворуч",
"Move column right": "Перемістити стовпець праворуч",
"Move row down": "Перемістити рядок вниз",
"Move row up": "Перемістити рядок вгору",
"Sort A → Z": "Сортувати A → Z",
"Sort Z → A": "Сортувати Z → A",
"Info": "Інформація",
"Note": "Примітка",
"Success": "Успішно",
@@ -365,8 +348,6 @@
"Create block quote.": "Створити блок цитування.",
"Insert code snippet.": "Вставити фрагмент коду.",
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Page break": "Розрив сторінки",
"Insert a page break for printing.": "Вставте розрив сторінки для друку.",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
@@ -411,10 +392,6 @@
"Write...": "Напишіть...",
"Column count": "Кількість колонок",
"{{count}} Columns": "{count, plural, one{# колонка} few{# колонки} many{# колонок} other{# колонки}}",
"{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд",
"{{count}} result available_one": "Доступний 1 результат",
"{{count}} result available_other": "Доступно {{count}} результатів",
"Equal columns": "Рівні колонки",
"Left sidebar": "Ліва бічна панель",
"Right sidebar": "Права бічна панель",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "Доступна версія {{latestVersion}}",
"Default page edit mode": "Режим редагування сторінки за замовчуванням",
"Choose your preferred page edit mode. Avoid accidental edits.": "Виберіть бажаний режим редагування сторінки. Уникайте випадкових редагувань.",
"Choose {{format}} file": "Виберіть файл {{format}}",
"Reading": "Читання",
"Delete member": "Видалити учасника",
"Member deleted successfully": "Учасника успішно видалено",
@@ -589,8 +565,6 @@
"Move to trash": "Перемістити в кошик",
"Move this page to trash?": "Перемістити цю сторінку до кошика?",
"Restore page": "Відновити сторінку",
"Permanently delete": "Видалити назавжди",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> перемістив цю сторінку до кошика {{time}}.",
"Page moved to trash": "Сторінку переміщено в кошик",
"Page restored successfully": "Сторінку успішно відновлено",
"Deleted by": "Видалив",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update": "Оновити",
"Update {{credential}}": "Оновити {{credential}}",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"Restrict API key creation to admins": "Обмежити створення API-ключів лише для адміністраторів",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Тільки адміністратори та власники можуть створювати нові API-ключі. Існуючі ключі учасників і надалі працюватимуть.",
@@ -880,12 +858,9 @@
"AI Chat": "AI-чат",
"Analyze for insights": "Проаналізувати для отримання висновків",
"Ask anything...": "Запитайте що завгодно...",
"Assistant said:": "Помічник сказав:",
"Chat history": "Історія чатів",
"Chat name": "Назва чату",
"Chat transcript": "Стенограма чату",
"Close": "Закрити",
"Copy assistant response": "Копіювати відповідь помічника",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "Не вдалося завантажити чат. Сталася помилка.",
"Failed to render this message.": "Не вдалося відобразити це повідомлення.",
@@ -895,17 +870,9 @@
"No chats found": "Чатів не знайдено",
"No conversations yet": "Розмов поки немає",
"Open full page": "Відкрити повну сторінку",
"Scroll to bottom": "Прокрутити вниз",
"You said:": "Ви сказали:",
"Previous 7 days": "Попередні 7 днів",
"Previous 30 days": "Попередні 30 днів",
"Search chats...": "Шукати чати...",
"Search chats": "Шукати чати",
"Ask anything... Use @ to mention pages": "Запитайте будь-що... Використовуйте @, щоб згадувати сторінки",
"Ask anything or search your workspace": "Запитайте будь-що або шукайте у своєму робочому просторі",
"Welcome to {{name}}": "Ласкаво просимо до {{name}}",
"Add files": "Додати файли",
"Mention a page": "Згадати сторінку",
"Start a new chat to see it here.": "Почніть новий чат, щоб побачити його тут.",
"Summarize this page": "Підсумувати цю сторінку",
"Toggle AI Chat": "Перемкнути AI-чат",
@@ -913,176 +880,5 @@
"Try a different search term.": "Спробуйте інший пошуковий запит.",
"Try again": "Спробувати ще раз",
"Untitled chat": "Чат без назви",
"What can I help you with?": "Чим я можу вам допомогти?",
"Are you sure you want to revoke this {{credential}}": "Ви впевнені, що хочете відкликати цей {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматично надавайте користувачів і групи від вашого постачальника ідентифікації через SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Налаштуйте свого постачальника ідентифікації за допомогою цієї URL-адреси для надання користувачів і груп.",
"Create {{credential}}": "Створити {{credential}}",
"{{credential}} created": "{{credential}} створено",
"{{credential}} created successfully": "{{credential}} успішно створено",
"Created by": "Створено",
"Custom": "Користувацький",
"Enable SCIM": "Увімкнути SCIM",
"Enter a descriptive name": "Введіть описову назву",
"I've saved my {{credential}}": "Я зберіг(ла) свій {{credential}}",
"Important": "Важливо",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Обов’язково скопіюйте свій {{credential}} зараз. Ви більше не зможете побачити його знову!",
"Never": "Ніколи",
"Revoke {{credential}}": "Відкликати {{credential}}",
"SCIM endpoint URL": "URL-адреса кінцевої точки SCIM",
"SCIM provisioning": "Надання SCIM",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM має пріоритет над синхронізацією груп SSO, коли його ввімкнено.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "Ви досягли максимальної кількості токенів SCIM: {{max}}. Видаліть наявний токен, щоб створити новий.",
"SCIM token": "Токен SCIM",
"SCIM tokens": "Токени SCIM",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "Цю дію не можна скасувати. Ваш постачальник ідентифікації негайно припинить синхронізацію.",
"Toggle SCIM provisioning": "Перемкнути надання SCIM",
"Token": "Токен",
"Page menu": "Меню сторінки",
"Expand": "Розгорнути",
"Collapse": "Згорнути",
"Comment menu": "Меню коментаря",
"Group menu": "Меню групи",
"Show hidden breadcrumbs": "Показати приховані \"хлібні крихти\"",
"Breadcrumbs": "\"Хлібні крихти\"",
"Page actions": "Дії сторінки",
"Pick emoji": "Вибрати емодзі",
"Template menu": "Меню шаблону",
"Use": "Використати",
"Use template": "Використати шаблон",
"Preview template: {{title}}": "Попередній перегляд шаблону: {{title}}",
"Use a template": "Використати шаблон",
"Search templates...": "Шукати шаблони...",
"Search spaces...": "Шукати простори...",
"No templates found": "Шаблони не знайдено",
"No spaces found": "Простори не знайдено",
"Browse all templates": "Переглянути всі шаблони",
"This space": "Цей простір",
"All templates": "Усі шаблони",
"Global": "Глобальний",
"New template": "Новий шаблон",
"Edit template": "Редагувати шаблон",
"Are you sure you want to delete this template?": "Ви впевнені, що хочете видалити цей шаблон?",
"Template scope updated": "Область дії шаблону оновлено",
"Choose which space this template belongs to": "Виберіть, до якого простору належить цей шаблон",
"Scope": "Область дії",
"Select scope": "Вибрати область дії",
"Title": "Назва",
"Saving...": "Збереження...",
"Saved": "Збережено",
"Save failed. Retry": "Не вдалося зберегти. Повторіть спробу",
"By {{name}}": "Від {{name}}",
"Updated {{time}}": "Оновлено {{time}}",
"Choose destination": "Вибрати місце призначення",
"Search pages and spaces...": "Шукати сторінки та простори...",
"No results found": "Результати не знайдено",
"You don't have permission to create pages here": "У вас немає дозволу на створення сторінок тут",
"Chat menu": "Меню чату",
"API key menu": "Меню ключа API",
"Jump to comment selection": "Перейти до вибору коментаря",
"Slash commands": "Слеш-команди",
"Mention suggestions": "Підказки згадок",
"Link suggestions": "Підказки посилань",
"Diagram editor": "Редактор діаграм",
"Add comment": "Додати коментар",
"Find and replace": "Знайти й замінити",
"Main navigation": "Основна навігація",
"Space navigation": "Навігація простору",
"Settings navigation": "Навігація налаштувань",
"AI navigation": "Навігація AI",
"Breadcrumb": "Хлібна крихта",
"Synced block": "Синхронізований блок",
"Create a block that stays in sync across pages.": "Створіть блок, який синхронізується між сторінками.",
"Editing original": "Редагування оригіналу",
"Copy synced block": "Скопіювати синхронізований блок",
"Unsync": "Скасувати синхронізацію",
"Delete synced block": "Видалити синхронізований блок",
"Synced to {{count}} other page_one": "Синхронізовано з {{count}} іншою сторінкою",
"Synced to {{count}} other page_other": "Синхронізовано з {{count}} іншими сторінками",
"ORIGINAL": "ОРИГІНАЛ",
"THIS PAGE": "ЦЯ СТОРІНКА",
"No pages": "Немає сторінок",
"The original synced block no longer exists": "Оригінальний синхронізований блок більше не існує",
"You don't have access to this synced block": "У вас немає доступу до цього синхронізованого блоку",
"Failed to load this synced block": "Не вдалося завантажити цей синхронізований блок",
"Fixed editor toolbar": "Закріплена панель інструментів редактора",
"Show a formatting toolbar above the editor with quick access to common actions.": "Показувати панель форматування над редактором для швидкого доступу до поширених дій.",
"Toggle fixed editor toolbar": "Перемкнути закріплену панель інструментів редактора",
"Normal text": "Звичайний текст",
"More inline formatting": "Більше вбудованого форматування",
"Subscript": "Нижній індекс",
"Superscript": "Верхній індекс",
"Inline code": "Вбудований код",
"Insert media": "Вставити медіа",
"Mention": "Згадка",
"Emoji": "Емодзі",
"Columns": "Стовпці",
"More inserts": "Більше вставок",
"Embeds": "Вбудовування",
"Diagrams": "Діаграми",
"Advanced": "Додатково",
"Utility": "Службові",
"Decrease indent": "Зменшити відступ",
"Increase indent": "Збільшити відступ",
"Clear formatting": "Очистити форматування",
"Code block": "Блок коду",
"Experimental": "Експериментальне",
"Strikethrough": "Закреслення",
"Undo": "Скасувати",
"Redo": "Повторити",
"Backlinks": "Зворотні посилання",
"Last updated by": "Востаннє оновив",
"Last updated": "Останнє оновлення",
"Stats": "Статистика",
"Word count": "Кількість слів",
"Characters": "Символи",
"Incoming links": "Вхідні посилання",
"Outgoing links": "Вихідні посилання",
"Incoming links ({{count}})": "Вхідні посилання ({{count}})",
"Outgoing links ({{count}})": "Вихідні посилання ({{count}})",
"No pages link here yet.": "Поки що жодна сторінка не посилається сюди.",
"This page doesn't link to other pages yet.": "Ця сторінка ще не містить посилань на інші сторінки.",
"Verified until {{date}}": "Перевірено до {{date}}",
"Labels": "Мітки",
"Add label": "Додати мітку",
"No labels yet": "Міток поки немає",
"Already added": "Уже додано",
"Invalid label name": "Некоректна назва мітки",
"No matches": "Немає збігів",
"Search or create…": "Шукати або створити…",
"Remove label {{name}}": "Видалити мітку {{name}}",
"Failed to add label": "Не вдалося додати мітку",
"Failed to remove label": "Не вдалося видалити мітку",
"No pages with this label": "Немає сторінок із цією міткою",
"Pages tagged with this label will appear here.": "Тут з’являться сторінки, позначені цією міткою.",
"No pages match your search.": "Немає сторінок, що відповідають вашому запиту.",
"Updated {{date}}": "Оновлено {{date}}",
"Cell actions": "Дії з коміркою",
"Column actions": "Дії зі стовпцем",
"Row actions": "Дії з рядком",
"Filter": "Фільтр",
"Page title": "Назва сторінки",
"Page content": "Вміст сторінки",
"Member actions": "Дії з учасником",
"Toggle password visibility": "Перемкнути видимість пароля",
"Send comment": "Надіслати коментар",
"Token actions": "Дії з токеном",
"Template settings": "Налаштування шаблону",
"Edit diagram": "Редагувати діаграму",
"Edit embed": "Редагувати вбудований елемент",
"Edit drawing": "Редагувати рисунок",
"Delete equation": "Видалити рівняння",
"Invite actions": "Дії із запрошенням",
"Get started": "Почати",
"* indicates required fields": "* позначає обов’язкові поля",
"List of spaces in this workspace": "Список просторів у цьому робочому просторі",
"Active sessions": "Активні сеанси",
"Add {{name}} to favorites": "Додати {{name}} до обраного",
"Remove {{name}} from favorites": "Видалити {{name}} з обраного",
"Added to favorites": "Додано до обраного",
"Removed from favorites": "Видалено з обраного",
"Added {{name}} to favorites": "{{name}} додано до обраного",
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
"What can I help you with?": "Чим я можу вам допомогти?"
}
@@ -71,7 +71,6 @@
"Export": "导出",
"Failed to create page": "创建页面失败",
"Failed to delete page": "删除页面失败",
"Failed to restore page": "恢复页面失败",
"Failed to fetch recent pages": "获取最近页面失败",
"Failed to import pages": "导入页面失败",
"Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。",
@@ -277,9 +276,6 @@
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
"Alt text": "替代文本",
"Describe this for accessibility.": "为无障碍访问添加描述。",
"Add a description": "添加描述",
"Justify": "两端对齐",
"Merge cells": "合并单元格",
"Split cell": "分割单元格",
@@ -290,19 +286,6 @@
"Add row above": "在上方添加行",
"Add row below": "在下方插入行",
"Delete table": "删除表格",
"Add column left": "在左侧添加列",
"Add column right": "在右侧添加列",
"Clear cell": "清空单元格",
"Clear cells": "清空单元格",
"Toggle header cell": "切换标题单元格",
"Toggle header column": "切换标题列",
"Toggle header row": "切换标题行",
"Move column left": "左移列",
"Move column right": "右移列",
"Move row down": "下移行",
"Move row up": "上移行",
"Sort A → Z": "按 A → Z 排序",
"Sort Z → A": "按 Z → A 排序",
"Info": "信息",
"Note": "注意",
"Success": "成功",
@@ -365,8 +348,6 @@
"Create block quote.": "创建引用块",
"Insert code snippet.": "插入代码片段",
"Insert horizontal rule divider": "插入水平分割线",
"Page break": "分页符",
"Insert a page break for printing.": "插入一个用于打印的分页符。",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
@@ -411,10 +392,6 @@
"Write...": "写点内容...",
"Column count": "列数",
"{{count}} Columns": "{{count}} 列",
"{{count}} command available_one": "有 1 个可用命令",
"{{count}} command available_other": "有 {{count}} 个可用命令",
"{{count}} result available_one": "有 1 个可用结果",
"{{count}} result available_other": "有 {{count}} 个可用结果",
"Equal columns": "等宽列",
"Left sidebar": "左侧边栏",
"Right sidebar": "右侧边栏",
@@ -439,7 +416,6 @@
"{{latestVersion}} is available": "{{latestVersion}} 可用",
"Default page edit mode": "默认页面编辑模式",
"Choose your preferred page edit mode. Avoid accidental edits.": "选择您偏好的页面编辑模式。避免意外编辑。",
"Choose {{format}} file": "选择 {{format}} 文件",
"Reading": "阅读",
"Delete member": "删除成员",
"Member deleted successfully": "成员删除成功",
@@ -589,8 +565,6 @@
"Move to trash": "移至回收站",
"Move this page to trash?": "将此页面移至垃圾箱?",
"Restore page": "恢复页面",
"Permanently delete": "永久删除",
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> 于 {{time}} 将此页面移至回收站。",
"Page moved to trash": "页面已移至回收站",
"Page restored successfully": "页面恢复成功",
"Deleted by": "删除者",
@@ -634,21 +608,25 @@
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update": "更新",
"Update {{credential}}": "更新{{credential}}",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"Restrict API key creation to admins": "仅限管理员创建 API 密钥。",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "只有管理员和所有者可以创建新的 API 密钥。现有成员密钥将继续有效。",
@@ -880,12 +858,9 @@
"AI Chat": "AI 聊天",
"Analyze for insights": "分析并获取洞察",
"Ask anything...": "随便问点什么...",
"Assistant said:": "助手说:",
"Chat history": "聊天记录",
"Chat name": "聊天名称",
"Chat transcript": "聊天记录",
"Close": "关闭",
"Copy assistant response": "复制助手回复",
"Docmost AI": "Docmost AI",
"Failed to load chat. An error occurred.": "加载聊天失败。发生错误。",
"Failed to render this message.": "渲染此消息失败。",
@@ -895,17 +870,9 @@
"No chats found": "未找到聊天",
"No conversations yet": "暂无对话",
"Open full page": "打开完整页面",
"Scroll to bottom": "滚动到底部",
"You said:": "你说:",
"Previous 7 days": "前 7 天",
"Previous 30 days": "前 30 天",
"Search chats...": "搜索聊天...",
"Search chats": "搜索聊天",
"Ask anything... Use @ to mention pages": "询问任何内容……使用 @ 提及页面",
"Ask anything or search your workspace": "询问任何问题或搜索你的工作区",
"Welcome to {{name}}": "欢迎使用 {{name}}",
"Add files": "添加文件",
"Mention a page": "提及页面",
"Start a new chat to see it here.": "开始新的聊天后会显示在这里。",
"Summarize this page": "总结此页面",
"Toggle AI Chat": "切换 AI 聊天",
@@ -913,176 +880,5 @@
"Try a different search term.": "请尝试其他搜索词。",
"Try again": "重试",
"Untitled chat": "未命名聊天",
"What can I help you with?": "我能帮您做什么?",
"Are you sure you want to revoke this {{credential}}": "确定要撤销此{{credential}}吗",
"Automatically provision users and groups from your identity provider via SCIM.": "通过 SCIM 从您的身份提供商自动预配用户和群组。",
"Configure your identity provider with this URL to provision users and groups.": "使用此 URL 配置您的身份提供商以预配用户和群组。",
"Create {{credential}}": "创建{{credential}}",
"{{credential}} created": "已创建{{credential}}",
"{{credential}} created successfully": "已成功创建{{credential}}",
"Created by": "创建者",
"Custom": "自定义",
"Enable SCIM": "启用 SCIM",
"Enter a descriptive name": "输入描述性名称",
"I've saved my {{credential}}": "我已保存我的{{credential}}",
"Important": "重要",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "请务必立即复制您的{{credential}}。之后您将无法再次查看!",
"Never": "从不",
"Revoke {{credential}}": "撤销{{credential}}",
"SCIM endpoint URL": "SCIM 端点 URL",
"SCIM provisioning": "SCIM 预配",
"SCIM takes precedence over SSO group sync while enabled.": "启用后,SCIM 的优先级高于 SSO 群组同步。",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "您已达到 {{max}} 个 SCIM 令牌的上限。请删除一个现有令牌以创建新令牌。",
"SCIM token": "SCIM 令牌",
"SCIM tokens": "SCIM 令牌",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "此操作无法撤销。您的身份提供商将立即停止同步。",
"Toggle SCIM provisioning": "切换 SCIM 预配",
"Token": "令牌",
"Page menu": "页面菜单",
"Expand": "展开",
"Collapse": "折叠",
"Comment menu": "评论菜单",
"Group menu": "群组菜单",
"Show hidden breadcrumbs": "显示隐藏的面包屑",
"Breadcrumbs": "面包屑",
"Page actions": "页面操作",
"Pick emoji": "选择表情符号",
"Template menu": "模板菜单",
"Use": "使用",
"Use template": "使用模板",
"Preview template: {{title}}": "预览模板:{{title}}",
"Use a template": "使用模板",
"Search templates...": "搜索模板……",
"Search spaces...": "搜索空间……",
"No templates found": "未找到模板",
"No spaces found": "未找到空间",
"Browse all templates": "浏览所有模板",
"This space": "此空间",
"All templates": "所有模板",
"Global": "全局",
"New template": "新建模板",
"Edit template": "编辑模板",
"Are you sure you want to delete this template?": "你确定要删除此模板吗?",
"Template scope updated": "模板范围已更新",
"Choose which space this template belongs to": "选择此模板所属的空间",
"Scope": "范围",
"Select scope": "选择范围",
"Title": "标题",
"Saving...": "正在保存……",
"Saved": "已保存",
"Save failed. Retry": "保存失败。重试",
"By {{name}}": "作者:{{name}}",
"Updated {{time}}": "更新于 {{time}}",
"Choose destination": "选择目标位置",
"Search pages and spaces...": "搜索页面和空间……",
"No results found": "未找到结果",
"You don't have permission to create pages here": "你无权在此处创建页面",
"Chat menu": "聊天菜单",
"API key menu": "API 密钥菜单",
"Jump to comment selection": "跳转到评论选择",
"Slash commands": "斜杠命令",
"Mention suggestions": "提及建议",
"Link suggestions": "链接建议",
"Diagram editor": "图表编辑器",
"Add comment": "添加评论",
"Find and replace": "查找和替换",
"Main navigation": "主导航",
"Space navigation": "空间导航",
"Settings navigation": "设置导航",
"AI navigation": "AI 导航",
"Breadcrumb": "面包屑",
"Synced block": "同步块",
"Create a block that stays in sync across pages.": "创建一个可在多个页面间保持同步的块。",
"Editing original": "正在编辑原始内容",
"Copy synced block": "复制同步块",
"Unsync": "取消同步",
"Delete synced block": "删除同步块",
"Synced to {{count}} other page_one": "已与另外 {{count}} 个页面同步",
"Synced to {{count}} other page_other": "已与另外 {{count}} 个页面同步",
"ORIGINAL": "原始内容",
"THIS PAGE": "此页面",
"No pages": "没有页面",
"The original synced block no longer exists": "原始同步块已不存在",
"You don't have access to this synced block": "你无权访问此同步块",
"Failed to load this synced block": "加载此同步块失败",
"Fixed editor toolbar": "固定编辑器工具栏",
"Show a formatting toolbar above the editor with quick access to common actions.": "在编辑器上方显示格式工具栏,便于快速访问常用操作。",
"Toggle fixed editor toolbar": "切换固定编辑器工具栏",
"Normal text": "普通文本",
"More inline formatting": "更多内联格式",
"Subscript": "下标",
"Superscript": "上标",
"Inline code": "行内代码",
"Insert media": "插入媒体",
"Mention": "提及",
"Emoji": "表情符号",
"Columns": "分栏",
"More inserts": "更多插入项",
"Embeds": "嵌入内容",
"Diagrams": "图表",
"Advanced": "高级",
"Utility": "实用工具",
"Decrease indent": "减少缩进",
"Increase indent": "增加缩进",
"Clear formatting": "清除格式",
"Code block": "代码块",
"Experimental": "实验性",
"Strikethrough": "删除线",
"Undo": "撤销",
"Redo": "重做",
"Backlinks": "反向链接",
"Last updated by": "最后更新者",
"Last updated": "最后更新",
"Stats": "统计",
"Word count": "字数",
"Characters": "字符数",
"Incoming links": "传入链接",
"Outgoing links": "传出链接",
"Incoming links ({{count}})": "传入链接({{count}}",
"Outgoing links ({{count}})": "传出链接({{count}}",
"No pages link here yet.": "还没有页面链接到这里。",
"This page doesn't link to other pages yet.": "此页面尚未链接到其他页面。",
"Verified until {{date}}": "验证有效期至 {{date}}",
"Labels": "标签",
"Add label": "添加标签",
"No labels yet": "还没有标签",
"Already added": "已添加",
"Invalid label name": "标签名称无效",
"No matches": "无匹配结果",
"Search or create…": "搜索或创建…",
"Remove label {{name}}": "移除标签 {{name}}",
"Failed to add label": "添加标签失败",
"Failed to remove label": "移除标签失败",
"No pages with this label": "没有带有此标签的页面",
"Pages tagged with this label will appear here.": "带有此标签的页面将显示在这里。",
"No pages match your search.": "没有页面匹配你的搜索。",
"Updated {{date}}": "更新于 {{date}}",
"Cell actions": "单元格操作",
"Column actions": "列操作",
"Row actions": "行操作",
"Filter": "筛选",
"Page title": "页面标题",
"Page content": "页面内容",
"Member actions": "成员操作",
"Toggle password visibility": "切换密码可见性",
"Send comment": "发送评论",
"Token actions": "令牌操作",
"Template settings": "模板设置",
"Edit diagram": "编辑图表",
"Edit embed": "编辑嵌入内容",
"Edit drawing": "编辑绘图",
"Delete equation": "删除公式",
"Invite actions": "邀请操作",
"Get started": "开始使用",
"* indicates required fields": "* 表示必填字段",
"List of spaces in this workspace": "此工作区中的空间列表",
"Active sessions": "活动会话",
"Add {{name}} to favorites": "将 {{name}} 添加到收藏",
"Remove {{name}} from favorites": "将 {{name}} 从收藏中移除",
"Added to favorites": "已添加到收藏",
"Removed from favorites": "已从收藏中移除",
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
"What can I help you with?": "我能帮您做什么?"
}
-5
View File
@@ -38,7 +38,6 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
@@ -46,7 +45,6 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -94,7 +92,6 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -107,8 +104,6 @@ export default function App() {
element={<Page />}
/>
<Route path={"/base/:pageId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
@@ -80,20 +80,12 @@ export default function AvatarUploader({
}
};
const actionLabel = {
const ariaLabel = {
[AvatarIconType.AVATAR]: t("Change avatar"),
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
}[type];
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
// visible text. When no image is set, the avatar renders the name's
// initials, so prepend the name to the action label.
const ariaLabel =
!currentImageUrl && fallbackName
? `${fallbackName} ${actionLabel}`
: actionLabel;
const handleRemove = async () => {
if (disabled) return;
+3 -7
View File
@@ -8,19 +8,15 @@ interface CopyProps {
text: string;
size?: MantineSize;
color?: MantineColor;
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
label?: string;
}
export default function CopyTextButton({ text, size, label }: CopyProps) {
export default function CopyTextButton({ text, size }: CopyProps) {
const { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : copyLabel}
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
@@ -29,7 +25,7 @@ export default function CopyTextButton({ text, size, label }: CopyProps) {
variant="subtle"
onClick={copy}
size={size}
aria-label={copied ? t("Copied") : copyLabel}
aria-label={copied ? t("Copied") : t("Copy")}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
@@ -81,7 +81,7 @@ export default function ExportModal({
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
@@ -1,18 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import { IconFileDescription, IconTable } from "@tabler/icons-react";
type Props = {
icon?: string | null;
isBase?: boolean;
};
export function PageListIcon({ icon, isBase }: Props) {
if (icon) {
return <>{icon}</>;
}
return (
<ThemeIcon variant="transparent" color="gray" size={18}>
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
</ThemeIcon>
);
}
@@ -4,20 +4,19 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
import rowClasses from "@/components/ui/clickable-table-row.module.css";
interface Props {
spaceId?: string;
@@ -42,18 +41,21 @@ export default function RecentChanges({ spaceId }: Props) {
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.map((page) => (
<Table.Tr key={page.id} className={rowClasses.row}>
<Table.Tr key={page.id}>
<Table.Td>
<UnstyledButton
className={rowClasses.link}
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
<PageListIcon icon={page.icon} isBase={page.isBase} />
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{getPageTitle(page.title, page.isBase, t)}
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
@@ -27,3 +27,23 @@
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
}
}
.skipLink {
position: fixed;
left: 8px;
top: 8px;
padding: 8px 12px;
background: var(--mantine-color-blue-6);
color: #fff;
border-radius: 4px;
text-decoration: none;
z-index: 1000;
transform: translateY(-150%);
&:focus {
transform: translateY(0);
outline: 2px solid var(--mantine-color-blue-3);
}
}
@@ -1,27 +1,18 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { Box, ScrollArea, Text } from "@mantine/core";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
if (!isAsideOpen) return;
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
let title: string;
let component: ReactNode;
@@ -39,10 +30,6 @@ export default function Aside() {
component = <AsideChatPanel />;
title = "AI Chat";
break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default:
component = null;
title = null;
@@ -53,19 +40,9 @@ export default function Aside() {
{component && (
<>
{tab !== "chat" && (
<Group justify="space-between" wrap="nowrap" mb="md">
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Text mb="md" fw={500}>
{t(title)}
</Text>
)}
{tab === "comments" || tab === "chat" ? (
@@ -18,8 +18,6 @@ import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
export default function GlobalAppShell({
children,
@@ -84,7 +82,9 @@ export default function GlobalAppShell({
return (
<>
<SkipToMain />
<a href="#main-content" className={classes.skipLink}>
{t("Skip to main content")}
</a>
<AppShell
header={{ height: 45 }}
navbar={{
@@ -129,7 +129,7 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
<AppShell.Main id="main-content">
{isSettingsRoute ? (
<Container size={900} pb={80}>
{children}
@@ -141,8 +141,6 @@ export default function GlobalAppShell({
{isPageRoute && (
<AppShell.Aside
id={ASIDE_PANEL_ID}
tabIndex={-1}
className={classes.aside}
p="md"
withBorder={false}
@@ -153,9 +151,7 @@ export default function GlobalAppShell({
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: asideTab === "details"
? t("Details")
: undefined
: undefined
}
>
<Aside />
@@ -31,11 +31,6 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
&[data-active] {
&,
& :hover {
@@ -43,16 +38,6 @@
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
}
&[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
@mixin hover {
background-color: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
}
.linkIcon {
@@ -101,9 +86,4 @@
);
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import {
IconHome,
IconClock,
@@ -7,7 +7,6 @@ import {
IconLayoutGrid,
IconSettings,
IconUserPlus,
IconTemplate,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./global-sidebar.module.css";
@@ -21,9 +20,12 @@ import { useDisclosure } from "@mantine/hooks";
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
];
export default function GlobalSidebar() {
const { t } = useTranslation();
@@ -31,19 +33,6 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const upgradeLabel = useUpgradeLabel();
const mainNavItems = [
{ label: "Home", icon: IconHome, path: "/home" },
{ label: "Favorites", icon: IconStar, path: "/favorites" },
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
{
label: "Templates",
icon: IconTemplate,
path: "/templates",
disabled: !hasTemplates,
},
];
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
@@ -69,38 +58,18 @@ export default function GlobalSidebar() {
<div className={classes.navbar}>
<ScrollArea w="100%" style={{ flex: 1 }}>
<div className={classes.section}>
{mainNavItems.map((item) =>
item.disabled ? (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<UnstyledButton
className={classes.link}
data-disabled
aria-disabled="true"
tabIndex={-1}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</UnstyledButton>
</Tooltip>
) : (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
aria-current={active === item.path ? "page" : undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
),
)}
{mainNavItems.map((item) => (
<Link
key={item.label}
className={classes.link}
data-active={active === item.path || undefined}
to={item.path}
onClick={handleNavClick}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
</div>
<Divider my="xs" />
@@ -160,7 +129,6 @@ export default function GlobalSidebar() {
<Link
className={classes.link}
data-active={active.startsWith("/settings") || undefined}
aria-current={active.startsWith("/settings") ? "page" : undefined}
to="/settings/account/profile"
onClick={handleNavClick}
>
@@ -10,7 +10,6 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
@@ -29,7 +29,7 @@ export default function AppVersion() {
>
<Indicator
label={t("New update")}
color="gray"
color="dark"
inline
size={16}
position="middle-end"
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) {
return (
<>
<Title order={1} size="h3">
<Title order={3}>
{title}
</Title>
<Divider my="md" />
@@ -1,29 +0,0 @@
/*
* Focus styling for list-style tables (recent changes, favorites, all
* spaces, groups, verified pages, shares).
*
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
* data tables should not be made fully clickable. Only the title cell is the
* link, and that link is what receives Tab focus.
*
* - `.row` adds a subtle background tint when the row contains the focused
* element, so keyboard users can see which row they're inspecting.
* - `.link` adds a visible :focus-visible outline on the title link itself.
*
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
* inside table cells cause column reflow on focus in Chromium.
*/
.row:focus-within {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
.link:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
@@ -16,18 +16,14 @@ 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` pairs whose filled background meets WCAG AA (4.5:1) against
// white text. Avoids lime/yellow/green/orange — even their dark shades have
// weak white-text contrast.
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
"grape.9",
"indigo.8",
"grape.7",
"indigo.7",
"pink.8",
"red.8",
"violet.7",
@@ -46,11 +42,6 @@ function pickInitialsColor(name: string) {
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
}
function sanitizeInitialsSource(name: string) {
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
return sanitized || name;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
@@ -58,13 +49,12 @@ export const CustomAvatar = React.forwardRef<
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const initialsSource = sanitizeInitialsSource(name ?? "");
return (
<Avatar
ref={ref}
src={avatarLink}
name={initialsSource}
name={name}
alt={name}
color={resolvedColor}
{...props}
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Modal, Button, Group, Divider } from "@mantine/core";
import { Modal, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker";
import {
@@ -16,8 +16,6 @@ export function DestinationPickerModal({
loading,
excludePageId,
pageLimit,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerModalProps) {
const { t } = useTranslation();
const [selection, setSelection] = useState<DestinationSelection | null>(null);
@@ -41,20 +39,16 @@ export function DestinationPickerModal({
<Modal.Content>
<Modal.Header py={0}>
<Modal.Title fw={500}>{title}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<DestinationPicker
onSelectionChange={setSelection}
excludePageId={excludePageId}
pageLimit={pageLimit}
initialSpaceId={initialSpaceId}
searchSpacesOnly={searchSpacesOnly}
/>
<Divider my="md" />
<Group justify="flex-end">
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
@@ -13,7 +13,6 @@
display: flex;
align-items: center;
gap: 8px;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
transition: background-color 150ms ease;
user-select: none;
@@ -23,11 +22,6 @@
var(--mantine-color-dark-6)
);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.selected {
@@ -63,7 +57,7 @@
border-radius: var(--mantine-radius-sm);
flex-shrink: 0;
transition: transform 150ms ease;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
@mixin hover {
background-color: light-dark(
@@ -89,6 +83,14 @@
}
}
.selectedIndicator {
padding: 8px 12px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: var(--mantine-spacing-xs);
}
.emptyState {
padding: 12px;
text-align: center;
@@ -109,7 +111,7 @@
}
.spaceName {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
import { useState, useCallback } from "react";
import { TextInput, ScrollArea, Loader } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
import { IconSearch, IconFile } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
@@ -15,29 +15,23 @@ type DestinationPickerProps = {
onSelectionChange: (selection: DestinationSelection | null) => void;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
export function DestinationPicker({
onSelectionChange,
excludePageId,
pageLimit = 15,
initialSpaceId,
searchSpacesOnly,
}: DestinationPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<DestinationSelection | null>(null);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
limit: 100,
});
const searchEnabled =
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
const searchEnabled = debouncedQuery && debouncedQuery.length >= 2;
const { data: searchData, isLoading: searchLoading } =
useSearchSuggestionsQuery({
@@ -48,18 +42,6 @@ export function DestinationPicker({
const isSearching = !!searchEnabled;
const filteredSpaces = useMemo(() => {
const items = spacesData?.items ?? [];
if (!searchSpacesOnly || !debouncedQuery) return items;
const fold = (s: string) =>
s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLocaleLowerCase();
const term = fold(debouncedQuery);
return items.filter((s) => fold(s.name).includes(term));
}, [spacesData, searchSpacesOnly, debouncedQuery]);
const selectedId =
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
@@ -105,48 +87,18 @@ export function DestinationPicker({
[updateSelection],
);
// Pre-select space when initialSpaceId is set and spaces have loaded.
// Only runs once: skip if user has already made a selection.
useEffect(() => {
if (!initialSpaceId || selection) return;
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (match) {
updateSelection({ type: "space", spaceId: match.id, space: match });
requestAnimationFrame(() => {
const el = viewportRef.current?.querySelector<HTMLElement>(
`[data-space-id="${match.id}"]`,
);
el?.scrollIntoView({ block: "nearest" });
});
}
}, [initialSpaceId, selection, spacesData, updateSelection]);
return (
<>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
aria-label={
searchSpacesOnly
? t("Search spaces...")
: t("Search pages and spaces...")
}
placeholder={t("Search pages and spaces...")}
variant="filled"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
className={classes.searchInput}
/>
<ScrollArea
h="50vh"
offsetScrollbars
className={classes.scrollArea}
viewportRef={viewportRef}
>
<ScrollArea h="50vh" offsetScrollbars className={classes.scrollArea}>
{isSearching ? (
searchLoading ? (
<div className={classes.emptyState}>
@@ -159,28 +111,16 @@ export function DestinationPicker({
<div
key={page.id}
className={classes.searchResult}
role="button"
tabIndex={0}
onClick={() => handleSearchResultClick(page)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSearchResultClick(page);
}
}}
>
<div className={classes.iconWrapper}>
{page.icon ? (
page.icon
) : (
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
<div className={classes.pageTitle}>
@@ -201,14 +141,8 @@ export function DestinationPicker({
<div className={classes.emptyState}>
<Loader size="xs" />
</div>
) : filteredSpaces.length === 0 ? (
<div className={classes.emptyState}>
{searchSpacesOnly && debouncedQuery
? t("No spaces found")
: t("No results found")}
</div>
) : (
filteredSpaces.map((space) => (
spacesData?.items?.map((space) => (
<SpaceRow
key={space.id}
space={space}
@@ -221,6 +155,14 @@ export function DestinationPicker({
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -20,6 +20,4 @@ export type DestinationPickerModalProps = {
loading?: boolean;
excludePageId?: string;
pageLimit?: number;
initialSpaceId?: string;
searchSpacesOnly?: boolean;
};
@@ -1,9 +1,7 @@
import { KeyboardEvent, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useState } from "react";
import { IconChevronRight, IconFile } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { getPageTitle } from "@/features/page/page.utils";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
@@ -38,44 +36,23 @@ export function PageRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (!isExcluded) onSelect(page);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
return (
<>
<div
className={rowClasses}
style={{ paddingLeft: depth * 20 + 12 }}
role="button"
tabIndex={isExcluded ? -1 : 0}
aria-disabled={isExcluded || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
onClick={() => !isExcluded && onSelect(page)}
>
{page.hasChildren ? (
<ActionIcon
<div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</ActionIcon>
</div>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
@@ -84,19 +61,15 @@ export function PageRow({
{page.icon ? (
page.icon
) : (
<ActionIcon
component="div"
variant="transparent"
c="gray"
size={22}
>
<IconFileDescription size={18} />
</ActionIcon>
<IconFile
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</div>
<div className={classes.pageTitle}>
{getPageTitle(page.title, page.isBase, t)}
{page.title || t("Untitled")}
</div>
</div>
@@ -1,5 +1,5 @@
import { KeyboardEvent, useState } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useState } from "react";
import { Tooltip } from "@mantine/core";
import { IconChevronRight, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types";
@@ -42,43 +42,21 @@ export function SpaceRow({
.filter(Boolean)
.join(" ");
const handleSelect = () => {
if (writable) onSelectSpace(space);
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
};
const rowContent = (
<div
className={rowClasses}
data-space-id={space.id}
role="button"
tabIndex={writable ? 0 : -1}
aria-disabled={!writable || undefined}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
onClick={() => writable && onSelectSpace(space)}
>
{writable ? (
<ActionIcon
<div
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
variant="subtle"
color="gray"
size="sm"
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<IconChevronRight size={14} />
</ActionIcon>
</div>
) : (
<div style={{ width: 20, flexShrink: 0 }} />
)}
+3 -51
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from "react";
import React, { ReactNode, useState } from "react";
import {
ActionIcon,
Popover,
@@ -7,24 +7,9 @@ import {
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "react";
const Picker = React.lazy(() => import("@emoji-mart/react"));
import { useTranslation } from "react-i18next";
// Load the picker module AND the emoji data in parallel inside the lazy
// resolution, then bind the data into the component. React.lazy only finishes
// suspending once both are in memory, so the Suspense boundary hides the
// Remove button until the Picker can render with real content.
const Picker = React.lazy(async () => {
const [pickerModule, dataModule] = await Promise.all([
import("@slidoapp/emoji-mart-react"),
import("@slidoapp/emoji-mart-data"),
]);
const PickerComp = pickerModule.default;
const data = dataModule.default;
return {
default: (props: any) => <PickerComp {...props} data={data} />,
};
});
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
@@ -34,7 +19,6 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -66,38 +50,6 @@ function EmojiPicker({
}
});
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
// makes the browser scroll every scrollable ancestor of the search input to
// bring it on screen — including the page editor's scroll container, so the
// page jumps to the top whenever the picker is opened from a scrolled-down
// position. The search input lives inside the <em-emoji-picker> custom
// element's shadow root, so we poll for it after the dropdown mounts and
// focus it ourselves with preventScroll.
useEffect(() => {
if (!opened || !dropdown) return;
let cancelled = false;
let rafId = 0;
const tryFocus = (attempts: number) => {
if (cancelled) return;
const pickerEl = dropdown.querySelector("em-emoji-picker");
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
'input[type="search"]',
);
if (input) {
input.focus({ preventScroll: true });
return;
}
if (attempts < 60) {
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
}
};
rafId = requestAnimationFrame(() => tryFocus(0));
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [opened, dropdown]);
const handleEmojiSelect = (emoji) => {
onEmojiSelect(emoji);
handlers.close();
@@ -122,7 +74,6 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
@@ -134,6 +85,7 @@ function EmojiPicker({
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
@@ -14,14 +14,7 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => {
return (
<ActionIcon
size={size}
aria-expanded={opened}
{...others}
variant="subtle"
color="gray"
ref={ref}
>
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
{opened ? (
<IconLayoutSidebarRightExpand />
) : (
@@ -1,27 +0,0 @@
.skipLink {
position: absolute;
top: 8px;
left: 8px;
z-index: 9999;
padding: 8px 16px;
background: var(--mantine-color-body);
color: var(--mantine-color-text);
border: 2px solid var(--mantine-color-blue-6);
border-radius: 4px;
text-decoration: none;
font-weight: 500;
font-size: var(--mantine-font-size-sm);
transform: translateY(-200%);
transition: transform 0.15s ease-out;
}
.skipLink:focus {
transform: translateY(0);
outline: none;
}
@media print {
.skipLink {
display: none !important;
}
}
@@ -1,13 +0,0 @@
import { useTranslation } from "react-i18next";
import classes from "./skip-to-main.module.css";
export const MAIN_CONTENT_ID = "main-content";
export function SkipToMain() {
const { t } = useTranslation();
return (
<a href={`#${MAIN_CONTENT_ID}`} className={classes.skipLink}>
{t("Skip to main content")}
</a>
);
}
@@ -120,7 +120,7 @@ export default function AiChatSidebar() {
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<h2 className={classes.title}>{t("AI Chat")}</h2>
<span className={classes.title}>{t("AI Chat")}</span>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
@@ -137,8 +137,7 @@ export default function AiChatSidebar() {
<TextInput
className={classes.searchInput}
placeholder={t("Search chats...")}
aria-label={t("Search chats")}
placeholder="Search chats..."
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
@@ -176,7 +175,7 @@ export default function AiChatSidebar() {
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
<div className={classes.chatGroupLabel}>{group.label}</div>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
@@ -178,7 +178,6 @@ export default function AsideChatPanel() {
href="/ai"
variant="subtle"
color="dark"
aria-label={t("New chat")}
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
@@ -186,23 +185,13 @@ export default function AsideChatPanel() {
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
@@ -56,22 +56,22 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<h1 className={classes.emptyStateTitle}>
<div className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</h1>
</div>
<div className={classes.emptyStateInput}>
<ChatInput
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder={t("Ask anything... Use @ to mention pages")}
placeholder="Ask anything... Use @ to mention pages"
autofocus
/>
</div>
<div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsLabel}>Get started</div>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
placeholder: placeholder || "Ask anything... Use @ to mention pages",
}),
CharacterCount.configure({
limit: 50000,
@@ -225,11 +225,6 @@ export default function ChatInput({
}),
],
editorProps: {
attributes: {
role: "textbox",
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
if (
@@ -280,8 +275,6 @@ export default function ChatInput({
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
@@ -336,15 +329,7 @@ export default function ChatInput({
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover
opened={plusMenuOpen}
onChange={setPlusMenuOpen}
position="top-start"
width={220}
shadow="md"
trapFocus
returnFocus
>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover.Target>
<button
type="button"
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@mantine/core";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
@@ -34,7 +33,6 @@ export default function ChatMessageList({
streamingContent,
streamingToolCalls,
}: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
@@ -42,38 +40,6 @@ export default function ChatMessageList({
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
// Dedicated status-region announcement for screen readers. Rather than
// putting aria-live on the whole transcript (which re-fires for every
// streamed token), announce "AI is thinking…" when streaming starts and
// the full assistant reply once streaming completes — a single, clean read.
const [statusAnnouncement, setStatusAnnouncement] = useState("");
const wasStreamingRef = useRef(false);
useEffect(() => {
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
if (justStartedStreaming) {
setStatusAnnouncement(t("AI is thinking..."));
} else if (justFinishedStreaming) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant" && lastMessage.content) {
// Strip markdown punctuation so screen readers don't read symbols
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
// version stays in the DOM for visual users.
const plainText = lastMessage.content
.replace(/[#*_`~]/g, "")
.replace(/\s+/g, " ")
.trim();
setStatusAnnouncement(plainText);
} else {
setStatusAnnouncement("");
}
}
wasStreamingRef.current = isStreaming;
}, [isStreaming, messages, t]);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
@@ -161,18 +127,7 @@ export default function ChatMessageList({
return (
<div className={classes.messageListWrapper}>
{/* Single status region for chat announcements. Kept outside the
scrolling transcript so changes here trigger one polite read per
state change instead of re-announcing every streamed token. */}
<VisuallyHidden role="status" aria-live="polite">
{statusAnnouncement}
</VisuallyHidden>
<div
ref={containerRef}
className={classes.messageList}
aria-label={t("Chat transcript")}
>
<div ref={containerRef} className={classes.messageList}>
{messages.map((msg) => (
<ErrorBoundary
key={msg.id}
@@ -207,7 +162,7 @@ export default function ChatMessageList({
{showScrollButton && (
<button
type="button"
aria-label={t("Scroll to bottom")}
aria-label="Scroll to bottom"
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
@@ -1,6 +1,5 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
@@ -17,26 +16,14 @@ import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName !== "A") return;
const href = node.getAttribute("href") || "";
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
const m = href.match(PAGE_PATH_RE);
if (m) {
node.setAttribute("href", m[0]);
node.removeAttribute("target");
node.removeAttribute("rel");
return;
}
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
}
});
@@ -56,7 +43,6 @@ export default function ChatMessage({
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -92,11 +78,7 @@ export default function ChatMessage({
}[]) || [];
return (
<div
className={classes.userMessage}
role="article"
aria-label={t("You said:")}
>
<div className={classes.userMessage}>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
@@ -118,16 +100,8 @@ export default function ChatMessage({
);
}
// Only label the article when there's something meaningful to announce.
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
const hasAnnouncableContent = Boolean(content);
return (
<div
className={classes.assistantMessage}
role="article"
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
>
<div className={classes.assistantMessage}>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
@@ -157,10 +131,7 @@ export default function ChatMessage({
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton
text={message?.content}
label={t("Copy assistant response")}
/>
<CopyTextButton text={message?.content} />
</div>
)}
</div>
@@ -31,16 +31,7 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs);
}
@@ -106,7 +106,6 @@
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-top: 0;
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
@@ -126,10 +125,9 @@
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0;
margin-bottom: var(--mantine-spacing-sm);
}
@@ -43,7 +43,7 @@
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.attachmentChips {
@@ -114,7 +114,7 @@
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: var(--mantine-color-placeholder);
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
content: attr(data-placeholder);
float: left;
height: 0;
@@ -183,7 +183,7 @@
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
@@ -15,7 +15,6 @@
}
.title {
margin: 0;
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
@@ -34,11 +33,10 @@
}
.chatGroupLabel {
margin: 0;
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none;
}
@@ -106,7 +104,7 @@
.chatItemDate {
font-size: var(--mantine-font-size-xs);
color: var(--mantine-color-dimmed);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
transition: opacity 150ms;
}
@@ -120,8 +118,7 @@
color: inherit;
}
.chatItem:hover .chatItemDate,
.chatItem:focus-within .chatItemDate {
.chatItem:hover .chatItemDate {
opacity: 0;
}
@@ -136,12 +133,6 @@
position: relative;
}
.chatItem:hover .chatItemActions,
.chatItem:focus-within .chatItemActions {
.chatItem:hover .chatItemActions {
opacity: 1;
}
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
@@ -33,7 +33,6 @@ export function ApiKeyCreatedModal({
onClose={onClose}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Alert
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
@@ -23,11 +23,10 @@ export function ApiKeyTable({
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString(i18n.language, {
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
@@ -107,7 +107,6 @@ export function CreateApiKeyModal({
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -32,7 +32,6 @@ export function RevokeApiKeyModal({
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
@@ -55,7 +55,6 @@ export function UpdateApiKeyModal({
onClose={onClose}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
@@ -1,43 +0,0 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell } 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.
export const activeViewIdAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const editingCellAtomFamily = atomFamily((_pageId: string) =>
atom<EditingCell>(null),
);
export type FormulaEditorTarget = {
propertyId: string;
rowId: string | null;
} | null;
export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) =>
atom<FormulaEditorTarget>(null),
);
export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) =>
atom<boolean>(false),
);
export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) =>
atom<number>(0),
);
export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
atom<Set<string>>(new Set<string>()),
);
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
@@ -1,3 +0,0 @@
import { atom } from "jotai";
export const formulaRecomputeAtom = atom<Record<string, string[]>>({});
@@ -1,20 +0,0 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import type { RowReferences } from "@/ee/base/types/base.types";
// Per-base normalized store of resolved reference entities, hydrated from each
// rows-page `references`. Keyed by pageId, matching base-atoms.ts.
export const referenceStoreAtomFamily = atomFamily((_pageId: string) =>
atom<RowReferences>({ users: {}, pages: {} }),
);
export function mergeReferences(
prev: RowReferences,
next: RowReferences | undefined,
): RowReferences {
if (!next) return prev;
return {
users: { ...prev.users, ...next.users },
pages: { ...prev.pages, ...next.pages },
};
}
@@ -1,21 +0,0 @@
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/ee/base/types/base.types";
export type ViewDraftKey = {
userId: string;
pageId: string;
viewId: string;
};
export const viewDraftStorageKey = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`;
// atomWithStorage handles JSON serialization and cross-tab sync. The custom
// comparator ensures the same userId/pageId/viewId triple resolves to the
// same atom instance, so Jotai's identity-equality cache hits still work.
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(viewDraftStorageKey(k), null),
(a, b) =>
a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId,
);
@@ -1,91 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebouncedCallback } from "@mantine/hooks";
import {
usePageQuery,
useUpdateTitlePageMutation,
updatePageData,
} from "@/features/page/queries/page-query";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter";
import classes from "@/ee/base/styles/grid.module.css";
// Editable base name for the inline embed. Follows the TitleEditor convention
// (updatePageData + localEmitter + websocket emit) so the sidebar and other
// clients stay in sync. Standalone pages use the page TitleEditor instead.
export function BaseEmbedTitle({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId });
const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation();
const emit = useQueryEmit();
const [value, setValue] = useState("");
const focusedRef = useRef(false);
// Keep in sync with the persisted title but never clobber active user input.
useEffect(() => {
if (!focusedRef.current) setValue(page?.title ?? "");
}, [page?.title]);
const commit = useCallback(() => {
const trimmed = value.trim();
if (!page || trimmed === (page.title ?? "")) return;
updateTitleAsync({ pageId, title: trimmed }).then((updated) => {
if (updated.title !== trimmed) return;
const event: UpdateEvent = {
operation: "updateOne",
spaceId: updated.spaceId,
entity: ["pages"],
id: updated.id,
payload: {
title: updated.title,
slugId: updated.slugId,
parentPageId: updated.parentPageId,
icon: updated.icon,
},
};
updatePageData(updated);
localEmitter.emit("message", event);
emit(event);
});
}, [value, page, pageId, updateTitleAsync, emit]);
const debouncedCommit = useDebouncedCallback(commit, 500);
// Force-save any pending edit on unmount (e.g. navigating away mid-type).
const commitRef = useRef(commit);
useEffect(() => {
commitRef.current = commit;
}, [commit]);
useEffect(() => () => commitRef.current(), []);
return (
<input
className={classes.embedTitleInput}
value={value}
placeholder={t("Untitled base")}
aria-label={t("Base name")}
onChange={(e) => {
setValue(e.currentTarget.value);
debouncedCommit();
}}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
commit();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
if (e.key === "Escape") {
setValue(page?.title ?? "");
e.currentTarget.blur();
}
}}
/>
);
}
@@ -1,92 +0,0 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/ee/base/styles/grid.module.css";
import classes from "@/ee/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const DEFAULT_COLUMN_COUNT = 6;
const DEFAULT_ROW_COUNT = 10;
// Deterministic widths prevent flicker between renders.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
type BaseTableSkeletonProps = {
// Match the eventual content shape to avoid a jarring size jump on swap.
rows?: number;
columns?: number;
};
export function BaseTableSkeleton({
rows = DEFAULT_ROW_COUNT,
columns = DEFAULT_COLUMN_COUNT,
}: BaseTableSkeletonProps = {}) {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div className={classes.root}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: columns }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex % HEADER_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,70 +0,0 @@
import { GridContainer } from "@/ee/base/components/grid/grid-container";
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/ee/base/types/base.types";
type BaseTableProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
isFiltered: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode;
};
export function BaseTable({
base,
rows: _rows,
table,
pageId,
embedded,
isFiltered,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
onCellUpdate,
onAddRow,
onColumnReorder,
onResizeEnd,
onRowReorder,
scrollportRef,
aboveBand,
}: BaseTableProps) {
return (
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={onCellUpdate}
onAddRow={onAddRow}
pageId={pageId}
isFiltered={isFiltered}
onColumnReorder={onColumnReorder}
onResizeEnd={onResizeEnd}
onRowReorder={onRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
scrollElement={embedded ? window : scrollportRef.current}
aboveBand={aboveBand ?? null}
/>
);
}
@@ -1,299 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
IconArrowsDiagonal,
IconLayoutColumns,
IconAdjustments,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { getApiErrorMessage } from "@/lib/api-error";
import { useBaseEditable } from "@/ee/base/context/base-editable";
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";
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker";
import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css";
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
type BaseToolbarProps = {
base: IBase;
activeView: IBaseView | undefined;
views: IBaseView[];
table?: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
canAddView?: boolean;
onPersistViewConfig: () => void;
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
onExpand?: () => void;
getViewShareUrl?: (viewId: string) => string | null;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
canAddView,
onPersistViewConfig,
onDraftSortsChange,
onDraftFiltersChange,
onExpand,
getViewShareUrl,
}: BaseToolbarProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false);
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const isKanban = activeView?.type === "kanban";
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: getApiErrorMessage(err, t("Failed to export CSV")),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setPropertiesOpened(panel === "properties" ? (v) => !v : false);
}, []);
const sorts = activeView?.config?.sorts ?? [];
const conditions = useMemo<FilterCondition[]>(() => {
const filter = activeView?.config?.filter;
if (!filter || filter.op !== "and") return [];
return filter.children.filter(
(c): c is FilterCondition => !("children" in c),
);
}, [activeView?.config?.filter]);
const hiddenPropertyCount = useMemo(() => {
if (!table) return 0;
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table?.getState().columnVisibility]);
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
},
[onDraftSortsChange],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
onDraftFiltersChange(filter);
},
[onDraftFiltersChange],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
pageId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
base={base}
canAddView={canAddView}
getViewShareUrl={getViewShareUrl}
/>
<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>
)}
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
{isKanban && activeView && (
<>
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
<Tooltip label={t("Group by")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<IconLayoutColumns size={16} />
</ActionIcon>
</Tooltip>
</KanbanGroupByPicker>
<KanbanCardProperties
opened={cardPropertiesOpened}
onClose={() => setCardPropertiesOpened(false)}
base={base}
view={activeView}
pageId={base.id}
>
<Tooltip label={t("Card properties")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() => setCardPropertiesOpened((v) => !v)}
>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
</KanbanCardProperties>
</>
)}
{!isKanban && (
<>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
{table && (
<ViewPropertyVisibility
opened={propertiesOpened}
onClose={() => setPropertiesOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
>
<IconEye size={16} />
{hiddenPropertyCount > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{hiddenPropertyCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewPropertyVisibility>
)}
</>
)}
{onExpand && (
<Tooltip label={t("Open as page")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onExpand}
>
<IconArrowsDiagonal size={16} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
);
}
@@ -1,45 +0,0 @@
import { Group, Button, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
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 (
<Group justify="flex-end" gap="xs" px="md" py={6} wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>
{t("Reset")}
</Button>
{canSave && (
<Tooltip
label={t("Filter and sort changes are visible only to you")}
position="top"
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={onSave}
loading={saving}
>
{t("Save for everyone")}
</Button>
</Tooltip>
)}
</Group>
);
}
@@ -1,526 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
EditingCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
useBaseRowsQuery,
flattenRows,
useCreateRowMutation,
useUpdateRowMutation,
useReorderRowMutation,
} from "@/ee/base/queries/base-row-query";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
} 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";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store";
import { useViewDraft } from "@/ee/base/hooks/use-view-draft";
import { BaseToolbar } from "@/ee/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner";
import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { ViewRenderer } from "@/ee/base/components/views/view-renderer";
import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { BaseEditableProvider } from "@/ee/base/context/base-editable";
import { RowExpandProvider } from "@/ee/base/context/row-expand";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { getAppUrl } from "@/lib/config.ts";
import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css";
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
type BaseViewProps = {
pageId: string;
embedded?: boolean;
/** False makes the view read-only. Standalone passes page.permissions.canEdit;
* embedded ANDs that with the host editor's editability. */
editable?: boolean;
titleSlot?: React.ReactNode;
};
export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) {
const { t } = useTranslation();
// Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } =
useBaseQuery(pageId);
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId });
const handleExpand = useCallback(() => {
if (!page) return;
navigate(buildPageUrl(page.space?.slug, page.slugId, page.title));
}, [navigate, page]);
// Share URL for a specific view; always points at the standalone page where ?view= is honored.
const getViewShareUrl = useCallback(
(viewId: string) =>
page
? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}`
: null,
[page],
);
const [activeViewId, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const [, setEditingCell] = useAtom(
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[base?.views],
);
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const { data: currentUser } = useCurrentUser();
useHydrateCurrentUser(pageId);
const {
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
pageId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Baseline merged with local draft. Used for table state and toolbar badge counts.
// The real activeView remains the auto-persist baseline so drafts can't leak into layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
const canSave = editable;
// Gate on base to avoid a "bland" list request before the active view's
// config resolves, which would double network traffic for sorted/filtered views.
const isKanban = activeView?.type === "kanban";
const {
data: rowsData,
isLoading: rowsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
// Deep link: apply ?view=<id> once after views load; skip if the id is
// unrecognised so we fall back to the default without fighting a later tab switch.
const appliedViewParamRef = useRef(false);
useEffect(() => {
if (appliedViewParamRef.current || views.length === 0) return;
const viewParam = new URLSearchParams(window.location.search).get("view");
if (viewParam && views.some((v) => v.id === viewParam)) {
setActiveViewId(viewParam);
}
appliedViewParamRef.current = true;
}, [views, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// With an active sort the server returns rows in sort order via keyset
// pagination; re-sorting by position on the client would break it as more
// pages load. Position sort only applies when no view sort is active.
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const rowsRef = useRef(rows);
rowsRef.current = rows;
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView);
const guardedPersistViewConfig = useCallback(() => {
if (!editable) return;
persistViewConfig();
}, [editable, persistViewConfig]);
// Mutation result objects change identity every render; only .mutate is
// stable. Rows are memoized on these callbacks' identities, so they must
// not churn with unrelated re-renders.
const updateRow = updateRowMutation.mutate;
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
if (!editable) return;
updateRow({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[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 });
}
},
},
);
}, [editable, pageId, createRowMutation, table, setEditingCell]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder;
const startIndex = order.indexOf(columnId);
if (startIndex === -1 || startIndex === finishIndex) return;
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
guardedPersistViewConfig();
},
[table, guardedPersistViewConfig],
);
const handleResizeEnd = useCallback(() => {
guardedPersistViewConfig();
}, [guardedPersistViewConfig]);
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[] | undefined) => {
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
},
[setDraftSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => {
setDraftFilter(filter);
},
[setDraftFilter],
);
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
// Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
pageId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const { openRowId, openRow, closeRow } = useRowDetailModal(pageId);
// openRow's identity tracks searchParams; rows subscribe to the expand
// context, so hand them a stable wrapper instead.
const openRowRef = useRef(openRow);
openRowRef.current = openRow;
const handleExpandRow = useCallback((rowId: string) => {
openRowRef.current(rowId);
}, []);
const handleRowNavigate = useCallback((rowId: string) => {
openRowRef.current(rowId, { replace: true });
}, []);
const reorderRow = reorderRowMutation.mutate;
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
if (!editable) return;
const remainingRows = rowsRef.current.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos =
targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos =
targetIndex < remainingRows.length - 1
? remainingRows[targetIndex + 1]?.position
: null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRow({ rowId, pageId, position: newPosition });
} catch {
// Position computation failed; skip silently.
}
},
[editable, pageId, reorderRow],
);
if (baseLoading || (!isKanban && rowsLoading)) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
// Ghost rows are an "empty database" affordance, not a "filter matched nothing" state.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
canAddView={editable}
onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
onExpand={embedded ? handleExpand : undefined}
getViewShareUrl={getViewShareUrl}
/>
);
const kanbanBand = (
<div className={kanbanClasses.bandWrap}>
{embedded ? null : titleSlot}
{banner}
{toolbar}
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
</div>
);
const viewRenderer = (folded: React.ReactNode) => (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
editable={editable}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
kanbanFilter={activeFilter}
aboveBand={folded}
/>
);
if (embedded) {
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Banner and toolbar go into aboveBand so they scroll with the host document;
// only the column-header row stays pinned (via --sticky-band-top).
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>,
)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<div className={kanbanClasses.standalone}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned.
return (
<BaseEditableProvider editable={editable}>
<div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{titleSlot}
{banner}
{toolbar}
</>,
)}
</RowExpandProvider>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
@@ -1,100 +0,0 @@
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function computeVisibleBadgeCount(
itemWidths: number[],
gap: number,
available: number,
badgeWidth: number,
): number {
const count = itemWidths.length;
if (count === 0) return 0;
if (available <= 0) return count;
let lineWidth = 0;
for (let i = 0; i < count; i++) {
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
}
if (lineWidth <= available) return count;
let used = 0;
let fit = 0;
for (let i = 0; i < count; i++) {
const advance = itemWidths[i] + (i > 0 ? gap : 0);
if (used + advance + gap + badgeWidth <= available) {
used += advance;
fit = i + 1;
} else {
break;
}
}
return Math.max(fit, 1);
}
const BADGE_GAP = 4;
type BadgeOverflowListProps = {
chips: ReactElement[];
measureKey: string;
tooltipLabel?: string;
};
export function BadgeOverflowList({
chips,
measureKey,
tooltipLabel,
}: BadgeOverflowListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(chips.length);
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const recompute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
setVisibleCount(
computeVisibleBadgeCount(
chipWidths,
BADGE_GAP,
container.clientWidth,
badgeWidth,
),
);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(container);
return () => observer.disconnect();
}, [measureKey]);
const visible = chips.slice(0, visibleCount);
const overflow = chips.length - visibleCount;
return (
<Tooltip
label={tooltipLabel}
multiline
withinPortal
openDelay={400}
disabled={!tooltipLabel || overflow <= 0}
>
<div className={cellClasses.badgeGroup} ref={containerRef}>
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
{chips}
<span className={cellClasses.overflowCount}>+{chips.length}</span>
</div>
{visible}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
</Tooltip>
);
}
@@ -1,44 +0,0 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
if (readOnly) return;
onCommit(!checked);
}, [readOnly, checked, onCommit]);
return (
<div
className={cellClasses.checkboxCell}
onClick={handleChange}
style={readOnly ? { cursor: "default" } : undefined}
>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{
input: {
cursor: readOnly ? "default" : "pointer",
pointerEvents: "none",
},
}}
/>
</div>
);
}
@@ -1,22 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -1,146 +0,0 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -1,52 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -1,235 +0,0 @@
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { uploadFile } from "@/features/page/services/page-service";
import { getFileUrl } from "@/lib/config";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
url?: string;
};
function buildFileUrl(file: Pick<FileValue, "id" | "fileName" | "url">): string {
return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`;
}
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
readOnly,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
if (readOnly) return;
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[readOnly, files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
// Reuse the page-attachment upload pipeline: the base's pageId is passed
// to the standard /files/upload endpoint, which enforces the same edit
// access check as any other page attachment.
for (const file of Array.from(fileList)) {
try {
const attachment = await uploadFile(file, property.pageId);
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.pageId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{!readOnly && files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div key={file.id} className={cellClasses.fileItemRow}>
<IconFile size={14} className={cellClasses.fileItemIcon} />
<a
href={getFileUrl(buildFileUrl(file))}
target="_blank"
rel="noreferrer"
className={cellClasses.fileItemLink}
>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</a>
{!readOnly && (
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
)}
</div>
))}
{!readOnly && (
<>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
<UnstyledButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={cellClasses.fileUploadBtn}
style={{
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
}
function FileList({
files,
maxVisible,
}: {
files: FileValue[];
maxVisible: number;
}) {
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
<IconPaperclip size={12} />
{file.fileName}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -1,38 +0,0 @@
import { Badge, Tooltip } from "@mantine/core";
import {
IBaseProperty,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
import { CellNumber } from "./cell-number";
import { CellCheckbox } from "./cell-checkbox";
import { CellDate } from "./cell-date";
type Props = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellFormula(props: Props) {
const { value, property } = props;
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`}>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
const readOnlyProps = { ...props, isEditing: false };
if (resultType === "number") return <CellNumber {...readOnlyProps} />;
if (resultType === "boolean") return <CellCheckbox {...readOnlyProps} />;
if (resultType === "date") return <CellDate {...readOnlyProps} />;
return <CellText {...readOnlyProps} />;
}
@@ -1,22 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -1,41 +0,0 @@
import { Group, Tooltip } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useReferenceStore } from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedByProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedBy({ value, property }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(property.pageId);
const user = userId ? store.users[userId] ?? null : null;
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<span className={cellClasses.lastEditedByName}>{name}</span>
</Tooltip>
</Group>
);
}
@@ -1,145 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLongTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function CellLongText({
value,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Seed draft and focus on the false->true editing transition only; ignore
// value changes mid-edit so the user's typing is not clobbered.
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
cancelledRef.current = false;
committedRef.current = false;
setDraft(toText(value));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
// Autosave after a typing pause; commit/cancel clear the pending fire so
// a closed editor can never write a stale or discarded draft.
const debouncedAutosave = useDebouncedCallback(() => {
onValueChange(normalize(draft));
}, 10_000);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
debouncedAutosave.cancel();
onCommit(normalize(draft));
};
const cancel = () => {
cancelledRef.current = true;
debouncedAutosave.cancel();
onCancel();
};
const preview = formatLongTextPreview(toText(value));
return (
<Popover
opened={isEditing}
onChange={(opened) => {
if (opened) return;
// Programmatic close after cancel must not re-commit.
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
commit();
}}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={cellClasses.popoverTargetFlex}>
{preview ? (
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
<span className={cellClasses.longTextPreview}>{preview}</span>
</Tooltip>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown
p={4}
onClick={(e) => e.stopPropagation()}
className={cellClasses.longTextDropdown}
>
{isEditing && (
<>
<Group justify="flex-end" mb={2}>
<CloseButton size="sm" onClick={commit} aria-label="Close" />
</Group>
<Textarea
ref={textareaRef}
data-autofocus
autosize
minRows={3}
maxRows={12}
maxLength={25000}
variant="unstyled"
value={draft}
onChange={(e) => {
setDraft(e.currentTarget.value);
debouncedAutosave();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();
}
}}
styles={{ input: { padding: 4 } }}
/>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,289 +0,0 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import { Popover, TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
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 cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellMultiSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onValueChange,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedSet = new Set(selectedIds);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = (
search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices
).filter((c) => !selectedSet.has(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onValueChange(newIds);
},
[selectedIds, selectedSet, onValueChange],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onValueChange([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onValueChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<BadgeList choices={selectedChoices} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
{selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
handleToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (selectedChoices.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return (
<BadgeList
choices={selectedChoices}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function BadgeList({
choices,
tooltipLabel,
}: {
choices: Choice[];
tooltipLabel?: string;
}) {
const chips = choices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={choices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={tooltipLabel}
/>
);
}
@@ -1,155 +0,0 @@
import {
IBaseProperty,
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";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
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;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "currency":
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
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,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
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}
/>
);
}
const numValue = typeof value === "number" ? value : null;
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={cellClasses.numberValue}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{formatNumber(numValue, typeOptions)}
</AutoTooltipText>
);
}
@@ -1,338 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { IconX, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useResolvePage } from "@/ee/base/reference/reference-store";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { searchSuggestions } from "@/features/search/services/search-service";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type PageSuggestion = {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space?: { id: string; slug: string; name: string } | null;
};
function parsePageId(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
return null;
}
function parsePastedPageSlugId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
let path = trimmed;
if (/^https?:\/\//i.test(trimmed)) {
try {
path = new URL(trimmed).pathname;
} catch {
return null;
}
}
const match = path.match(/\/p\/([^/?#]+)/);
if (!match) return null;
return extractPageSlugId(match[1]) ?? null;
}
export function CellPage({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPageProps) {
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.pageId);
const resolvedPage = useResolvePage(property.pageId, pageId);
if (isEditing) {
return (
<PagePicker
pageId={pageId}
resolvedPage={resolvedPage ?? null}
spaceId={base?.spaceId}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
if (!pageId) {
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === undefined) {
// placeholder to avoid "Page not found" flicker on initial load
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
return <PagePill page={resolvedPage} />;
}
type PillPage = {
slugId: string;
title: string | null;
icon: string | null;
space: { slug: string } | null;
};
function PagePill({ page }: { page: PillPage }) {
const { t } = useTranslation();
const title = getPageTitle(page.title, undefined, t);
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{page.icon ? (
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
type PagePickerProps = {
pageId: string | null;
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
spaceId?: string;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function PagePicker({
pageId,
resolvedPage,
spaceId,
onCommit,
onCancel,
}: PagePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const trimmed = debouncedSearch.trim();
const pastedSlugId = useMemo(
() => parsePastedPageSlugId(debouncedSearch),
[debouncedSearch],
);
const { data: suggestions = [] } = useQuery({
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
queryFn: async () => {
const res = await searchSuggestions({
query: trimmed,
includePages: true,
spaceId,
limit: trimmed ? 25 : 5,
});
return (res.pages ?? []) as PageSuggestion[];
},
enabled: !pastedSlugId,
staleTime: 15_000,
});
// Once the pasted link resolves via slugId lookup, commit and close.
const { data: linkedPage, isFetching: resolvingLink } = usePageQuery(
pastedSlugId ? { pageId: pastedSlugId } : {},
);
const linkedRef = useRef(false);
useEffect(() => {
if (!pastedSlugId) {
linkedRef.current = false;
return;
}
if (linkedPage && !linkedRef.current) {
linkedRef.current = true;
onCommit(linkedPage.id);
}
}, [pastedSlugId, linkedPage, onCommit]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(suggestions.length, [debouncedSearch]);
const handleSelect = useCallback(
(id: string) => {
onCommit(id === pageId ? null : id);
},
[pageId, onCommit],
);
const handleRemove = useCallback(() => {
onCommit(null);
}, [onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= suggestions.length) return;
e.preventDefault();
handleSelect(suggestions[activeIndex].id);
}
},
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
);
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={320}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{pageId && resolvedPage && (
<span className={cellClasses.personTag}>
{resolvedPage.icon ? (
<span>{resolvedPage.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<span className={cellClasses.personTagName}>
{getPageTitle(resolvedPage.title, undefined, t)}
</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
>
<IconX size={10} />
</button>
</span>
)}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={pageId ? "" : "Search for a page..."}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{pastedSlugId ? (
<div className={cellClasses.personDropdownHint}>
{resolvingLink || linkedPage ? "Linking page…" : "Page not found"}
</div>
) : (
<>
{suggestions.length === 0 && (
<div className={cellClasses.personDropdownHint}>
{trimmed ? "No pages found" : "No pages yet"}
</div>
)}
{suggestions.map((page, idx) => {
const isSelected = page.id === pageId;
return (
<div
key={page.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<div className={cellClasses.pageOptionText}>
<span className={cellClasses.personOptionName}>
{getPageTitle(page.title, undefined, t)}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" truncate>
{page.space.name}
</Text>
)}
</div>
</div>
);
})}
</>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,246 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
} from "@/ee/base/types/base.types";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple === true;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const store = useReferenceStore(property.pageId);
const hydrateUsers = useHydrateUsers(property.pageId);
const suggestions = usePersonSearch(search, isEditing);
// In multi mode omit already-selected from the list (they appear as tags above).
// Single mode keeps the selected row visible so it can be deselected.
const filteredMembers = allowMultiple
? suggestions.filter((m) => !selectedSet.has(m.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
const picked = suggestions.find((s) => s.id === memberId);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (allowMultiple) {
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onValueChange([...personIds, memberId]);
}
} else {
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[suggestions, hydrateUsers, allowMultiple, personIds, onCommit, onValueChange],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit, onValueChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<PersonReadList personIds={personIds} users={store.users} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = store.users[id];
const name = member?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={personIds.length === 0 ? "Search for a person..." : ""}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
{allowMultiple && (
<div className={cellClasses.personDropdownHint}>
Select as many as you like
</div>
)}
<div className={cellClasses.selectDropdown}>
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
return (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
);
})}
{filteredMembers.length === 0 && (
<div className={cellClasses.personDropdownHint}>
No members found
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <PersonReadList personIds={personIds} users={store.users} />;
}
@@ -1,239 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
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 cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit(newChoice.id);
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -1,202 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import cellClasses from "@/ee/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
type CellStatusProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type CategoryGroup = {
label: string;
choices: Choice[];
};
const categoryLabels: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
export function CellStatus({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellStatusProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const groups = useMemo(() => {
const filtered = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const grouped: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(choice);
}
const result: CategoryGroup[] = [];
for (const key of ["todo", "inProgress", "complete"]) {
if (grouped[key]?.length) {
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
}
}
return result;
}, [choices, search]);
const flatChoices = useMemo(
() => groups.flatMap((g) => g.choices),
[groups],
);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(flatChoices.length, [search, isEditing]);
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[activeIndex]);
}
},
[onCancel, handleNavKey, activeIndex, flatChoices, handleSelect],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label}>
<div className={cellClasses.selectCategoryLabel}>
{group.label}
</div>
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -1,51 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
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";
import gridClasses from "@/ee/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
maxLength={1000}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={gridClasses.cellContent}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{displayValue}
</AutoTooltipText>
);
}
@@ -1,61 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { sanitizeUrl } from "@docmost/editor-ext";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return <span>{displayValue}</span>;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -1,30 +0,0 @@
function isEmpty(v: unknown): boolean {
return v === null || v === undefined || v === "";
}
/**
* Deep equality for cell values. Treats null/undefined/"" as equivalent
* "empty", compares arrays by ordered content, objects by serialized form.
* Note: an empty array [] is a real value, distinct from "empty".
*/
export function cellValuesEqual(a: unknown, b: unknown): boolean {
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (!aArr && !bArr) {
if (isEmpty(a) && isEmpty(b)) return true;
if (isEmpty(a) !== isEmpty(b)) return false;
}
if (aArr || bArr) {
if (!aArr || !bArr) return false;
if (a.length !== b.length) return false;
return a.every((x, i) => cellValuesEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object" && a && b) {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
@@ -1,29 +0,0 @@
import { CSSProperties, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
type ChoiceBadgeProps = {
name: string;
style: CSSProperties;
};
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
return (
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
<span
ref={ref}
className={cellClasses.badge}
style={style}
onMouseEnter={() => {
const el = ref.current;
if (el) setTruncated(el.scrollWidth > el.clientWidth);
}}
>
{name}
</span>
</Tooltip>
);
}
@@ -1,25 +0,0 @@
import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#bf2020", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1864ab", textDark: "#74c0fc" },
cyan: { bg: "#c5f6fa", bgDark: "#1a3a3a", text: "#0b7285", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#066649", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#1b5e20", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#44700a", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#8a5200", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#a63508", textDark: "#ffa94d" },
};
export function choiceColor(color: string): CSSProperties {
const c = colorMap[color] ?? colorMap.gray;
return {
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
color: `light-dark(${c.text}, ${c.textDark})`,
};
}
@@ -1,258 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} 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 { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORY_LABELS: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type ChoiceGroup = { label: string | null; choices: Choice[] };
type ChoicePickerProps = {
property: IBaseProperty;
selectedIds: string[];
/** Multi keeps the picker open, hides picked options from the list and
* shows them as removable tags instead. */
multiple?: boolean;
/** Group options under status category headings. */
grouped?: boolean;
/** Offer "Add option: <search>" when the search has no exact match. */
allowCreate?: boolean;
onToggle: (choice: Choice) => void;
onEscape: () => void;
};
/** Searchable choice list shared by select-like editors (modal fields; the
* grid cells render the same UI and can migrate here). */
export function ChoicePicker({
property,
selectedIds,
multiple = false,
grouped = false,
allowCreate = false,
onToggle,
onEscape,
}: ChoicePickerProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const groups = useMemo<ChoiceGroup[]>(() => {
const filtered = (
search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices
).filter((c) => !multiple || !selectedSet.has(c.id));
if (!grouped) return [{ label: null, choices: filtered }];
const byCategory: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
(byCategory[cat] ??= []).push(choice);
}
return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map(
(key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }),
);
}, [choices, search, grouped, multiple, selectedSet]);
const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[flatChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onToggle(newChoice);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") onToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption],
);
const addOptionIdx = flatChoices.length;
return (
<>
{multiple && selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
onToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label ?? "all"}>
{group.label && (
<div className={cellClasses.selectCategoryLabel}>{group.label}</div>
)}
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = !multiple && selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => onToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span className={cellClasses.badge} style={choiceColor(addOptionColor)}>
{trimmedSearch}
</span>
</div>
)}
</div>
</>
);
}
@@ -1,40 +0,0 @@
import clsx from "clsx";
import { UserRef } from "@/ee/base/types/base.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
type PersonReadListProps = {
personIds: string[];
users: Record<string, UserRef>;
};
export function PersonReadList({ personIds, users }: PersonReadListProps) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -1,189 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Divider,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconMathFunction,
IconPointFilled,
} from "@tabler/icons-react";
import { registry } from "@docmost/base-formula/client";
import { FormulaInput } from "./formula-input";
import { PropertyChipRow } from "./property-chip-row";
import { FunctionPalette } from "./function-palette";
import { useFormulaParser } from "@/ee/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
type Props = {
properties: IBaseProperty[];
editingPropertyId: string | null;
initialSource?: string;
name?: string;
disabled?: boolean;
onSave: (
source: string,
ast: unknown,
resultType: string,
dependencies: string[],
) => void;
onCancel: () => void;
};
export function FormulaEditor({
properties,
editingPropertyId,
initialSource = "",
name,
disabled = false,
onSave,
onCancel,
}: Props) {
const [source, setSource] = useState(initialSource);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingCursorRef = useRef<number | null>(null);
const parseState = useFormulaParser(
source,
properties,
editingPropertyId,
registry,
);
const canSave = parseState.state === "ok" && !disabled;
// useEffect (not RAF) ensures the DOM update ran before restoring cursor.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(pos, pos);
}, [source]);
const insertAtCursor = (snippet: string, cursorOffsetFromEnd = 0) => {
const ta = textareaRef.current;
const start = ta?.selectionStart ?? source.length;
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
const next = before + prefix + snippet + after;
pendingCursorRef.current =
before.length + prefix.length + snippet.length - cursorOffsetFromEnd;
setSource(next);
};
return (
<Paper
withBorder
radius="md"
shadow="sm"
p={0}
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={12}
className={classes.formulaHeaderRow}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<div className={classes.formulaIconBadge}>
<IconMathFunction size={14} />
</div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed" truncate>
· {name}
</Text>
)}
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Button variant="subtle" size="xs" onClick={onCancel}>
Cancel
</Button>
<Button
size="xs"
disabled={!canSave}
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}}
>
Save
</Button>
</Group>
</Group>
<Stack gap={6} px={14} pt={10} pb={8}>
<FormulaInput
ref={textareaRef}
value={source}
onChange={setSource}
hasError={parseState.state === "error"}
/>
<Group justify="space-between" gap={8} mih={16}>
{parseState.state === "error" ? (
<Group gap={6} c="red.7">
<IconAlertTriangle size={12} />
<Text size="xs">{parseState.message}</Text>
</Group>
) : parseState.state === "ok" ? (
<Group gap={6} c="dimmed">
<IconPointFilled size={10} color="var(--mantine-color-teal-6)" />
<Text size="xs">
Returns{" "}
<Text span fw={600} c="gray.8">
{parseState.resultType}
</Text>
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Click a property or function below to insert.
</Text>
)}
</Group>
</Stack>
<Divider />
<Stack gap={8} px={14} pt={10} pb={10}>
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtCursor(`prop("${name}")`)}
/>
</Stack>
<Divider />
<Stack gap={6} px={14} pt={10} pb={10}>
<Text size="xs" fw={600} c="gray.7">
Functions
</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -1,40 +0,0 @@
import { forwardRef } from "react";
import { Textarea } from "@mantine/core";
type Props = {
value: string;
onChange: (v: string) => void;
hasError?: boolean;
};
export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
function FormulaInput({ value, onChange, hasError }, ref) {
return (
<Textarea
ref={ref}
autosize
minRows={3}
maxRows={8}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder='prop("Price") * prop("Qty")'
styles={{
input: {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 13,
lineHeight: 1.65,
backgroundColor: "var(--mantine-color-gray-0)",
borderColor: hasError
? "var(--mantine-color-red-6)"
: "var(--mantine-color-blue-6)",
borderWidth: 1.5,
boxShadow: hasError
? "0 0 0 3px var(--mantine-color-red-1)"
: "0 0 0 3px var(--mantine-color-blue-1)",
},
}}
/>
);
},
);
@@ -1,48 +0,0 @@
import { FormulaEditor } from "./formula-editor";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import {
IBaseProperty,
FormulaTypeOptions,
TypeOptions,
} from "@/ee/base/types/base.types";
type Props = {
property: IBaseProperty;
pageId: string;
onClose: () => void;
};
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
const { data: base } = useBaseQuery(pageId);
const updatePropertyMutation = useUpdatePropertyMutation();
const opts = property.typeOptions as FormulaTypeOptions | undefined;
return (
<FormulaEditor
properties={base?.properties ?? []}
editingPropertyId={property.id}
initialSource={opts?.source ?? ""}
name={property.name}
onCancel={onClose}
onSave={(source, ast, resultType, dependencies) => {
if (source === (opts?.source ?? "")) {
onClose();
return;
}
updatePropertyMutation.mutate({
propertyId: property.id,
pageId,
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
});
onClose();
}}
/>
);
}
@@ -1,83 +0,0 @@
import { useState } from "react";
import {
Accordion,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client";
import classes from "@/ee/base/styles/formula.module.css";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
export function FunctionPalette({
registry,
onInsert,
}: {
registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState<string | null>("logic");
const byCat = new Map<string, FormulaFn[]>();
for (const fn of registry.values()) {
if (!byCat.has(fn.category)) byCat.set(fn.category, []);
byCat.get(fn.category)!.push(fn);
}
return (
<Accordion
value={open}
onChange={setOpen}
variant="contained"
radius="md"
chevronSize={14}
styles={{
item: { borderColor: "var(--mantine-color-gray-2)" },
control: { padding: "7px 12px", minHeight: 0 },
label: {
padding: 0,
fontSize: 13,
fontWeight: 600,
textTransform: "capitalize",
},
content: { padding: "6px 10px 10px" },
panel: { background: "var(--mantine-color-gray-0)" },
}}
>
{CATEGORIES.map((cat) => {
const fns = byCat.get(cat) ?? [];
return (
<Accordion.Item key={cat} value={cat}>
<Accordion.Control>
<Group gap={8}>
<span>{cat}</span>
<Text size="xs" c="dimmed" ff="monospace">
{fns.length}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Group gap={6}>
{fns.map((fn) => (
<Tooltip key={fn.name} label={fn.doc} withArrow>
<UnstyledButton
onClick={() => onInsert(fn.name)}
className={classes.fnChip}
>
{fn.name}
<span className={classes.fnChipParens}>
()
</span>
</UnstyledButton>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
@@ -1,56 +0,0 @@
import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
export function PropertyChipRow({
properties,
onInsert,
}: {
properties: IBaseProperty[];
onInsert: (name: string) => void;
}) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return properties;
return properties.filter((p) => p.name.toLowerCase().includes(q));
}, [properties, query]);
return (
<div>
<Group justify="space-between" mb={8}>
<Text size="xs" fw={600} c="gray.7">
Properties
</Text>
<TextInput
size="xs"
placeholder="Search"
leftSection={<IconSearch size={12} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
w={140}
/>
</Group>
{visible.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
No matches.
</Text>
) : (
<Group gap={6}>
{visible.map((p) => (
<UnstyledButton
key={p.id}
onClick={() => onInsert(p.name)}
className={classes.propChip}
>
{p.name}
</UnstyledButton>
))}
</Group>
)}
</div>
);
}
@@ -1,26 +0,0 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css";
type AddRowButtonProps = {
onClick?: () => void;
};
export const AddRowButton = memo(function AddRowButton({
onClick,
}: AddRowButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addRowButton}
onClick={onClick}
role="button"
tabIndex={0}
>
<IconPlus size={14} />
<span>{t("New row")}</span>
</div>
);
});
@@ -1,10 +0,0 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import classes from "@/ee/base/styles/grid.module.css";
type Props = {
edge: Edge;
};
export function BaseDropEdgeIndicator({ edge }: Props) {
return <div className={classes.dropEdgeLine} data-edge={edge} aria-hidden />;
}
@@ -1,225 +0,0 @@
import { memo, useCallback } from "react";
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 {
editingCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import {
isSystemPropertyType,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/ee/base/styles/grid.module.css";
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
pageId: string;
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
const property = cell.column.columnDef.meta?.property;
const isRowNumber = cell.column.id === "__row_number";
const isPinned = cell.column.getIsPinned();
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const { t } = useTranslation();
const editable = useBaseEditable();
const readOnly = !editable;
const onExpandRow = useRowExpand();
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleDoubleClick = 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 });
}
return;
}
if (property.type === "formula") {
setActiveFormulaEditor({ propertyId: property.id, rowId });
return;
}
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const handleValueChange = useCallback(
(value: unknown) => {
if (!property) return;
if (!cellValuesEqual(value, cell.getValue())) {
onCellUpdate(rowId, property.id, value);
}
},
[property, rowId, cell, onCellUpdate],
);
const handleCommit = useCallback(
(value: unknown) => {
handleValueChange(value);
setEditingCell(null);
},
[handleValueChange, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
pageId={pageId}
/>
);
}
if (!property) return null;
const CellComponent = getDescriptor(property.type)?.cellComponent;
if (!CellComponent) return null;
const value = cell.getValue();
const cellInner = (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
>
<IconArrowsDiagonal size={13} />
</button>
</Tooltip>
</span>
)}
</div>
);
if (property.type !== "formula") return cellInner;
const formulaEditorOpen =
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === rowId;
return (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>{cellInner}</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
);
},
gridCellPropsEqual);
// Cell instances are re-created whenever the table data identity changes;
// compare by coordinates + value so unchanged cells skip re-rendering.
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
return false;
}
if (prev.cell === next.cell) return true;
return (
prev.cell.row.id === next.cell.row.id &&
prev.cell.column.id === next.cell.column.id &&
prev.cell.column.columnDef.meta?.property ===
next.cell.column.columnDef.meta?.property &&
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
);
}
@@ -1,391 +0,0 @@
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
observeWindowOffset,
observeWindowRect,
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 { 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";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
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 { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
// var(--base-row-height) from this constant so the rendered height can
// never drift from the virtualizer estimate.
const ROW_HEIGHT = 37;
const OVERSCAN = 25;
const GRID_ROOT_STYLE = {
"--base-row-height": `${ROW_HEIGHT}px`,
} as React.CSSProperties;
const ADD_COLUMN_TRACK_WIDTH = 40;
// Hoisted to module scope to avoid allocating a fresh options object on
// every GridContainer render. The function refs from virtual-core are
// stable; only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/** true when a view filter with at least one condition is active; suppresses ghost rows */
isFiltered?: boolean;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
* with the content while only the column-header row stays pinned. In
* inline mode BaseTable injects banner + toolbar here; standalone passes
* null (they render outside the scrollport instead).
*/
aboveBand?: React.ReactNode;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
pageId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
isFiltered,
scrollElement,
aboveBand,
}: GridContainerProps) {
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const rowsContainerRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
useGridAutoScroll(bodyRef, pageId);
useRowAutoScroll(scrollElement, pageId);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const rowIdsRef = useRef(rowIds);
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
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 { deleteSelected } = useDeleteSelectedRows(pageId);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
// This handler's sole job is to commit an inline input editor
// (text/number/url/email) when the user clicks elsewhere, since clicking
// a non-focusable cell does not natively blur the input. Gating on
// editingCell also stops it from stealing focus from unrelated inputs.
if (!editingCellRef.current) return;
const target = e.target as HTMLElement;
if (target.closest(`.${classes.headerCell}`)) return;
if (target.closest("[role=\"dialog\"]")) return;
if (target.closest("[role=\"listbox\"]")) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
if (target.closest(`.${classes.cellEditing}`)) return;
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
// which also clears editingCell. No setEditingCell(null) needed here.
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body && typeof active.blur === "function") {
active.blur();
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, []);
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
// have. Swap in the Window-mode observers so the virtualizer reads
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
// upcast on getScrollElement; virtual-core's runtime accepts Window when
// the observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Rows are positioned inside .rowsContainer, which sits below the sticky
// band (and aboveBand content) within the scroll content. scrollMargin =
// the container's offset from the scroll content top, in both modes, so
// virtual indexing lines up with what is actually on screen.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const el = rowsContainerRef.current;
if (!el || !scrollElement) return;
const update = () => {
const rect = el.getBoundingClientRect();
if (isWindowScroll) {
setScrollMargin(rect.top + window.scrollY);
} else {
const scrollport = scrollElement as HTMLElement;
setScrollMargin(
rect.top -
scrollport.getBoundingClientRect().top +
scrollport.scrollTop,
);
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) can move the
// grid without resizing it, so listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll, scrollElement]);
// Stable row-id keys: the direct-update element cache and measurement
// cache are keyed by item key, so index keys would go stale whenever rows
// are inserted or reordered above the viewport.
const getItemKey = useCallback(
(index: number) => rowIds[index] ?? index,
[rowIds],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
getItemKey,
directDomUpdates: true,
// 'position' (writes `top`), not 'transform': a transform on the row
// creates a containing block that breaks the position:sticky pinned
// cells inside it.
directDomUpdatesMode: "position",
...windowScrollOptions,
// virtual-core bug: on first attach _willUpdate calls
// _scrollToOffset(getScrollOffset()), which returns undefined when no
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
// mid-page. Seeding initialOffset to the current scroll position makes
// the first _scrollToOffset a no-op.
initialOffset: isWindowScroll
? () => window.scrollY
: () =>
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
});
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
useEffect(() => {
// When the row set shrinks (filter/sort/view change) or resets to zero,
// un-gate the trigger so the first page can trigger the next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [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();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return (
columnWidths.join(" ") +
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
);
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
const totalColumnsWidth = useMemo(
() =>
table
.getVisibleLeafColumns()
.reduce((sum, col) => sum + col.getSize(), 0) +
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
);
const showGhostRows = rows.length === 0 && !isFiltered;
// Append a flexible trailing track so every row spans the full width.
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
// fills remaining width otherwise. The header grid keeps the plain template.
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const getColumnOrder = useCallback(
() => table.getState().columnOrder,
[table],
);
return (
<div role="grid" style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<GridRowOrderProvider value={getOrderedRowIds}>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
} as React.CSSProperties
}
>
<div
className={classes.rowsContainer}
ref={(node) => {
rowsContainerRef.current = node;
virtualizer.containerRef(node);
}}
role="rowgroup"
style={{ width: totalColumnsWidth, minWidth: "100%" }}
>
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
measureRef={virtualizer.measureElement}
onCellUpdate={onCellUpdate}
properties={properties}
columnVisibility={table.getState().columnVisibility}
columnOrder={table.getState().columnOrder}
pageId={pageId}
onRowReorder={onRowReorder}
/>
);
})}
</div>
{showGhostRows && (
<GridGhostRows
count={3}
columnCount={table.getVisibleLeafColumns().length}
onCreate={editable ? handleAddRow : undefined}
/>
)}
{editable && <AddRowButton onClick={handleAddRow} />}
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</GridRowOrderProvider>
</div>
);
}
@@ -1,32 +0,0 @@
import classes from "@/ee/base/styles/grid.module.css";
type GridGhostRowsProps = {
/** how many placeholder rows to render */
count: number;
/** number of visible leaf columns (incl. the row-number column) */
columnCount: number;
/** create the first real row (clicking any ghost cell); omit when read-only */
onCreate?: () => void;
};
// Empty-state ghost rows shown when no data rows exist and no filter is active.
// Clicking any ghost row creates the first real row; cells align via subgrid.
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
return (
<>
{Array.from({ length: count }).map((_, rowIdx) => (
<div
key={rowIdx}
className={`${classes.row} ${classes.ghostRow}`}
role={onCreate ? "button" : undefined}
aria-label={onCreate ? "Create first row" : undefined}
onClick={onCreate}
>
{Array.from({ length: columnCount }).map((_, colIdx) => (
<div key={colIdx} className={classes.cell} aria-hidden="true" />
))}
</div>
))}
</>
);
}
@@ -1,338 +0,0 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { Badge, Popover } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import {
activePropertyMenuAtomFamily,
propertyMenuDirtyAtomFamily,
propertyMenuCloseRequestAtomFamily,
editingCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import classes from "@/ee/base/styles/grid.module.css";
export const COLUMN_DRAG_TYPE = "base-column";
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
pageId: string;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
pageId,
getColumnOrder,
onColumnReorder,
}: GridHeaderCellProps) {
const { t } = useTranslation();
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 hasSelection = selectionCount > 0;
const editable = useBaseEditable();
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const resizeIntentRef = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || !!isPinned || !editable;
// onColumnReorder ultimately depends on React Query result objects
// (activeView, base) via persistViewConfig, and their identity changes on
// every cache invalidation (every WS-driven collab refresh). Holding the
// callback in a ref keeps it out of the DnD effect's dep array so we don't
// tear down and re-register the pragmatic-dnd adapter on every header cell
// each time another user edits the base.
const onColumnReorderRef = useRef(onColumnReorder);
useLayoutEffect(() => {
onColumnReorderRef.current = onColumnReorder;
});
useEffect(() => {
const el = cellRef.current;
if (!el || isSortableDisabled) return;
return combine(
draggable({
element: el,
canDrag: () => !resizeIntentRef.current,
getInitialData: () => ({
type: COLUMN_DRAG_TYPE,
columnId: header.column.id,
pageId,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === COLUMN_DRAG_TYPE &&
source.data.columnId !== header.column.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ columnId: header.column.id },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
const order = getColumnOrder();
const startIndex = order.indexOf(source.data.columnId as string);
const indexOfTarget = order.indexOf(header.column.id);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
onColumnReorderRef.current?.(source.data.columnId as string, finishIndex);
triggerPostMoveFlash(el);
liveRegion.announce(`Moved column to position ${finishIndex + 1}`);
},
}),
);
}, [header.column.id, isSortableDisabled, getColumnOrder]);
const handleHeaderClick = useCallback(() => {
if (resizeIntentRef.current) {
resizeIntentRef.current = false;
return;
}
setEditingCell(null);
if (!editable) return;
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [editable, isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
const handleEditFormula = useCallback(() => {
if (!property) return;
handleMenuClose();
setActiveFormulaEditor({ propertyId: property.id, rowId: null });
}, [property, handleMenuClose, setActiveFormulaEditor]);
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const formulaEditorOpen =
!!property &&
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === null;
// A closed property menu can never hold unsaved changes. Saving a rename
// must clear propertyMenuDirty; otherwise it stays stuck true and
// handleHeaderClick refuses to reopen any property menu, making the menu
// appear dead after the first save. Reset only on the open-to-closed
// transition so a sibling header cell can't clear the flag while another
// column's menu is mid-edit.
const wasMenuOpenedRef = useRef(menuOpened);
useEffect(() => {
if (wasMenuOpenedRef.current && !menuOpened) {
setPropertyMenuDirty(false);
}
wasMenuOpenedRef.current = menuOpened;
}, [menuOpened, setPropertyMenuDirty]);
const handleMenuOpenChange = useCallback(
(next: boolean) => {
if (next) return; // opening is driven by the atom, not Mantine
if (propertyMenuDirty) {
// Veto the close and route through the discard-confirm flow.
setCloseRequest(closeRequest + 1);
} else {
handleMenuClose();
}
},
[propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose],
);
const TypeIcon = property ? getDescriptor(property.type)?.icon : undefined;
return (
<div
ref={cellRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber || !editable ? {} : { cursor: "pointer" }),
opacity: isDragging ? 0.4 : 1,
}}
onPointerDown={() => {
resizeIntentRef.current = false;
}}
onClick={handleHeaderClick}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{property?.pendingType && (
<Badge size="xs" color="gray" variant="light" ml={6}>
{t("Converting…")}
</Badge>
)}
</div>
)}
{editable && header.column.getCanResize() && (
<div
className={`${classes.resizeHandle} ${
header.column.getIsResizing() ? classes.resizeHandleActive : ""
}`}
onMouseDown={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onPointerDown={(e) => {
resizeIntentRef.current = true;
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{editable && property && !isRowNumber && (
<Popover
opened={menuOpened}
onChange={handleMenuOpenChange}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
trapFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
onEditFormula={
property.type === "formula" ? handleEditFormula : undefined
}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
)}
{property && !isRowNumber && property.type === "formula" && (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
)}
</div>
);
});

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