Compare commits

..
Author SHA1 Message Date
Philipinho 27c7767f86 feat: workspace default edit preference 2026-06-30 01:09:24 +01:00
202 changed files with 4250 additions and 9672 deletions
+1
View File
@@ -0,0 +1 @@
shamefully-hoist = true
+4 -12
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.23.0 RUN npm install -g pnpm@10.4.0
FROM base AS builder FROM base AS builder
@@ -18,15 +18,6 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \ && apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# drop npm and corepack
RUN rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -rf /usr/local/bin/npm \
&& rm -rf /usr/local/bin/npx \
&& rm -rf /usr/local/bin/corepack \
&& rm -rf /root/.npm \
&& rm -rf /root/.node-gyp
WORKDIR /app WORKDIR /app
# Copy apps # Copy apps
@@ -43,6 +34,7 @@ COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-f
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches # Copy patches
COPY --from=builder /app/patches /app/patches COPY --from=builder /app/patches /app/patches
@@ -51,7 +43,7 @@ RUN chown -R node:node /app
USER node USER node
RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage RUN mkdir -p /app/data/storage
+6 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.95.0", "version": "0.90.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -36,7 +36,7 @@
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3", "@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0", "alfaaz": "1.1.0",
"axios": "1.18.1", "axios": "1.16.0",
"blueimp-load-image": "5.16.0", "blueimp-load-image": "5.16.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
@@ -50,9 +50,9 @@
"katex": "0.16.40", "katex": "0.16.40",
"lowlight": "3.3.0", "lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1", "mermaid": "11.15.0",
"mitt": "3.0.1", "mitt": "3.0.1",
"nanoid": "3.3.18", "nanoid": "3.3.8",
"posthog-js": "1.391.2", "posthog-js": "1.391.2",
"react": "19.2.7", "react": "19.2.7",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.18",
@@ -61,10 +61,9 @@
"react-error-boundary": "6.1.1", "react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0", "react-helmet-async": "3.0.0",
"react-i18next": "16.5.8", "react-i18next": "16.5.8",
"react-router-dom": "7.18.2", "react-router-dom": "7.18.0",
"semver": "7.7.4", "semver": "7.7.4",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -87,7 +86,7 @@
"globals": "15.13.0", "globals": "15.13.0",
"jsdom": "25.0.0", "jsdom": "25.0.0",
"optics-ts": "2.4.1", "optics-ts": "2.4.1",
"postcss": "8.5.25", "postcss": "8.5.14",
"postcss-preset-mantine": "1.18.0", "postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1", "postcss-simple-vars": "7.0.1",
"prettier": "3.8.1", "prettier": "3.8.1",
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade Ihres Plans", "Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"AI & MCP": "KI & MCP", "AI & MCP": "KI & MCP",
"AI": "KI", "AI": "KI",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Eigenschaft erstellen", "Create property": "Eigenschaft erstellen",
"Hide properties": "Eigenschaften ausblenden", "Hide properties": "Eigenschaften ausblenden",
"Find a property type": "Einen Eigenschaftstyp finden", "Find a property type": "Einen Eigenschaftstyp finden",
"Properties": "Eigenschaften", "Properties": "Eigenschaften"
"A property with this name already exists": "Eine Eigenschaft mit diesem Namen existiert bereits",
"Add card": "Karte hinzufügen",
"Add filter": "Filter hinzufügen",
"Add option": "Option hinzufügen",
"Add sort": "Sortierung hinzufügen",
"Add view": "Ansicht hinzufügen",
"All data in this column will be lost.": "Alle Daten in dieser Spalte gehen verloren.",
"Allow multiple people": "Mehrere Personen zulassen",
"Alphabetize": "Alphabetisch sortieren",
"Are you sure you want to delete": "Sind Sie sicher, dass Sie löschen möchten",
"Ascending": "Aufsteigend",
"Base deleted": "Base gelöscht",
"Base name": "Base-Name",
"Base table": "Base-Tabelle",
"Card properties": "Karteneigenschaften",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Zellen, die das Textlimit überschreiten, werden abgeschnitten und der zusätzliche Inhalt geht dauerhaft verloren.",
"Checkbox": "Kontrollkästchen",
"Checked by default": "Standardmäßig aktiviert",
"Choose a property": "Wählen Sie eine Eigenschaft",
"Clear selection": "Auswahl aufheben",
"Column options": "Spaltenoptionen",
"Comma, period": "Komma, Punkt",
"Complete": "Abgeschlossen",
"Contains": "Enthält",
"Copy link to view": "Link zur Ansicht kopieren",
"Create a status property": "Eine Statuseigenschaft erstellen",
"Created at": "Erstellt am",
"Currency": "Währung",
"Decimal places": "Dezimalstellen",
"Default value": "Standardwert",
"Delete property": "Eigenschaft löschen",
"Delete view": "Ansicht löschen",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Absteigend",
"Discard": "Verwerfen",
"Doesn't contain": "Enthält nicht",
"Done": "Fertig",
"Edit formula": "Formel bearbeiten",
"Edit property": "Eigenschaft bearbeiten",
"Export CSV": "CSV exportieren",
"Failed to load base": "Base konnte nicht geladen werden",
"False": "Falsch",
"File": "Datei",
"Filter and sort changes are visible only to you": "Änderungen an Filtern und Sortierungen sind nur für Sie sichtbar",
"Filter by": "Filtern nach",
"Formula": "Formel",
"Get started with": "Erste Schritte mit",
"Greater than": "Größer als",
"Group by": "Gruppieren nach",
"Group this board by a select or status property.": "Gruppieren Sie dieses Board nach einer Auswahl- oder Statuseigenschaft.",
"Hide all": "Alle ausblenden",
"Hide group": "Gruppe ausblenden",
"In Progress": "In Bearbeitung",
"In progress": "In Bearbeitung",
"Include time": "Uhrzeit einschließen",
"Insert a kanban board on this page": "Fügen Sie ein Kanban-Board auf dieser Seite ein",
"Insert an inline base on this page": "Fügen Sie eine Inline-Base auf dieser Seite ein",
"Is": "Ist",
"Is after": "Ist nach",
"Is any of": "Ist eines von",
"Is before": "Ist vor",
"Is empty": "Ist leer",
"Is none of": "Ist keines von",
"Is not": "Ist nicht",
"Is not empty": "Ist nicht leer",
"Is on or after": "Ist am oder nach",
"Is on or before": "Ist am oder vor",
"Is within": "Ist innerhalb von",
"Keep editing": "Weiter bearbeiten",
"Last edited at": "Zuletzt bearbeitet am",
"Last edited by": "Zuletzt bearbeitet von",
"Less than": "Kleiner als",
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
"Local": "Lokal",
"Long text": "Langer Text",
"Moved card to {{column}}": "Karte nach {{column}} verschoben",
"Moved column to {{column}}": "Spalte nach {{column}} verschoben",
"Multi-select": "Mehrfachauswahl",
"New row": "Neue Zeile",
"Next month": "Nächster Monat",
"Next week": "Nächste Woche",
"Next year": "Nächstes Jahr",
"No filters applied": "Keine Filter angewendet",
"No match": "Keine Übereinstimmung",
"No options for this property type": "Keine Optionen für diesen Eigenschaftstyp",
"No sorts applied": "Keine Sortierungen angewendet",
"No value": "Kein Wert",
"None": "Keine",
"Not started": "Nicht begonnen",
"Number": "Zahl",
"One month ago": "Vor einem Monat",
"One month from now": "In einem Monat",
"One week ago": "Vor einer Woche",
"One week from now": "In einer Woche",
"Open as page": "Als Seite öffnen",
"Options": "Optionen",
"Past month": "Letzter Monat",
"Past week": "Letzte Woche",
"Past year": "Letztes Jahr",
"Percent": "Prozent",
"Period, comma": "Punkt, Komma",
"Person": "Person",
"Please enter a valid email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"Progress": "Fortschritt",
"Property options": "Eigenschaftsoptionen",
"Relative": "Relativ",
"Rename property": "Eigenschaft umbenennen",
"Reset": "Zurücksetzen",
"Save for everyone": "Für alle speichern",
"Select": "Auswahl",
"Select a property": "Eigenschaft auswählen",
"Select all loaded rows": "Alle geladenen Zeilen auswählen",
"Show all": "Alle anzeigen",
"Sort": "Sortieren",
"Sort by": "Sortieren nach",
"Space, comma": "Leerzeichen, Komma",
"Space, period": "Leerzeichen, Punkt",
"This board has no grouping property yet.": "Dieses Board hat noch keine Gruppierungseigenschaft.",
"This month": "Diesen Monat",
"This year": "Dieses Jahr",
"Thousands and decimal separators": "Tausender- und Dezimaltrennzeichen",
"Time format": "Zeitformat",
"To Do": "Zu erledigen",
"Tomorrow": "Morgen",
"True": "Wahr",
"Unsaved changes": "Nicht gespeicherte Änderungen",
"Untitled base": "Unbenannte Base",
"URL": "URL",
"Value": "Wert",
"View updated for everyone": "Ansicht für alle aktualisiert",
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -294,7 +294,6 @@
"Export space": "Export space", "Export space": "Export space",
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media",
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
@@ -388,8 +387,6 @@
"Insert horizontal rule divider": "Insert horizontal rule divider", "Insert horizontal rule divider": "Insert horizontal rule divider",
"Page break": "Page break", "Page break": "Page break",
"Insert a page break for printing.": "Insert a page break for printing.", "Insert a page break for printing.": "Insert a page break for printing.",
"Footnote": "Footnote",
"Insert a footnote reference.": "Insert a footnote reference.",
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.", "Upload any audio from your device.": "Upload any audio from your device.",
@@ -699,7 +696,7 @@
"Upgrade your plan": "Upgrade your plan", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.", "Upgrade your license tier.": "Upgrade your license tier.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1156,160 +1153,5 @@
"Create property": "Create property", "Create property": "Create property",
"Hide properties": "Hide properties", "Hide properties": "Hide properties",
"Find a property type": "Find a property type", "Find a property type": "Find a property type",
"Properties": "Properties", "Properties": "Properties"
"A property with this name already exists": "A property with this name already exists",
"Add card": "Add card",
"Add filter": "Add filter",
"Add option": "Add option",
"Add sort": "Add sort",
"Add view": "Add view",
"All data in this column will be lost.": "All data in this column will be lost.",
"Allow multiple people": "Allow multiple people",
"Alphabetize": "Alphabetize",
"Are you sure you want to delete": "Are you sure you want to delete",
"Ascending": "Ascending",
"Base deleted": "Base deleted",
"Base name": "Base name",
"Base table": "Base table",
"Card properties": "Card properties",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
"Checkbox": "Checkbox",
"Checked by default": "Checked by default",
"Choose a property": "Choose a property",
"Clear selection": "Clear selection",
"Column options": "Column options",
"Comma, period": "Comma, period",
"Complete": "Complete",
"Contains": "Contains",
"Copy link to view": "Copy link to view",
"Create a status property": "Create a status property",
"Created at": "Created at",
"Currency": "Currency",
"Decimal places": "Decimal places",
"Default value": "Default value",
"Delete property": "Delete property",
"Delete view": "Delete view",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descending",
"Discard": "Discard",
"Doesn't contain": "Doesn't contain",
"Done": "Done",
"Edit formula": "Edit formula",
"Edit property": "Edit property",
"Export CSV": "Export CSV",
"Failed to load base": "Failed to load base",
"False": "False",
"File": "File",
"Filter and sort changes are visible only to you": "Filter and sort changes are visible only to you",
"Filter by": "Filter by",
"Formula": "Formula",
"Get started with": "Get started with",
"Greater than": "Greater than",
"Group by": "Group by",
"Group this board by a select or status property.": "Group this board by a select or status property.",
"Hide all": "Hide all",
"Hide group": "Hide group",
"In Progress": "In Progress",
"In progress": "In progress",
"Include time": "Include time",
"Insert a kanban board on this page": "Insert a kanban board on this page",
"Insert an inline base on this page": "Insert an inline base on this page",
"Is": "Is",
"Is after": "Is after",
"Is any of": "Is any of",
"Is before": "Is before",
"Is empty": "Is empty",
"Is none of": "Is none of",
"Is not": "Is not",
"Is not empty": "Is not empty",
"Is on or after": "Is on or after",
"Is on or before": "Is on or before",
"Is within": "Is within",
"Keep editing": "Keep editing",
"Last edited at": "Last edited at",
"Last edited by": "Last edited by",
"Less than": "Less than",
"Link copied to clipboard": "Link copied to clipboard",
"Local": "Local",
"Long text": "Long text",
"Moved card to {{column}}": "Moved card to {{column}}",
"Moved column to {{column}}": "Moved column to {{column}}",
"Multi-select": "Multi-select",
"New row": "New row",
"Next month": "Next month",
"Next week": "Next week",
"Next year": "Next year",
"No filters applied": "No filters applied",
"No match": "No match",
"No options for this property type": "No options for this property type",
"No sorts applied": "No sorts applied",
"No value": "No value",
"None": "None",
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
"One month from now": "One month from now",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
"Options": "Options",
"Past month": "Past month",
"Past week": "Past week",
"Past year": "Past year",
"Percent": "Percent",
"Period, comma": "Period, comma",
"Person": "Person",
"Please enter a valid email": "Please enter a valid email",
"Progress": "Progress",
"Property options": "Property options",
"Relative": "Relative",
"Rename property": "Rename property",
"Reset": "Reset",
"Save for everyone": "Save for everyone",
"Select": "Select",
"Select a property": "Select a property",
"Select all loaded rows": "Select all loaded rows",
"Show all": "Show all",
"Sort": "Sort",
"Sort by": "Sort by",
"Space, comma": "Space, comma",
"Space, period": "Space, period",
"This board has no grouping property yet.": "This board has no grouping property yet.",
"This month": "This month",
"This year": "This year",
"Thousands and decimal separators": "Thousands and decimal separators",
"Time format": "Time format",
"To Do": "To Do",
"Tomorrow": "Tomorrow",
"True": "True",
"Unsaved changes": "Unsaved changes",
"Untitled base": "Untitled base",
"URL": "URL",
"Value": "Value",
"View updated for everyone": "View updated for everyone",
"You have unsaved changes. Do you want to discard them?": "You have unsaved changes. Do you want to discard them?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare",
"Search attachments...": "Search attachments...",
"Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Mejora tu plan", "Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago", "Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.", "Upgrade your license tier.": "Mejora el nivel de tu licencia.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"AI & MCP": "IA y MCP", "AI & MCP": "IA y MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Crear propiedad", "Create property": "Crear propiedad",
"Hide properties": "Ocultar propiedades", "Hide properties": "Ocultar propiedades",
"Find a property type": "Buscar un tipo de propiedad", "Find a property type": "Buscar un tipo de propiedad",
"Properties": "Propiedades", "Properties": "Propiedades"
"A property with this name already exists": "Ya existe una propiedad con este nombre",
"Add card": "Agregar tarjeta",
"Add filter": "Agregar filtro",
"Add option": "Agregar opción",
"Add sort": "Agregar orden",
"Add view": "Agregar vista",
"All data in this column will be lost.": "Todos los datos de esta columna se perderán.",
"Allow multiple people": "Permitir varias personas",
"Alphabetize": "Ordenar alfabéticamente",
"Are you sure you want to delete": "¿Está seguro de que desea eliminar",
"Ascending": "Ascendente",
"Base deleted": "Base eliminada",
"Base name": "Nombre de la base",
"Base table": "Tabla base",
"Card properties": "Propiedades de la tarjeta",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Las celdas más largas que el límite de texto se truncarán y el contenido adicional se perderá permanentemente.",
"Checkbox": "Casilla de verificación",
"Checked by default": "Marcado por defecto",
"Choose a property": "Seleccione una propiedad",
"Clear selection": "Borrar selección",
"Column options": "Opciones de columna",
"Comma, period": "Coma, punto",
"Complete": "Completo",
"Contains": "Contiene",
"Copy link to view": "Copiar enlace a la vista",
"Create a status property": "Crear una propiedad de estado",
"Created at": "Creado el",
"Currency": "Moneda",
"Decimal places": "Decimales",
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
"Done": "Hecho",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propiedad",
"Export CSV": "Exportar CSV",
"Failed to load base": "No se pudo cargar la base",
"False": "Falso",
"File": "Archivo",
"Filter and sort changes are visible only to you": "Los cambios de filtro y ordenación solo son visibles para usted",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Comenzar con",
"Greater than": "Mayor que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este tablero por una propiedad de selección o estado.",
"Hide all": "Ocultar todo",
"Hide group": "Ocultar grupo",
"In Progress": "En curso",
"In progress": "En curso",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Insertar un tablero kanban en esta página",
"Insert an inline base on this page": "Insertar una base integrada en esta página",
"Is": "Es",
"Is after": "Es posterior a",
"Is any of": "Es cualquiera de",
"Is before": "Es anterior a",
"Is empty": "Está vacío",
"Is none of": "No es ninguno de",
"Is not": "No es",
"Is not empty": "No está vacío",
"Is on or after": "Es en o después de",
"Is on or before": "Es en o antes de",
"Is within": "Está dentro de",
"Keep editing": "Seguir editando",
"Last edited at": "Última edición el",
"Last edited by": "Última edición por",
"Less than": "Menor que",
"Link copied to clipboard": "Enlace copiado al portapapeles",
"Local": "Local",
"Long text": "Texto largo",
"Moved card to {{column}}": "Tarjeta movida a {{column}}",
"Moved column to {{column}}": "Columna movida a {{column}}",
"Multi-select": "Selección múltiple",
"New row": "Nueva fila",
"Next month": "Próximo mes",
"Next week": "Próxima semana",
"Next year": "Próximo año",
"No filters applied": "No hay filtros aplicados",
"No match": "Sin coincidencias",
"No options for this property type": "No hay opciones para este tipo de propiedad",
"No sorts applied": "No hay ordenaciones aplicadas",
"No value": "Sin valor",
"None": "Ninguno",
"Not started": "No iniciado",
"Number": "Número",
"One month ago": "Hace un mes",
"One month from now": "Dentro de un mes",
"One week ago": "Hace una semana",
"One week from now": "Dentro de una semana",
"Open as page": "Abrir como página",
"Options": "Opciones",
"Past month": "Último mes",
"Past week": "Última semana",
"Past year": "Último año",
"Percent": "Porcentaje",
"Period, comma": "Punto, coma",
"Person": "Persona",
"Please enter a valid email": "Por favor, introduzca un correo electrónico válido",
"Progress": "Progreso",
"Property options": "Opciones de propiedad",
"Relative": "Relativo",
"Rename property": "Renombrar propiedad",
"Reset": "Restablecer",
"Save for everyone": "Guardar para todos",
"Select": "Selección",
"Select a property": "Seleccionar una propiedad",
"Select all loaded rows": "Seleccionar todas las filas cargadas",
"Show all": "Mostrar todo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espacio, coma",
"Space, period": "Espacio, punto",
"This board has no grouping property yet.": "Este tablero aún no tiene una propiedad de agrupación.",
"This month": "Este mes",
"This year": "Este año",
"Thousands and decimal separators": "Separadores de miles y decimales",
"Time format": "Formato de hora",
"To Do": "Por hacer",
"Tomorrow": "Mañana",
"True": "Verdadero",
"Unsaved changes": "Cambios no guardados",
"Untitled base": "Base sin título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Mettez à niveau votre forfait", "Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante", "Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"AI & MCP": "IA & MCP", "AI & MCP": "IA & MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Créer une propriété", "Create property": "Créer une propriété",
"Hide properties": "Masquer les propriétés", "Hide properties": "Masquer les propriétés",
"Find a property type": "Rechercher un type de propriété", "Find a property type": "Rechercher un type de propriété",
"Properties": "Propriétés", "Properties": "Propriétés"
"A property with this name already exists": "Une propriété portant ce nom existe déjà",
"Add card": "Ajouter une carte",
"Add filter": "Ajouter un filtre",
"Add option": "Ajouter une option",
"Add sort": "Ajouter un tri",
"Add view": "Ajouter une vue",
"All data in this column will be lost.": "Toutes les données de cette colonne seront perdues.",
"Allow multiple people": "Autoriser plusieurs personnes",
"Alphabetize": "Trier par ordre alphabétique",
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
"Ascending": "Croissant",
"Base deleted": "Base supprimée",
"Base name": "Nom de la base",
"Base table": "Table de base",
"Card properties": "Propriétés de la carte",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Les cellules dépassant la limite de texte seront tronquées et le contenu supplémentaire sera définitivement perdu.",
"Checkbox": "Case à cocher",
"Checked by default": "Coché par défaut",
"Choose a property": "Choisir une propriété",
"Clear selection": "Effacer la sélection",
"Column options": "Options de la colonne",
"Comma, period": "Virgule, point",
"Complete": "Terminé",
"Contains": "Contient",
"Copy link to view": "Copier le lien vers la vue",
"Create a status property": "Créer une propriété de statut",
"Created at": "Créé le",
"Currency": "Devise",
"Decimal places": "Décimales",
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
"Done": "Terminé",
"Edit formula": "Modifier la formule",
"Edit property": "Modifier la propriété",
"Export CSV": "Exporter en CSV",
"Failed to load base": "Échec du chargement de la base",
"False": "Faux",
"File": "Fichier",
"Filter and sort changes are visible only to you": "Les modifications de filtre et de tri ne sont visibles que par vous",
"Filter by": "Filtrer par",
"Formula": "Formule",
"Get started with": "Commencer avec",
"Greater than": "Supérieur à",
"Group by": "Grouper par",
"Group this board by a select or status property.": "Groupez ce tableau par une propriété de sélection ou de statut.",
"Hide all": "Tout masquer",
"Hide group": "Masquer le groupe",
"In Progress": "En cours",
"In progress": "En cours",
"Include time": "Inclure l'heure",
"Insert a kanban board on this page": "Insérer un tableau kanban sur cette page",
"Insert an inline base on this page": "Insérer une base intégrée sur cette page",
"Is": "Est",
"Is after": "Est après",
"Is any of": "Est l'un de",
"Is before": "Est avant",
"Is empty": "Est vide",
"Is none of": "N'est aucun de",
"Is not": "N'est pas",
"Is not empty": "N'est pas vide",
"Is on or after": "Est le ou après le",
"Is on or before": "Est le ou avant le",
"Is within": "Est dans la plage",
"Keep editing": "Continuer la modification",
"Last edited at": "Dernière modification le",
"Last edited by": "Dernière modification par",
"Less than": "Inférieur à",
"Link copied to clipboard": "Lien copié dans le presse-papiers",
"Local": "Local",
"Long text": "Texte long",
"Moved card to {{column}}": "Carte déplacée vers {{column}}",
"Moved column to {{column}}": "Colonne déplacée vers {{column}}",
"Multi-select": "Sélection multiple",
"New row": "Nouvelle ligne",
"Next month": "Le mois prochain",
"Next week": "La semaine prochaine",
"Next year": "L'année prochaine",
"No filters applied": "Aucun filtre appliqué",
"No match": "Aucune correspondance",
"No options for this property type": "Aucune option pour ce type de propriété",
"No sorts applied": "Aucun tri appliqué",
"No value": "Aucune valeur",
"None": "Aucun",
"Not started": "Non commencé",
"Number": "Nombre",
"One month ago": "Il y a un mois",
"One month from now": "Dans un mois",
"One week ago": "Il y a une semaine",
"One week from now": "Dans une semaine",
"Open as page": "Ouvrir comme page",
"Options": "Options",
"Past month": "Le mois dernier",
"Past week": "La semaine dernière",
"Past year": "L'année dernière",
"Percent": "Pourcentage",
"Period, comma": "Point, virgule",
"Person": "Personne",
"Please enter a valid email": "Veuillez entrer une adresse e-mail valide",
"Progress": "Progression",
"Property options": "Options de la propriété",
"Relative": "Relatif",
"Rename property": "Renommer la propriété",
"Reset": "Réinitialiser",
"Save for everyone": "Enregistrer pour tout le monde",
"Select": "Sélection",
"Select a property": "Sélectionner une propriété",
"Select all loaded rows": "Sélectionner toutes les lignes chargées",
"Show all": "Tout afficher",
"Sort": "Trier",
"Sort by": "Trier par",
"Space, comma": "Espace, virgule",
"Space, period": "Espace, point",
"This board has no grouping property yet.": "Ce tableau n'a pas encore de propriété de groupement.",
"This month": "Ce mois-ci",
"This year": "Cette année",
"Thousands and decimal separators": "Séparateurs des milliers et des décimales",
"Time format": "Format de l'heure",
"To Do": "À faire",
"Tomorrow": "Demain",
"True": "Vrai",
"Unsaved changes": "Modifications non enregistrées",
"Untitled base": "Base sans titre",
"URL": "URL",
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Aggiorna il tuo piano", "Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento", "Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"AI & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Crea proprietà", "Create property": "Crea proprietà",
"Hide properties": "Nascondi proprietà", "Hide properties": "Nascondi proprietà",
"Find a property type": "Trova un tipo di proprietà", "Find a property type": "Trova un tipo di proprietà",
"Properties": "Proprietà", "Properties": "Proprietà"
"A property with this name already exists": "Esiste già una proprietà con questo nome",
"Add card": "Aggiungi scheda",
"Add filter": "Aggiungi filtro",
"Add option": "Aggiungi opzione",
"Add sort": "Aggiungi ordinamento",
"Add view": "Aggiungi vista",
"All data in this column will be lost.": "Tutti i dati in questa colonna andranno persi.",
"Allow multiple people": "Consenti più persone",
"Alphabetize": "Ordina alfabeticamente",
"Are you sure you want to delete": "Sei sicuro di voler eliminare",
"Ascending": "Crescente",
"Base deleted": "Base eliminata",
"Base name": "Nome della base",
"Base table": "Tabella della base",
"Card properties": "Proprietà della scheda",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Le celle più lunghe del limite del testo verranno troncate e il contenuto aggiuntivo andrà perso in modo permanente.",
"Checkbox": "Casella di controllo",
"Checked by default": "Selezionata per impostazione predefinita",
"Choose a property": "Scegli una proprietà",
"Clear selection": "Cancella selezione",
"Column options": "Opzioni della colonna",
"Comma, period": "Virgola, punto",
"Complete": "Completato",
"Contains": "Contiene",
"Copy link to view": "Copia link alla vista",
"Create a status property": "Crea una proprietà di stato",
"Created at": "Creato il",
"Currency": "Valuta",
"Decimal places": "Posizioni decimali",
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
"Done": "Fatto",
"Edit formula": "Modifica formula",
"Edit property": "Modifica proprietà",
"Export CSV": "Esporta CSV",
"Failed to load base": "Impossibile caricare la base",
"False": "Falso",
"File": "File",
"Filter and sort changes are visible only to you": "Le modifiche a filtri e ordinamenti sono visibili solo a te",
"Filter by": "Filtra per",
"Formula": "Formula",
"Get started with": "Inizia con",
"Greater than": "Maggiore di",
"Group by": "Raggruppa per",
"Group this board by a select or status property.": "Raggruppa questa bacheca per una proprietà di selezione o di stato.",
"Hide all": "Nascondi tutto",
"Hide group": "Nascondi gruppo",
"In Progress": "In corso",
"In progress": "In corso",
"Include time": "Includi ora",
"Insert a kanban board on this page": "Inserisci una bacheca kanban in questa pagina",
"Insert an inline base on this page": "Inserisci una base in linea in questa pagina",
"Is": "È",
"Is after": "È dopo",
"Is any of": "È uno tra",
"Is before": "È prima",
"Is empty": "È vuoto",
"Is none of": "Non è nessuno di",
"Is not": "Non è",
"Is not empty": "Non è vuoto",
"Is on or after": "È il o dopo il",
"Is on or before": "È il o prima del",
"Is within": "È entro",
"Keep editing": "Continua a modificare",
"Last edited at": "Ultima modifica il",
"Last edited by": "Ultima modifica di",
"Less than": "Minore di",
"Link copied to clipboard": "Link copiato negli appunti",
"Local": "Locale",
"Long text": "Testo lungo",
"Moved card to {{column}}": "Scheda spostata in {{column}}",
"Moved column to {{column}}": "Colonna spostata in {{column}}",
"Multi-select": "Selezione multipla",
"New row": "Nuova riga",
"Next month": "Prossimo mese",
"Next week": "Prossima settimana",
"Next year": "Prossimo anno",
"No filters applied": "Nessun filtro applicato",
"No match": "Nessuna corrispondenza",
"No options for this property type": "Nessuna opzione per questo tipo di proprietà",
"No sorts applied": "Nessun ordinamento applicato",
"No value": "Nessun valore",
"None": "Nessuno",
"Not started": "Non iniziato",
"Number": "Numero",
"One month ago": "Un mese fa",
"One month from now": "Tra un mese",
"One week ago": "Una settimana fa",
"One week from now": "Tra una settimana",
"Open as page": "Apri come pagina",
"Options": "Opzioni",
"Past month": "Mese scorso",
"Past week": "Settimana scorsa",
"Past year": "Anno scorso",
"Percent": "Percentuale",
"Period, comma": "Punto, virgola",
"Person": "Persona",
"Please enter a valid email": "Inserisci un indirizzo email valido",
"Progress": "Avanzamento",
"Property options": "Opzioni della proprietà",
"Relative": "Relativo",
"Rename property": "Rinomina proprietà",
"Reset": "Reimposta",
"Save for everyone": "Salva per tutti",
"Select": "Seleziona",
"Select a property": "Seleziona una proprietà",
"Select all loaded rows": "Seleziona tutte le righe caricate",
"Show all": "Mostra tutto",
"Sort": "Ordina",
"Sort by": "Ordina per",
"Space, comma": "Spazio, virgola",
"Space, period": "Spazio, punto",
"This board has no grouping property yet.": "Questa bacheca non ha ancora una proprietà di raggruppamento.",
"This month": "Questo mese",
"This year": "Quest'anno",
"Thousands and decimal separators": "Separatori delle migliaia e dei decimali",
"Time format": "Formato ora",
"To Do": "Da fare",
"Tomorrow": "Domani",
"True": "Vero",
"Unsaved changes": "Modifiche non salvate",
"Untitled base": "Base senza titolo",
"URL": "URL",
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "プランをアップグレードする", "Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能", "Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"AI & MCP": "AI と MCP", "AI & MCP": "AI と MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "プロパティを作成", "Create property": "プロパティを作成",
"Hide properties": "プロパティを非表示", "Hide properties": "プロパティを非表示",
"Find a property type": "プロパティの種類を検索", "Find a property type": "プロパティの種類を検索",
"Properties": "プロパティ", "Properties": "プロパティ"
"A property with this name already exists": "この名前のプロパティはすでに存在します",
"Add card": "カードを追加",
"Add filter": "フィルターを追加",
"Add option": "オプションを追加",
"Add sort": "並べ替えを追加",
"Add view": "ビューを追加",
"All data in this column will be lost.": "この列内のすべてのデータは失われます。",
"Allow multiple people": "複数のユーザーを許可",
"Alphabetize": "アルファベット順",
"Are you sure you want to delete": "削除してもよろしいですか",
"Ascending": "昇順",
"Base deleted": "ベースを削除しました",
"Base name": "ベース名",
"Base table": "ベーステーブル",
"Card properties": "カードのプロパティ",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "テキストの制限を超える長さのセルは切り詰められ、超過分の内容は完全に失われます。",
"Checkbox": "チェックボックス",
"Checked by default": "デフォルトでチェックを入れる",
"Choose a property": "プロパティを選択",
"Clear selection": "選択をクリア",
"Column options": "列のオプション",
"Comma, period": "カンマ、小数点",
"Complete": "完了",
"Contains": "含む",
"Copy link to view": "ビューへのリンクをコピー",
"Create a status property": "ステータスプロパティを作成",
"Created at": "作成日時",
"Currency": "通貨",
"Decimal places": "小数点以下の桁数",
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
"Doesn't contain": "含まない",
"Done": "完了",
"Edit formula": "数式を編集",
"Edit property": "プロパティを編集",
"Export CSV": "CSVをエクスポート",
"Failed to load base": "ベースの読み込みに失敗しました",
"False": "偽",
"File": "ファイル",
"Filter and sort changes are visible only to you": "フィルターと並べ替えの変更は自分にのみ表示されます",
"Filter by": "条件",
"Formula": "数式",
"Get started with": "次から始める",
"Greater than": "より大きい",
"Group by": "グループ化",
"Group this board by a select or status property.": "このボードをセレクトまたはステータスのプロパティでグループ化します。",
"Hide all": "すべて非表示",
"Hide group": "グループを非表示",
"In Progress": "進行中",
"In progress": "進行中",
"Include time": "時刻を含める",
"Insert a kanban board on this page": "このページにカンバンボードを挿入",
"Insert an inline base on this page": "このページにインラインベースを挿入",
"Is": "次に等しい",
"Is after": "次より後",
"Is any of": "次のいずれか",
"Is before": "次より前",
"Is empty": "空である",
"Is none of": "次のいずれでもない",
"Is not": "次に等しくない",
"Is not empty": "空ではない",
"Is on or after": "次以降",
"Is on or before": "次以前",
"Is within": "次の期間内",
"Keep editing": "編集を続ける",
"Last edited at": "最終編集日時",
"Last edited by": "最終編集者",
"Less than": "より小さい",
"Link copied to clipboard": "リンクをクリップボードにコピーしました",
"Local": "ローカル",
"Long text": "長いテキスト",
"Moved card to {{column}}": "カードを{{column}}に移動しました",
"Moved column to {{column}}": "列を{{column}}に移動しました",
"Multi-select": "複数選択",
"New row": "新規行",
"Next month": "来月",
"Next week": "来週",
"Next year": "来年",
"No filters applied": "フィルターは適用されていません",
"No match": "一致なし",
"No options for this property type": "このプロパティタイプにはオプションがありません",
"No sorts applied": "並べ替えは適用されていません",
"No value": "値なし",
"None": "なし",
"Not started": "未着手",
"Number": "数値",
"One month ago": "1か月前",
"One month from now": "1か月後",
"One week ago": "1週間前",
"One week from now": "1週間後",
"Open as page": "ページとして開く",
"Options": "オプション",
"Past month": "先月",
"Past week": "先週",
"Past year": "昨年",
"Percent": "パーセント",
"Period, comma": "小数点、カンマ",
"Person": "ユーザー",
"Please enter a valid email": "有効なメールアドレスを入力してください",
"Progress": "進捗",
"Property options": "プロパティのオプション",
"Relative": "相対",
"Rename property": "プロパティ名を変更",
"Reset": "リセット",
"Save for everyone": "全員に保存",
"Select": "選択",
"Select a property": "プロパティを選択",
"Select all loaded rows": "読み込まれているすべての行を選択",
"Show all": "すべて表示",
"Sort": "並べ替え",
"Sort by": "並べ替え条件",
"Space, comma": "スペース、カンマ",
"Space, period": "スペース、小数点",
"This board has no grouping property yet.": "このボードにはまだグループ化プロパティがありません。",
"This month": "今月",
"This year": "今年",
"Thousands and decimal separators": "桁区切りと小数点区切り",
"Time format": "時刻形式",
"To Do": "To Do",
"Tomorrow": "明日",
"True": "真",
"Unsaved changes": "未保存の変更",
"Untitled base": "無題のベース",
"URL": "URL",
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "요금제를 업그레이드하세요", "Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.",
"AI & MCP": "AI 및 MCP", "AI & MCP": "AI 및 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "속성 만들기", "Create property": "속성 만들기",
"Hide properties": "속성 숨기기", "Hide properties": "속성 숨기기",
"Find a property type": "속성 유형 찾기", "Find a property type": "속성 유형 찾기",
"Properties": "속성", "Properties": "속성"
"A property with this name already exists": "이 이름의 속성이 이미 있습니다",
"Add card": "카드 추가",
"Add filter": "필터 추가",
"Add option": "옵션 추가",
"Add sort": "정렬 추가",
"Add view": "보기 추가",
"All data in this column will be lost.": "이 열의 모든 데이터가 손실됩니다.",
"Allow multiple people": "여러 사람 허용",
"Alphabetize": "가나다순",
"Are you sure you want to delete": "삭제하시겠습니까",
"Ascending": "오름차순",
"Base deleted": "베이스가 삭제됨",
"Base name": "베이스 이름",
"Base table": "베이스 테이블",
"Card properties": "카드 속성",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "텍스트 제한보다 긴 셀은 잘리며, 초과된 콘텐츠는 영구적으로 손실됩니다.",
"Checkbox": "체크박스",
"Checked by default": "기본적으로 선택됨",
"Choose a property": "속성 선택",
"Clear selection": "선택 지우기",
"Column options": "열 옵션",
"Comma, period": "쉼표, 마침표",
"Complete": "완료",
"Contains": "포함",
"Copy link to view": "보기 링크 복사",
"Create a status property": "상태 속성 만들기",
"Created at": "작성 시간",
"Currency": "통화",
"Decimal places": "소수 자릿수",
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
"Done": "완료",
"Edit formula": "수식 편집",
"Edit property": "속성 편집",
"Export CSV": "CSV 내보내기",
"Failed to load base": "베이스를 불러오지 못했습니다",
"False": "거짓",
"File": "파일",
"Filter and sort changes are visible only to you": "필터 및 정렬 변경 사항은 나에게만 표시됩니다",
"Filter by": "필터 기준",
"Formula": "수식",
"Get started with": "시작하기",
"Greater than": "보다 큼",
"Group by": "그룹화 기준",
"Group this board by a select or status property.": "선택 또는 상태 속성으로 이 보드를 그룹화하세요.",
"Hide all": "모두 숨기기",
"Hide group": "그룹 숨기기",
"In Progress": "진행 중",
"In progress": "진행 중",
"Include time": "시간 포함",
"Insert a kanban board on this page": "이 페이지에 칸반 보드 삽입",
"Insert an inline base on this page": "이 페이지에 인라인 베이스 삽입",
"Is": "같음",
"Is after": "이후",
"Is any of": "다음 중 하나와 같음",
"Is before": "이전",
"Is empty": "비어 있음",
"Is none of": "다음 중 어느 것과도 같지 않음",
"Is not": "같지 않음",
"Is not empty": "비어 있지 않음",
"Is on or after": "해당 날짜 또는 이후",
"Is on or before": "해당 날짜 또는 이전",
"Is within": "다음 범위 내",
"Keep editing": "계속 편집",
"Last edited at": "마지막 편집 시간",
"Last edited by": "마지막 편집자",
"Less than": "보다 작음",
"Link copied to clipboard": "링크가 클립보드에 복사됨",
"Local": "로컬",
"Long text": "긴 텍스트",
"Moved card to {{column}}": "카드를 {{column}}(으)로 이동함",
"Moved column to {{column}}": "열을 {{column}}(으)로 이동함",
"Multi-select": "다중 선택",
"New row": "새 행",
"Next month": "다음 달",
"Next week": "다음 주",
"Next year": "내년",
"No filters applied": "적용된 필터 없음",
"No match": "일치하는 항목 없음",
"No options for this property type": "이 속성 유형에는 옵션이 없습니다",
"No sorts applied": "적용된 정렬 없음",
"No value": "값 없음",
"None": "없음",
"Not started": "시작 전",
"Number": "숫자",
"One month ago": "한 달 전",
"One month from now": "한 달 후",
"One week ago": "일주일 전",
"One week from now": "일주일 후",
"Open as page": "페이지로 열기",
"Options": "옵션",
"Past month": "지난달",
"Past week": "지난주",
"Past year": "작년",
"Percent": "백분율",
"Period, comma": "마침표, 쉼표",
"Person": "사람",
"Please enter a valid email": "유효한 이메일을 입력하세요",
"Progress": "진행률",
"Property options": "속성 옵션",
"Relative": "상대값",
"Rename property": "속성 이름 변경",
"Reset": "재설정",
"Save for everyone": "모든 사용자에게 저장",
"Select": "선택",
"Select a property": "속성 선택",
"Select all loaded rows": "불러온 모든 행 선택",
"Show all": "모두 보기",
"Sort": "정렬",
"Sort by": "정렬 기준",
"Space, comma": "공백, 쉼표",
"Space, period": "공백, 마침표",
"This board has no grouping property yet.": "이 보드에는 아직 그룹화 속성이 없습니다.",
"This month": "이번 달",
"This year": "올해",
"Thousands and decimal separators": "천 단위 및 소수 구분 기호",
"Time format": "시간 형식",
"To Do": "할 일",
"Tomorrow": "내일",
"True": "참",
"Unsaved changes": "저장되지 않은 변경 사항",
"Untitled base": "제목 없는 베이스",
"URL": "URL",
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Upgrade je abonnement", "Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie", "Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.", "Upgrade your license tier.": "Upgrade je licentieniveau.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Eigenschap aanmaken", "Create property": "Eigenschap aanmaken",
"Hide properties": "Eigenschappen verbergen", "Hide properties": "Eigenschappen verbergen",
"Find a property type": "Een eigenschapstype zoeken", "Find a property type": "Een eigenschapstype zoeken",
"Properties": "Eigenschappen", "Properties": "Eigenschappen"
"A property with this name already exists": "Er bestaat al een eigenschap met deze naam",
"Add card": "Kaart toevoegen",
"Add filter": "Filter toevoegen",
"Add option": "Optie toevoegen",
"Add sort": "Sortering toevoegen",
"Add view": "Weergave toevoegen",
"All data in this column will be lost.": "Alle gegevens in deze kolom gaan verloren.",
"Allow multiple people": "Meerdere personen toestaan",
"Alphabetize": "Alfabetisch sorteren",
"Are you sure you want to delete": "Weet je zeker dat je wilt verwijderen",
"Ascending": "Oplopend",
"Base deleted": "Base verwijderd",
"Base name": "Basenaam",
"Base table": "Basistabel",
"Card properties": "Kaarteigenschappen",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cellen die langer zijn dan de tekstlimiet worden afgekapt en de extra inhoud gaat permanent verloren.",
"Checkbox": "Selectievakje",
"Checked by default": "Standaard aangevinkt",
"Choose a property": "Kies een eigenschap",
"Clear selection": "Selectie wissen",
"Column options": "Kolomopties",
"Comma, period": "Komma, punt",
"Complete": "Voltooid",
"Contains": "Bevat",
"Copy link to view": "Link naar weergave kopiëren",
"Create a status property": "Een statuseigenschap maken",
"Created at": "Aangemaakt op",
"Currency": "Valuta",
"Decimal places": "Decimalen",
"Default value": "Standaardwaarde",
"Delete property": "Eigenschap verwijderen",
"Delete view": "Weergave verwijderen",
"Delete {{count}} rows?_one": "1 rij verwijderen?",
"Delete {{count}} rows?_other": "{{count}} rijen verwijderen?",
"Descending": "Aflopend",
"Discard": "Negeren",
"Doesn't contain": "Bevat niet",
"Done": "Klaar",
"Edit formula": "Formule bewerken",
"Edit property": "Eigenschap bewerken",
"Export CSV": "CSV exporteren",
"Failed to load base": "Base laden mislukt",
"False": "Onwaar",
"File": "Bestand",
"Filter and sort changes are visible only to you": "Wijzigingen in filters en sortering zijn alleen zichtbaar voor jou",
"Filter by": "Filteren op",
"Formula": "Formule",
"Get started with": "Aan de slag met",
"Greater than": "Groter dan",
"Group by": "Groeperen op",
"Group this board by a select or status property.": "Groepeer dit bord op een selectie- of statuseigenschap.",
"Hide all": "Alles verbergen",
"Hide group": "Groep verbergen",
"In Progress": "In uitvoering",
"In progress": "In uitvoering",
"Include time": "Tijd opnemen",
"Insert a kanban board on this page": "Voeg een kanbanbord in op deze pagina",
"Insert an inline base on this page": "Voeg een inline base in op deze pagina",
"Is": "Is",
"Is after": "Is na",
"Is any of": "Is een van",
"Is before": "Is vóór",
"Is empty": "Is leeg",
"Is none of": "Is geen van",
"Is not": "Is niet",
"Is not empty": "Is niet leeg",
"Is on or after": "Is op of na",
"Is on or before": "Is op of vóór",
"Is within": "Is binnen",
"Keep editing": "Doorgaan met bewerken",
"Last edited at": "Laatst bewerkt op",
"Last edited by": "Laatst bewerkt door",
"Less than": "Kleiner dan",
"Link copied to clipboard": "Link gekopieerd naar klembord",
"Local": "Lokaal",
"Long text": "Lange tekst",
"Moved card to {{column}}": "Kaart verplaatst naar {{column}}",
"Moved column to {{column}}": "Kolom verplaatst naar {{column}}",
"Multi-select": "Meervoudige selectie",
"New row": "Nieuwe rij",
"Next month": "Volgende maand",
"Next week": "Volgende week",
"Next year": "Volgend jaar",
"No filters applied": "Geen filters toegepast",
"No match": "Geen overeenkomst",
"No options for this property type": "Geen opties voor dit eigenschapstype",
"No sorts applied": "Geen sorteringen toegepast",
"No value": "Geen waarde",
"None": "Geen",
"Not started": "Niet gestart",
"Number": "Getal",
"One month ago": "Een maand geleden",
"One month from now": "Over een maand",
"One week ago": "Een week geleden",
"One week from now": "Over een week",
"Open as page": "Openen als pagina",
"Options": "Opties",
"Past month": "Afgelopen maand",
"Past week": "Afgelopen week",
"Past year": "Afgelopen jaar",
"Percent": "Procent",
"Period, comma": "Punt, komma",
"Person": "Persoon",
"Please enter a valid email": "Voer een geldig e-mailadres in",
"Progress": "Voortgang",
"Property options": "Eigenschapopties",
"Relative": "Relatief",
"Rename property": "Eigenschap hernoemen",
"Reset": "Opnieuw instellen",
"Save for everyone": "Opslaan voor iedereen",
"Select": "Selecteren",
"Select a property": "Selecteer een eigenschap",
"Select all loaded rows": "Alle geladen rijen selecteren",
"Show all": "Alles tonen",
"Sort": "Sorteren",
"Sort by": "Sorteren op",
"Space, comma": "Spatie, komma",
"Space, period": "Spatie, punt",
"This board has no grouping property yet.": "Dit bord heeft nog geen groeperingseigenschap.",
"This month": "Deze maand",
"This year": "Dit jaar",
"Thousands and decimal separators": "Scheidingstekens voor duizendtallen en decimalen",
"Time format": "Tijdnotatie",
"To Do": "Te doen",
"Tomorrow": "Morgen",
"True": "Waar",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"Untitled base": "Base zonder titel",
"URL": "URL",
"Value": "Waarde",
"View updated for everyone": "Weergave bijgewerkt voor iedereen",
"You have unsaved changes. Do you want to discard them?": "Je hebt niet-opgeslagen wijzigingen. Wil je deze negeren?",
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Faça upgrade do seu plano", "Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga", "Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"AI & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Criar propriedade", "Create property": "Criar propriedade",
"Hide properties": "Ocultar propriedades", "Hide properties": "Ocultar propriedades",
"Find a property type": "Encontrar um tipo de propriedade", "Find a property type": "Encontrar um tipo de propriedade",
"Properties": "Propriedades", "Properties": "Propriedades"
"A property with this name already exists": "Já existe uma propriedade com este nome",
"Add card": "Adicionar cartão",
"Add filter": "Adicionar filtro",
"Add option": "Adicionar opção",
"Add sort": "Adicionar ordenação",
"Add view": "Adicionar visualização",
"All data in this column will be lost.": "Todos os dados nesta coluna serão perdidos.",
"Allow multiple people": "Permitir várias pessoas",
"Alphabetize": "Ordenar alfabeticamente",
"Are you sure you want to delete": "Tem certeza de que deseja excluir",
"Ascending": "Crescente",
"Base deleted": "Base excluída",
"Base name": "Nome da base",
"Base table": "Tabela da base",
"Card properties": "Propriedades do cartão",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Células maiores do que o limite de Texto serão truncadas e o conteúdo extra será perdido permanentemente.",
"Checkbox": "Caixa de seleção",
"Checked by default": "Marcado por padrão",
"Choose a property": "Escolha uma propriedade",
"Clear selection": "Limpar seleção",
"Column options": "Opções da coluna",
"Comma, period": "Vírgula, ponto",
"Complete": "Concluído",
"Contains": "Contém",
"Copy link to view": "Copiar link da visualização",
"Create a status property": "Criar uma propriedade de status",
"Created at": "Criado em",
"Currency": "Moeda",
"Decimal places": "Casas decimais",
"Default value": "Valor padrão",
"Delete property": "Excluir propriedade",
"Delete view": "Excluir visualização",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Descartar",
"Doesn't contain": "Não contém",
"Done": "Concluído",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propriedade",
"Export CSV": "Exportar CSV",
"Failed to load base": "Falha ao carregar a base",
"False": "Falso",
"File": "Arquivo",
"Filter and sort changes are visible only to you": "As alterações de filtro e ordenação são visíveis somente para você",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Começar com",
"Greater than": "Maior que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este quadro por uma propriedade de seleção ou status.",
"Hide all": "Ocultar tudo",
"Hide group": "Ocultar grupo",
"In Progress": "Em andamento",
"In progress": "Em andamento",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Inserir um quadro kanban nesta página",
"Insert an inline base on this page": "Inserir uma base inline nesta página",
"Is": "É",
"Is after": "É depois de",
"Is any of": "É qualquer um de",
"Is before": "É antes de",
"Is empty": "Está vazio",
"Is none of": "Não é nenhum de",
"Is not": "Não é",
"Is not empty": "Não está vazio",
"Is on or after": "É em ou depois de",
"Is on or before": "É em ou antes de",
"Is within": "Está dentro de",
"Keep editing": "Continuar editando",
"Last edited at": "Última edição em",
"Last edited by": "Última edição por",
"Less than": "Menor que",
"Link copied to clipboard": "Link copiado para a área de transferência",
"Local": "Local",
"Long text": "Texto longo",
"Moved card to {{column}}": "Cartão movido para {{column}}",
"Moved column to {{column}}": "Coluna movida para {{column}}",
"Multi-select": "Seleção múltipla",
"New row": "Nova linha",
"Next month": "Próximo mês",
"Next week": "Próxima semana",
"Next year": "Próximo ano",
"No filters applied": "Nenhum filtro aplicado",
"No match": "Sem correspondência",
"No options for this property type": "Sem opções para este tipo de propriedade",
"No sorts applied": "Nenhuma ordenação aplicada",
"No value": "Sem valor",
"None": "Nenhum",
"Not started": "Não iniciado",
"Number": "Número",
"One month ago": "Há um mês",
"One month from now": "Daqui a um mês",
"One week ago": "Há uma semana",
"One week from now": "Daqui a uma semana",
"Open as page": "Abrir como página",
"Options": "Opções",
"Past month": "Mês passado",
"Past week": "Semana passada",
"Past year": "Ano passado",
"Percent": "Porcentagem",
"Period, comma": "Ponto, vírgula",
"Person": "Pessoa",
"Please enter a valid email": "Por favor, insira um e-mail válido",
"Progress": "Progresso",
"Property options": "Opções da propriedade",
"Relative": "Relativo",
"Rename property": "Renomear propriedade",
"Reset": "Redefinir",
"Save for everyone": "Salvar para todos",
"Select": "Seleção",
"Select a property": "Selecione uma propriedade",
"Select all loaded rows": "Selecionar todas as linhas carregadas",
"Show all": "Mostrar tudo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espaço, vírgula",
"Space, period": "Espaço, ponto",
"This board has no grouping property yet.": "Este quadro ainda não tem uma propriedade de agrupamento.",
"This month": "Este mês",
"This year": "Este ano",
"Thousands and decimal separators": "Separadores de milhares e decimais",
"Time format": "Formato de hora",
"To Do": "A fazer",
"Tomorrow": "Amanhã",
"True": "Verdadeiro",
"Unsaved changes": "Alterações não salvas",
"Untitled base": "Base sem título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Visualização atualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Обновите свой тарифный план", "Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией", "Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.", "Upgrade your license tier.": "Обновите уровень вашей лицензии.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP", "AI & MCP": "ИИ и MCP",
"AI": "ИИ", "AI": "ИИ",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Создать свойство", "Create property": "Создать свойство",
"Hide properties": "Скрыть свойства", "Hide properties": "Скрыть свойства",
"Find a property type": "Найти тип свойства", "Find a property type": "Найти тип свойства",
"Properties": "Свойства", "Properties": "Свойства"
"A property with this name already exists": "Свойство с таким именем уже существует",
"Add card": "Добавить карточку",
"Add filter": "Добавить фильтр",
"Add option": "Добавить вариант",
"Add sort": "Добавить сортировку",
"Add view": "Добавить представление",
"All data in this column will be lost.": "Все данные в этом столбце будут потеряны.",
"Allow multiple people": "Разрешить нескольких пользователей",
"Alphabetize": "По алфавиту",
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
"Ascending": "По возрастанию",
"Base deleted": "База удалена",
"Base name": "Название базы",
"Base table": "Таблица базы",
"Card properties": "Свойства карточки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Ячейки, длина которых превышает ограничение для текста, будут обрезаны, а лишнее содержимое будет безвозвратно потеряно.",
"Checkbox": "Флажок",
"Checked by default": "Отмечено по умолчанию",
"Choose a property": "Выберите свойство",
"Clear selection": "Очистить выбор",
"Column options": "Параметры столбца",
"Comma, period": "Запятая, точка",
"Complete": "Завершено",
"Contains": "Содержит",
"Copy link to view": "Скопировать ссылку на представление",
"Create a status property": "Создать свойство статуса",
"Created at": "Создано",
"Currency": "Валюта",
"Decimal places": "Десятичные знаки",
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
"Done": "Готово",
"Edit formula": "Редактировать формулу",
"Edit property": "Редактировать свойство",
"Export CSV": "Экспорт CSV",
"Failed to load base": "Не удалось загрузить базу",
"False": "Ложь",
"File": "Файл",
"Filter and sort changes are visible only to you": "Изменения фильтра и сортировки видны только вам",
"Filter by": "Фильтр по",
"Formula": "Формула",
"Get started with": "Начать работу с",
"Greater than": "Больше чем",
"Group by": "Группировать по",
"Group this board by a select or status property.": "Сгруппируйте эту доску по свойству выбора или статуса.",
"Hide all": "Скрыть все",
"Hide group": "Скрыть группу",
"In Progress": "В процессе",
"In progress": "В процессе",
"Include time": "Включить время",
"Insert a kanban board on this page": "Вставить канбан-доску на эту страницу",
"Insert an inline base on this page": "Вставить встроенную базу на эту страницу",
"Is": "Равно",
"Is after": "После",
"Is any of": "Совпадает с любым из",
"Is before": "До",
"Is empty": "Пусто",
"Is none of": "Не совпадает ни с одним из",
"Is not": "Не равно",
"Is not empty": "Не пусто",
"Is on or after": "В этот день или позже",
"Is on or before": "В этот день или раньше",
"Is within": "Находится в пределах",
"Keep editing": "Продолжить редактирование",
"Last edited at": "Последнее изменение",
"Last edited by": "Последний изменивший",
"Less than": "Меньше чем",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Local": "Локальный",
"Long text": "Длинный текст",
"Moved card to {{column}}": "Карточка перемещена в {{column}}",
"Moved column to {{column}}": "Столбец перемещён в {{column}}",
"Multi-select": "Множественный выбор",
"New row": "Новая строка",
"Next month": "Следующий месяц",
"Next week": "Следующая неделя",
"Next year": "Следующий год",
"No filters applied": "Фильтры не применены",
"No match": "Нет совпадений",
"No options for this property type": "Для этого типа свойства нет параметров",
"No sorts applied": "Сортировка не применена",
"No value": "Нет значения",
"None": "Нет",
"Not started": "Не начато",
"Number": "Число",
"One month ago": "Месяц назад",
"One month from now": "Через месяц",
"One week ago": "Неделю назад",
"One week from now": "Через неделю",
"Open as page": "Открыть как страницу",
"Options": "Параметры",
"Past month": "Прошлый месяц",
"Past week": "Прошлая неделя",
"Past year": "Прошлый год",
"Percent": "Процент",
"Period, comma": "Точка, запятая",
"Person": "Пользователь",
"Please enter a valid email": "Пожалуйста, введите корректный адрес электронной почты",
"Progress": "Прогресс",
"Property options": "Параметры свойства",
"Relative": "Относительный",
"Rename property": "Переименовать свойство",
"Reset": "Сбросить",
"Save for everyone": "Сохранить для всех",
"Select": "Выбор",
"Select a property": "Выберите свойство",
"Select all loaded rows": "Выбрать все загруженные строки",
"Show all": "Показать все",
"Sort": "Сортировка",
"Sort by": "Сортировать по",
"Space, comma": "Пробел, запятая",
"Space, period": "Пробел, точка",
"This board has no grouping property yet.": "У этой доски пока нет свойства для группировки.",
"This month": "Этот месяц",
"This year": "Этот год",
"Thousands and decimal separators": "Разделители тысяч и десятичных знаков",
"Time format": "Формат времени",
"To Do": "К выполнению",
"Tomorrow": "Завтра",
"True": "Истина",
"Unsaved changes": "Несохранённые изменения",
"Untitled base": "База без названия",
"URL": "URL",
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "Оновіть свій тарифний план", "Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією", "Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP", "AI & MCP": "ШІ та MCP",
"AI": "ШІ", "AI": "ШІ",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "Створити властивість", "Create property": "Створити властивість",
"Hide properties": "Приховати властивості", "Hide properties": "Приховати властивості",
"Find a property type": "Знайти тип властивості", "Find a property type": "Знайти тип властивості",
"Properties": "Властивості", "Properties": "Властивості"
"A property with this name already exists": "Властивість із такою назвою вже існує",
"Add card": "Додати картку",
"Add filter": "Додати фільтр",
"Add option": "Додати опцію",
"Add sort": "Додати сортування",
"Add view": "Додати подання",
"All data in this column will be lost.": "Усі дані в цьому стовпці буде втрачено.",
"Allow multiple people": "Дозволити кількох людей",
"Alphabetize": "За алфавітом",
"Are you sure you want to delete": "Ви впевнені, що хочете видалити",
"Ascending": "За зростанням",
"Base deleted": "Базу видалено",
"Base name": "Назва бази",
"Base table": "Таблиця бази",
"Card properties": "Властивості картки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Клітинки, довші за обмеження для тексту, буде обрізано, а зайвий вміст буде безповоротно втрачено.",
"Checkbox": "Прапорець",
"Checked by default": "Позначено за замовчуванням",
"Choose a property": "Оберіть властивість",
"Clear selection": "Очистити вибір",
"Column options": "Параметри стовпця",
"Comma, period": "Кома, крапка",
"Complete": "Завершено",
"Contains": "Містить",
"Copy link to view": "Скопіювати посилання на подання",
"Create a status property": "Створити властивість статусу",
"Created at": "Створено",
"Currency": "Валюта",
"Decimal places": "Десяткові знаки",
"Default value": "Значення за замовчуванням",
"Delete property": "Видалити властивість",
"Delete view": "Видалити подання",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "За спаданням",
"Discard": "Скасувати",
"Doesn't contain": "Не містить",
"Done": "Готово",
"Edit formula": "Редагувати формулу",
"Edit property": "Редагувати властивість",
"Export CSV": "Експорт CSV",
"Failed to load base": "Не вдалося завантажити базу",
"False": "Хибність",
"File": "Файл",
"Filter and sort changes are visible only to you": "Зміни фільтрів і сортування видимі лише вам",
"Filter by": "Фільтрувати за",
"Formula": "Формула",
"Get started with": "Почніть із",
"Greater than": "Більше ніж",
"Group by": "Групувати за",
"Group this board by a select or status property.": "Згрупуйте цю дошку за властивістю вибору або статусу.",
"Hide all": "Приховати все",
"Hide group": "Приховати групу",
"In Progress": "У процесі",
"In progress": "У процесі",
"Include time": "Включити час",
"Insert a kanban board on this page": "Вставити канбан-дошку на цю сторінку",
"Insert an inline base on this page": "Вставити вбудовану базу на цю сторінку",
"Is": "Є",
"Is after": "Після",
"Is any of": "Є одним із",
"Is before": "До",
"Is empty": "Порожнє",
"Is none of": "Не є жодним із",
"Is not": "Не є",
"Is not empty": "Не порожнє",
"Is on or after": "У цей день або пізніше",
"Is on or before": "У цей день або раніше",
"Is within": "У межах",
"Keep editing": "Продовжити редагування",
"Last edited at": "Востаннє відредаговано",
"Last edited by": "Востаннє відредагував",
"Less than": "Менше ніж",
"Link copied to clipboard": "Посилання скопійовано в буфер обміну",
"Local": "Локальний",
"Long text": "Довгий текст",
"Moved card to {{column}}": "Картку переміщено до {{column}}",
"Moved column to {{column}}": "Стовпець переміщено до {{column}}",
"Multi-select": "Мультивибір",
"New row": "Новий рядок",
"Next month": "Наступного місяця",
"Next week": "Наступного тижня",
"Next year": "Наступного року",
"No filters applied": "Фільтри не застосовано",
"No match": "Немає збігів",
"No options for this property type": "Немає опцій для цього типу властивості",
"No sorts applied": "Сортування не застосовано",
"No value": "Немає значення",
"None": "Немає",
"Not started": "Не розпочато",
"Number": "Число",
"One month ago": "Місяць тому",
"One month from now": "Через місяць",
"One week ago": "Тиждень тому",
"One week from now": "Через тиждень",
"Open as page": "Відкрити як сторінку",
"Options": "Опції",
"Past month": "За минулий місяць",
"Past week": "За минулий тиждень",
"Past year": "За минулий рік",
"Percent": "Відсоток",
"Period, comma": "Крапка, кома",
"Person": "Особа",
"Please enter a valid email": "Будь ласка, введіть коректну електронну адресу",
"Progress": "Прогрес",
"Property options": "Параметри властивості",
"Relative": "Відносний",
"Rename property": "Перейменувати властивість",
"Reset": "Скинути",
"Save for everyone": "Зберегти для всіх",
"Select": "Вибір",
"Select a property": "Виберіть властивість",
"Select all loaded rows": "Вибрати всі завантажені рядки",
"Show all": "Показати все",
"Sort": "Сортування",
"Sort by": "Сортувати за",
"Space, comma": "Пробіл, кома",
"Space, period": "Пробіл, крапка",
"This board has no grouping property yet.": "Ця дошка ще не має властивості групування.",
"This month": "Цього місяця",
"This year": "Цього року",
"Thousands and decimal separators": "Роздільники тисяч і десяткових знаків",
"Time format": "Формат часу",
"To Do": "До виконання",
"Tomorrow": "Завтра",
"True": "Істина",
"Unsaved changes": "Незбережені зміни",
"Untitled base": "База без назви",
"URL": "URL",
"Value": "Значення",
"View updated for everyone": "Подання оновлено для всіх",
"You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
} }
@@ -696,7 +696,7 @@
"Upgrade your plan": "升级您的方案", "Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用", "Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。", "Upgrade your license tier.": "升级您的许可等级。",
"AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"AI & MCP": "AI 与 MCP", "AI & MCP": "AI 与 MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1153,141 +1153,5 @@
"Create property": "创建属性", "Create property": "创建属性",
"Hide properties": "隐藏属性", "Hide properties": "隐藏属性",
"Find a property type": "查找属性类型", "Find a property type": "查找属性类型",
"Properties": "属性", "Properties": "属性"
"A property with this name already exists": "已存在同名属性",
"Add card": "添加卡片",
"Add filter": "添加筛选",
"Add option": "添加选项",
"Add sort": "添加排序",
"Add view": "添加视图",
"All data in this column will be lost.": "此列中的所有数据都将丢失。",
"Allow multiple people": "允许多人",
"Alphabetize": "按字母顺序",
"Are you sure you want to delete": "您确定要删除",
"Ascending": "升序",
"Base deleted": "资料库已删除",
"Base name": "资料库名称",
"Base table": "资料库表",
"Card properties": "卡片属性",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "超出文本长度限制的单元格内容将被截断,额外内容将永久丢失。",
"Checkbox": "复选框",
"Checked by default": "默认选中",
"Choose a property": "选择一个属性",
"Clear selection": "清除选择",
"Column options": "列选项",
"Comma, period": "逗号、句点",
"Complete": "完成",
"Contains": "包含",
"Copy link to view": "复制视图链接",
"Create a status property": "创建状态属性",
"Created at": "创建于",
"Currency": "货币",
"Decimal places": "小数位数",
"Default value": "默认值",
"Delete property": "删除属性",
"Delete view": "删除视图",
"Delete {{count}} rows?_one": "删除 1 行?",
"Delete {{count}} rows?_other": "删除 {{count}} 行?",
"Descending": "降序",
"Discard": "放弃",
"Doesn't contain": "不包含",
"Done": "完成",
"Edit formula": "编辑公式",
"Edit property": "编辑属性",
"Export CSV": "导出 CSV",
"Failed to load base": "加载资料库失败",
"False": "假",
"File": "文件",
"Filter and sort changes are visible only to you": "筛选和排序更改仅对您可见",
"Filter by": "筛选条件",
"Formula": "公式",
"Get started with": "开始使用",
"Greater than": "大于",
"Group by": "分组依据",
"Group this board by a select or status property.": "按选择或状态属性对此看板进行分组。",
"Hide all": "全部隐藏",
"Hide group": "隐藏分组",
"In Progress": "进行中",
"In progress": "进行中",
"Include time": "包含时间",
"Insert a kanban board on this page": "在此页面插入一个看板",
"Insert an inline base on this page": "在此页面插入一个内联资料库",
"Is": "是",
"Is after": "在此之后",
"Is any of": "是其中之一",
"Is before": "在此之前",
"Is empty": "为空",
"Is none of": "都不是",
"Is not": "不是",
"Is not empty": "不为空",
"Is on or after": "在此日期或之后",
"Is on or before": "在此日期或之前",
"Is within": "在范围内",
"Keep editing": "继续编辑",
"Last edited at": "最后编辑于",
"Last edited by": "最后编辑者",
"Less than": "小于",
"Link copied to clipboard": "链接已复制到剪贴板",
"Local": "本地",
"Long text": "长文本",
"Moved card to {{column}}": "已将卡片移动到 {{column}}",
"Moved column to {{column}}": "已将列移动到 {{column}}",
"Multi-select": "多选",
"New row": "新行",
"Next month": "下个月",
"Next week": "下周",
"Next year": "明年",
"No filters applied": "未应用筛选",
"No match": "无匹配项",
"No options for this property type": "此属性类型没有可用选项",
"No sorts applied": "未应用排序",
"No value": "无值",
"None": "无",
"Not started": "未开始",
"Number": "数字",
"One month ago": "一个月前",
"One month from now": "从现在起一个月后",
"One week ago": "一周前",
"One week from now": "从现在起一周后",
"Open as page": "作为页面打开",
"Options": "选项",
"Past month": "过去一个月",
"Past week": "过去一周",
"Past year": "过去一年",
"Percent": "百分比",
"Period, comma": "句点、逗号",
"Person": "人员",
"Please enter a valid email": "请输入有效的电子邮件地址",
"Progress": "进度",
"Property options": "属性选项",
"Relative": "相对",
"Rename property": "重命名属性",
"Reset": "重置",
"Save for everyone": "保存给所有人",
"Select": "选择",
"Select a property": "选择一个属性",
"Select all loaded rows": "选择所有已加载的行",
"Show all": "显示全部",
"Sort": "排序",
"Sort by": "排序依据",
"Space, comma": "空格、逗号",
"Space, period": "空格、句点",
"This board has no grouping property yet.": "此看板尚无分组属性。",
"This month": "本月",
"This year": "今年",
"Thousands and decimal separators": "千位分隔符和小数分隔符",
"Time format": "时间格式",
"To Do": "待办",
"Tomorrow": "明天",
"True": "真",
"Unsaved changes": "未保存的更改",
"Untitled base": "未命名资料库",
"URL": "网址",
"Value": "值",
"View updated for everyone": "已为所有人更新视图",
"You have unsaved changes. Do you want to discard them?": "您有未保存的更改。要放弃这些更改吗?",
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项"
} }
+45 -77
View File
@@ -1,92 +1,60 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "@/components/layouts/global/layout.tsx"; import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
import LoginPage from "@/pages/auth/login";
import Home from "@/pages/dashboard/home";
import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import Billing from "@/ee/billing/pages/billing.tsx";
import CloudLogin from "@/ee/pages/cloud-login.tsx";
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin"; import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
const LoginPage = lazy(() => import("@/pages/auth/login")); import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
const Home = lazy(() => import("@/pages/dashboard/home")); import SpaceTrash from "@/pages/space/space-trash.tsx";
const Page = lazy(() => import("@/pages/page/page")); import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
const AccountSettings = lazy( import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
() => import("@/pages/settings/account/account-settings"), import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
); import BasePage from "@/ee/base/pages/base-page.tsx";
const WorkspaceMembers = lazy( import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
() => import("@/pages/settings/workspace/workspace-members"), import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
); import TemplateList from "@/ee/template/pages/template-list";
const WorkspaceSettings = lazy( import TemplateEditor from "@/ee/template/pages/template-editor";
() => import("@/pages/settings/workspace/workspace-settings"), import FavoritesPage from "@/pages/favorites/favorites-page";
); import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
const Groups = lazy(() => import("@/pages/settings/group/groups")); import VerifyEmail from "@/ee/pages/verify-email.tsx";
const GroupInfo = lazy(() => import("./pages/settings/group/group-info")); import LabelPage from "@/pages/label/label-page";
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx"));
const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx"));
const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx"));
const PasswordReset = lazy(() => import("./pages/auth/password-reset"));
const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx"));
const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx"));
const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx"));
const Security = lazy(() => import("@/ee/security/pages/security.tsx"));
const License = lazy(() => import("@/ee/licence/pages/license.tsx"));
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx"));
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
default: m.MfaChallengePage,
})),
);
const MfaSetupRequiredPage = lazy(() =>
import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({
default: m.MfaSetupRequiredPage,
})),
);
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys"));
const WorkspaceApiKeys = lazy(
() => import("@/ee/api-key/pages/workspace-api-keys"),
);
const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx"));
const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx"));
const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx"));
const VerifiedPages = lazy(
() => import("@/ee/page-verification/pages/verified-pages.tsx"),
);
const TemplateList = lazy(() => import("@/ee/template/pages/template-list"));
const TemplateEditor = lazy(
() => import("@/ee/template/pages/template-editor"),
);
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
useRedirectToCloudSelect(); useRedirectToCloudSelect();
useTrackOrigin(); useTrackOrigin();
useEffect(() => {
// warm the editor chunk so opening a page doesn't wait on the network
const timer = setTimeout(() => import("@/pages/page/page"), 3000);
return () => clearTimeout(timer);
}, []);
return ( return (
<Suspense fallback={null}> <>
<Routes> <Routes>
<Route index element={<Navigate to="/home" />} /> <Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
@@ -167,6 +135,6 @@ export default function App() {
<Route path="*" element={<Error404 />} /> <Route path="*" element={<Error404 />} />
</Routes> </Routes>
</Suspense> </>
); );
} }
@@ -1,30 +1,17 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react"; import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { lazy, ReactNode, Suspense, useEffect } from "react"; import React, { ReactNode, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
const CommentListWithTabs = lazy(
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
);
const TableOfContents = lazy(() =>
import(
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
).then((m) => ({ default: m.TableOfContents })),
);
const AsideChatPanel = lazy(
() => import("@/ee/ai-chat/components/aside-chat-panel"),
);
const PageDetailsAside = lazy(() =>
import("@/features/page-details/components/page-details-aside.tsx").then(
(m) => ({ default: m.PageDetailsAside }),
),
);
export default function Aside() { export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -81,7 +68,6 @@ export default function Aside() {
</Group> </Group>
)} )}
<Suspense fallback={null}>
{tab === "comments" || tab === "chat" ? ( {tab === "comments" || tab === "chat" ? (
component component
) : ( ) : (
@@ -93,7 +79,6 @@ export default function Aside() {
<div style={{ paddingBottom: "200px" }}>{component}</div> <div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea> </ScrollArea>
)} )}
</Suspense>
</> </>
)} )}
</Box> </Box>
@@ -11,10 +11,7 @@ import {
sidebarWidthAtom, sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
const AiChatSidebar = React.lazy(
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
);
import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx"; import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css"; import classes from "./app-shell.module.css";
@@ -129,11 +126,7 @@ export default function GlobalAppShell({
)} )}
{isSpaceRoute && <SpaceSidebar />} {isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />} {isSettingsRoute && <SettingsSidebar />}
{isAiRoute && ( {isAiRoute && <AiChatSidebar />}
<React.Suspense fallback={null}>
<AiChatSidebar />
</React.Suspense>
)}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}> <AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
@@ -1,12 +0,0 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
export const CheckboxMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";
@@ -1,45 +0,0 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -1,29 +0,0 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+4 -2
View File
@@ -1,15 +1,17 @@
import { Title, Text, Button, Container, Group } from "@mantine/core"; import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css"; import classes from "./error-404.module.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() { export function Error404() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("404 page not found")} /> <Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<Container className={classes.root}> <Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title> <Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}> <Text c="dimmed" size="lg" ta="center" className={classes.description}>
@@ -1,74 +0,0 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatReadOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Read-only mode")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI Chat can search and read workspace content, but cannot create or edit pages.",
)}
</Text>
</div>
<AiChatReadOnlyToggle />
</Group>
);
}
function AiChatReadOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatReadOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatReadOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat read-only mode")}
/>
</Tooltip>
);
}
@@ -1,74 +0,0 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function AiChatWorkspaceKnowledgeOnly() {
const { t } = useTranslation();
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("Workspace knowledge only")}</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
)}
</Text>
</div>
<AiChatWorkspaceKnowledgeOnlyToggle />
</Group>
);
}
function AiChatWorkspaceKnowledgeOnlyToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly,
);
const hasAccess = useHasFeature(Feature.AI_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
aiChatWorkspaceKnowledgeOnly: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle workspace knowledge only")}
/>
</Tooltip>
);
}
@@ -6,10 +6,7 @@ import {
IconFileText, IconFileText,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai"; import ChatInput from "./chat-input";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useRef } from "react";
import ChatInput, { ChatInputHandle } from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css"; import classes from "../styles/ai-chat.module.css";
@@ -17,7 +14,6 @@ type Suggestion = {
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
prompt: string; prompt: string;
write?: boolean;
}; };
const SUGGESTIONS: Suggestion[] = [ const SUGGESTIONS: Suggestion[] = [
@@ -30,7 +26,6 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconFilePlus size={16} />, icon: <IconFilePlus size={16} />,
text: "Create a new page", text: "Create a new page",
prompt: "Create a new page titled ", prompt: "Create a new page titled ",
write: true,
}, },
{ {
icon: <IconFileText size={16} />, icon: <IconFileText size={16} />,
@@ -41,7 +36,6 @@ const SUGGESTIONS: Suggestion[] = [
icon: <IconEdit size={16} />, icon: <IconEdit size={16} />,
text: "Update page content", text: "Update page content",
prompt: "Update the page @", prompt: "Update the page @",
write: true,
}, },
]; ];
@@ -53,13 +47,9 @@ type Props = {
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const workspace = useAtomValue(workspaceAtom);
const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true;
const inputRef = useRef<ChatInputHandle>(null);
const handleSuggestionClick = (prompt: string) => { const handleSuggestionClick = (prompt: string) => {
inputRef.current?.prefill(prompt); onSend(prompt, [], []);
}; };
return ( return (
@@ -72,7 +62,6 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.emptyStateInput}> <div className={classes.emptyStateInput}>
<ChatInput <ChatInput
ref={inputRef}
isStreaming={isStreaming} isStreaming={isStreaming}
onSend={onSend} onSend={onSend}
onStop={onStop} onStop={onStop}
@@ -84,7 +73,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
<div className={classes.suggestionsSection}> <div className={classes.suggestionsSection}>
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2> <h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
<div className={classes.suggestionsGrid}> <div className={classes.suggestionsGrid}>
{SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => ( {SUGGESTIONS.map((s) => (
<button <button
key={s.text} key={s.text}
type="button" type="button"
@@ -1,12 +1,4 @@
import { import { useCallback, useId, useRef, useEffect, useState } from "react";
forwardRef,
useCallback,
useId,
useImperativeHandle,
useRef,
useEffect,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react"; import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core"; import { Popover } from "@mantine/core";
@@ -43,10 +35,6 @@ type Props = {
chatId?: string; chatId?: string;
}; };
export type ChatInputHandle = {
prefill: (text: string) => void;
};
function extractMentions(json: any): PageMention[] { function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = []; const mentions: PageMention[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -101,7 +89,7 @@ function editorJsonToText(json: any): string {
return text; return text;
} }
const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({ export default function ChatInput({
isStreaming, isStreaming,
onSend, onSend,
onStop, onStop,
@@ -112,7 +100,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
variant = "card", variant = "card",
showDisclaimer = true, showDisclaimer = true,
chatId, chatId,
}: Props, ref) { }: Props) {
const chatIdRef = useRef(chatId); const chatIdRef = useRef(chatId);
chatIdRef.current = chatId; chatIdRef.current = chatId;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -267,7 +255,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
}, },
content: "", content: "",
editable: true, editable: true,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false, autofocus: autofocus ? "end" : false,
@@ -282,19 +269,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
} }
}, [editor]); }, [editor]);
useImperativeHandle(
ref,
() => ({
prefill: (text: string) => {
if (!editor || editor.isDestroyed) return;
editor.commands.clearContent();
editor.commands.insertContent(text);
editor.commands.focus();
},
}),
[editor],
);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0; const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper; const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
@@ -454,6 +428,4 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
)} )}
</> </>
); );
}); }
export default ChatInput;
+7 -24
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react"; import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
@@ -5,27 +7,20 @@ import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx"; import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import AiChatReadOnly from "@/ee/ai-chat/components/ai-chat-read-only.tsx";
import AiChatWorkspaceKnowledgeOnly from "@/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Collapse, Stack, Tabs } from "@mantine/core"; import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const workspace = useAtomValue(workspaceAtom);
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -45,7 +40,9 @@ export default function AiSettings() {
return ( return (
<> <>
<DocumentTitle title="AI settings" /> <Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} /> <SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}> <Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -67,7 +64,7 @@ export default function AiSettings() {
mb="lg" mb="lg"
> >
{t( {t(
"AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)} )}
</Alert> </Alert>
)} )}
@@ -76,20 +73,6 @@ export default function AiSettings() {
{!isCloud() && <EnableAiSearch />} {!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi /> <EnableGenerativeAi />
<EnableAiChat /> <EnableAiChat />
<Collapse expanded={aiChatEnabled}>
<Stack
gap="md"
pl="md"
ml="xs"
style={{
borderLeft:
"2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
}}
>
<AiChatReadOnly />
<AiChatWorkspaceKnowledgeOnly />
</Stack>
</Collapse>
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -15,14 +15,6 @@ export interface IAiSearchResponse {
}>; }>;
} }
export async function hintVectorCache(): Promise<void> {
try {
await api.post("/ai/vector-cache-hint");
} catch {
// best-effort cache hint
}
}
export async function aiAnswers( export async function aiAnswers(
params: IPageSearchParams, params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void, onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -1,9 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppUrl } from "@/lib/config"; import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal"; import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal"; import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -16,7 +17,6 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -49,7 +49,11 @@ export default function UserApiKeys() {
return ( return (
<> <>
<DocumentTitle title={t("API keys")} /> <Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
@@ -1,7 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal"; import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal"; import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -13,7 +15,6 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx'; import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins"; import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -46,7 +47,11 @@ export default function WorkspaceApiKeys() {
return ( return (
<> <>
<DocumentTitle title={t("API management")} /> <Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} /> <SettingsTitle title={t("API management")} />
@@ -10,9 +10,11 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { import {
@@ -24,7 +26,6 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role"; import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years"; type RetentionUnit = "days" | "months" | "years";
@@ -96,7 +97,11 @@ export default function AuditLogs() {
return ( return (
<> <>
<DocumentTitle title={t("Audit log")} /> <Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} /> <SettingsTitle title={t("Audit log")} />
@@ -30,7 +30,7 @@ type BaseKanbanProps = {
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) { export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view); const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation(); const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation(); const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId); const { openRow } = useRowDetailModal(pageId);
@@ -201,7 +201,6 @@ export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter
column={column} column={column}
viewFilter={viewFilter} viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!} groupByPropertyId={groupByPropertyId!}
groupByProperty={groupByProperty}
canEdit={editable} canEdit={editable}
onOpenRow={handleOpenRow} onOpenRow={handleOpenRow}
onHide={hideColumn} onHide={hideColumn}
@@ -1,27 +1,24 @@
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActionIcon, Text } from "@mantine/core"; import { ActionIcon, Menu, Text } from "@mantine/core";
import { IconPlus, IconGripVertical } from "@tabler/icons-react"; import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx"; import clsx from "clsx";
import { IBaseProperty, KanbanColumn } from "@/ee/base/types/base.types"; import { KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color"; import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd"; import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator"; import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { KanbanColumnTitle } from "@/ee/base/components/kanban/kanban-column-title";
import { KanbanColumnMenu } from "@/ee/base/components/kanban/kanban-column-menu";
import classes from "@/ee/base/styles/kanban.module.css"; import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = { type KanbanColumnHeaderProps = {
column: KanbanColumn; column: KanbanColumn;
pageId: string; pageId: string;
property: IBaseProperty | undefined;
count?: string; count?: string;
canEdit: boolean; canEdit: boolean;
onHide: () => void; onHide: () => void;
onAddCard: () => void; onAddCard: () => void;
}; };
export function KanbanColumnHeader({ column, pageId, property, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) { export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const dotColor = column.color const dotColor = column.color
? choiceColor(column.color).color as string ? choiceColor(column.color).color as string
@@ -52,13 +49,22 @@ export function KanbanColumnHeader({ column, pageId, property, count, canEdit, o
background: dotColor, background: dotColor,
}} }}
/> />
<KanbanColumnTitle column={column} property={property} pageId={pageId} canEdit={canEdit} /> <Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
{count !== undefined && <Text className={classes.count}>{count}</Text>} {count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && ( {canEdit && (
<> <>
{property && ( <Menu position="bottom-end" withinPortal>
<KanbanColumnMenu property={property} pageId={pageId} onHide={onHide} /> <Menu.Target>
)} <ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}> <ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} /> <IconPlus size={14} />
</ActionIcon> </ActionIcon>
@@ -1,119 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Popover, Stack } from "@mantine/core";
import { IconDots, IconEyeOff, IconSettings } from "@tabler/icons-react";
import { useAtom } from "jotai";
import {
propertyMenuCloseRequestAtomFamily,
propertyMenuDirtyAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { IBaseProperty } from "@/ee/base/types/base.types";
import {
MenuItem,
PropertyMenuContent,
} from "@/ee/base/components/property/property-menu";
type KanbanColumnMenuProps = {
property: IBaseProperty;
pageId: string;
onHide: () => void;
};
export function KanbanColumnMenu({ property, pageId, onHide }: KanbanColumnMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [view, setView] = useState<"menu" | "property">("menu");
const [dirty, setDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const handleClose = useCallback(() => {
setOpened(false);
setView("menu");
}, []);
const wasOpenedRef = useRef(opened);
useEffect(() => {
if (wasOpenedRef.current && !opened) setDirty(false);
wasOpenedRef.current = opened;
}, [opened, setDirty]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) return;
if (dirty) {
setCloseRequest(closeRequest + 1);
} else {
handleClose();
}
},
[dirty, closeRequest, setCloseRequest, handleClose],
);
const toggle = useCallback(() => {
if (opened) {
handleOpenChange(false);
} else if (!dirty) {
setOpened(true);
}
}, [opened, dirty, handleOpenChange]);
return (
<Popover
opened={opened}
onChange={handleOpenChange}
onClose={handleClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
aria-label={t("Column options")}
onClick={toggle}
>
<IconDots size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{view === "menu" ? (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconSettings size={14} />}
label={t("Edit property")}
onClick={() => setView("property")}
/>
<MenuItem
icon={<IconEyeOff size={14} />}
label={t("Hide group")}
onClick={() => {
handleClose();
onHide();
}}
/>
</Stack>
) : (
<PropertyMenuContent
property={property}
opened={opened}
onClose={handleClose}
onDirtyChange={setDirty}
pageId={pageId}
initialPanel={property.pendingType ? "main" : "options"}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,113 +0,0 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Group, Popover, Text, TextInput, UnstyledButton } from "@mantine/core";
import { IBaseProperty, KanbanColumn, SelectTypeOptions } from "@/ee/base/types/base.types";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnTitleProps = {
column: KanbanColumn;
property: IBaseProperty | undefined;
pageId: string;
canEdit: boolean;
};
export function KanbanColumnTitle({ column, property, pageId, canEdit }: KanbanColumnTitleProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState("");
const updateProperty = useUpdatePropertyMutation();
const commit = useCallback(() => {
setOpened(false);
const name = draft.trim();
const options = property?.typeOptions as SelectTypeOptions | undefined;
if (!property || !options || !name || name === column.name) return;
if (!options.choices.some((c) => c.id === column.key)) return;
updateProperty.mutate({
propertyId: property.id,
pageId,
typeOptions: {
...options,
choices: options.choices.map((c) =>
c.id === column.key ? { ...c, name } : c,
),
},
});
}, [draft, property, column.name, column.key, pageId, updateProperty]);
const toggle = useCallback(() => {
if (opened) {
commit();
} else {
setDraft(column.name);
setOpened(true);
}
}, [opened, commit, column.name]);
const cancel = useCallback(() => setOpened(false), []);
if (!canEdit || column.isNoValue || !property) {
return (
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
);
}
return (
<Popover
opened={opened}
onChange={(next) => {
if (!next) commit();
}}
position="bottom-start"
shadow="md"
width={240}
withinPortal
trapFocus
returnFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<UnstyledButton className={classes.columnTitleButton} onClick={toggle}>
<Text fw={600} size="sm" truncate flex={1} ta="left">
{column.name}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancel();
}
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
flex={1}
value={draft}
data-autofocus
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
/>
<Button size="xs" onClick={commit}>
{t("Done")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseProperty, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types"; import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter"; import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count"; import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll"; import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
@@ -19,7 +19,6 @@ type KanbanColumnProps = {
column: KanbanColumnType; column: KanbanColumnType;
viewFilter: FilterGroup | undefined; viewFilter: FilterGroup | undefined;
groupByPropertyId: string; groupByPropertyId: string;
groupByProperty: IBaseProperty | undefined;
canEdit: boolean; canEdit: boolean;
onOpenRow: (rowId: string) => void; onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void; onHide: (columnKey: string) => void;
@@ -34,7 +33,6 @@ export function KanbanColumn({
column, column,
viewFilter, viewFilter,
groupByPropertyId, groupByPropertyId,
groupByProperty,
canEdit, canEdit,
onOpenRow, onOpenRow,
onHide, onHide,
@@ -141,7 +139,6 @@ export function KanbanColumn({
<KanbanColumnHeader <KanbanColumnHeader
column={column} column={column}
pageId={pageId} pageId={pageId}
property={groupByProperty}
count={count} count={count}
canEdit={canEdit} canEdit={canEdit}
onHide={() => onHide(column.key)} onHide={() => onHide(column.key)}
@@ -53,7 +53,6 @@ type PropertyMenuContentProps = {
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void; onEditFormula?: () => void;
pageId: string; pageId: string;
initialPanel?: "main" | "options";
}; };
type MenuPanel = type MenuPanel =
@@ -97,10 +96,9 @@ export function PropertyMenuContent({
onDirtyChange, onDirtyChange,
onEditFormula, onEditFormula,
pageId, pageId,
initialPanel,
}: PropertyMenuContentProps) { }: PropertyMenuContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "main"); const [panel, setPanel] = useState<MenuPanel>("main");
const [renameValue, setRenameValue] = useState(property.name); const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false); const [optionsDirty, setOptionsDirty] = useState(false);
@@ -119,12 +117,12 @@ export function PropertyMenuContent({
useEffect(() => { useEffect(() => {
if (opened) { if (opened) {
setPanel(initialPanel ?? "main"); setPanel("main");
setRenameValue(property.name); setRenameValue(property.name);
setOptionsDirty(false); setOptionsDirty(false);
setPendingTargetType(null); setPendingTargetType(null);
} }
}, [opened, property.name, initialPanel]); }, [opened, property.name]);
useEffect(() => { useEffect(() => {
if (panel === "rename") { if (panel === "rename") {
@@ -465,7 +463,7 @@ export function PropertyMenuContent({
PropertyMenuContent.displayName = "PropertyMenuContent"; PropertyMenuContent.displayName = "PropertyMenuContent";
export function MenuItem({ function MenuItem({
icon, icon,
label, label,
rightIcon, rightIcon,
@@ -9,7 +9,6 @@ import {
Text, Text,
UnstyledButton, UnstyledButton,
Button, Button,
MultiSelect,
} from "@mantine/core"; } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react"; import { IconPlus, IconTrash } from "@tabler/icons-react";
import { import {
@@ -53,9 +52,6 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// stored value so a stale shape isn't sent to the engine. // stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string { function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none"; if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "choices") {
return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle";
}
if (inputKind === "person") { if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle"; return op === "any" || op === "none" ? "personMulti" : "personSingle";
} }
@@ -74,10 +70,6 @@ function getOperatorsForType(type: string): FilterOperator[] {
DEFAULT_FILTER_OPERATORS) as FilterOperator[]; DEFAULT_FILTER_OPERATORS) as FilterOperator[];
} }
function isMultiChoice(op: FilterCondition["op"]): boolean {
return op === "any" || op === "none";
}
function FilterValueInput({ function FilterValueInput({
condition, condition,
property, property,
@@ -129,32 +121,6 @@ function FilterValueInput({
const typeOptions = property.typeOptions as SelectTypeOptions | undefined; const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? []; const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name })); const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
if (isMultiChoice(condition.op)) {
const { value } = condition;
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
data={choiceOptions}
comboboxProps={{ withinPortal: false }}
value={selected}
onChange={(values) => onChange(values)}
w={160}
styles={{
pillsList: {
maxHeight: 70,
overflowY: "auto",
},
}}
maxDropdownHeight={220}
/>
);
}
return ( return (
<Select <Select
size="xs" size="xs"
@@ -233,18 +199,11 @@ export function ViewFilterConfigPopover({
label: p.name, label: p.name,
})); }));
const [unSaved, setUnSaved] = useState(false)
const [draft, setDraft] = useState<FilterCondition | null>(null); const [draft, setDraft] = useState<FilterCondition | null>(null);
const [draftConditions, setDraftConditions] =
useState<FilterCondition[]>(conditions);
useEffect(() => { useEffect(() => {
if (opened) { if (!opened) setDraft(null);
setDraftConditions(conditions); }, [opened]);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
const handleStartDraft = useCallback(() => { const handleStartDraft = useCallback(() => {
const firstProperty = properties[0]; const firstProperty = properties[0];
@@ -257,21 +216,14 @@ export function ViewFilterConfigPopover({
}, [properties]); }, [properties]);
const handleSaveDraft = useCallback(() => { const handleSaveDraft = useCallback(() => {
const nextConditions = draft if (!draft) return;
? [...draftConditions, draft] onChange([...conditions, draft]);
: draftConditions;
onChange(nextConditions);
setDraft(null); setDraft(null);
setUnSaved(false) }, [draft, conditions, onChange]);
}, [draft, draftConditions, onChange]);
const handleCancelDraft = useCallback(() => { const handleCancelDraft = useCallback(() => {
setDraftConditions(conditions)
setDraft(null); setDraft(null);
setUnSaved(false) }, []);
}, [conditions]);
const handleDraftPropertyChange = useCallback( const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => { (propertyId: string | null) => {
@@ -320,19 +272,17 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback( const handleRemove = useCallback(
(index: number) => { (index: number) => {
setUnSaved(true); onChange(conditions.filter((_, i) => i !== index));
setDraftConditions((current) => current.filter((_, i) => i !== index));
}, },
[], [conditions, onChange],
); );
const handlePropertyChange = useCallback( const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => { (index: number, propertyId: string | null) => {
if (!propertyId) return; if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId); const newProperty = properties.find((p) => p.id === propertyId);
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => {
current.map((f, i) => {
if (i !== index) return f; if (i !== index) return f;
if (newProperty) { if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type); const validOperators = getOperatorsForType(newProperty.type);
@@ -352,16 +302,15 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[properties], [conditions, properties, onChange],
); );
const handleOperatorChange = useCallback( const handleOperatorChange = useCallback(
(index: number, operator: string | null) => { (index: number, operator: string | null) => {
if (!operator) return; if (!operator) return;
const op = operator as FilterOperator; const op = operator as FilterOperator;
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => {
current.map((f, i) => {
if (i !== index) return f; if (i !== index) return f;
const kind = inputKindForProperty( const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId), properties.find((p) => p.id === f.propertyId),
@@ -371,17 +320,16 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[properties], [conditions, properties, onChange],
); );
const handleValueChange = useCallback( const handleValueChange = useCallback(
(index: number, value: unknown) => { (index: number, value: unknown) => {
setUnSaved(true) onChange(
setDraftConditions((current) => conditions.map((f, i) => (i === index ? { ...f, value } : f)),
current.map((f, i) => (i === index ? { ...f, value } : f)),
); );
}, },
[], [conditions, onChange],
); );
return ( return (
@@ -414,13 +362,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")} {t("Filter by")}
</Text> </Text>
{draftConditions.length === 0 && !draft && ( {conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("No filters applied")} {t("No filters applied")}
</Text> </Text>
)} )}
{draftConditions.map((condition, index) => { {conditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op); const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find( const property = properties.find(
(p) => p.id === condition.propertyId, (p) => p.id === condition.propertyId,
@@ -523,6 +471,14 @@ export function ViewFilterConfigPopover({
/> />
)} )}
</Group> </Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack> </Stack>
); );
})()} })()}
@@ -536,20 +492,6 @@ export function ViewFilterConfigPopover({
{t("Add filter")} {t("Add filter")}
</UnstyledButton> </UnstyledButton>
)} )}
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
disabled={!draft && !unSaved}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft} disabled={!draft && !unSaved}>
{t("Save")}
</Button>
</Group>
</Stack> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { IBase, IBaseProperty, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types"; import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
export type KanbanGroup = KanbanColumn & { hidden: boolean }; export type KanbanGroup = KanbanColumn & { hidden: boolean };
@@ -8,7 +8,6 @@ export function useKanbanColumns(
view: IBaseView | undefined, view: IBaseView | undefined,
): { ): {
groupByPropertyId: string | undefined; groupByPropertyId: string | undefined;
groupByProperty: IBaseProperty | undefined;
columns: KanbanColumn[]; columns: KanbanColumn[];
allGroups: KanbanGroup[]; allGroups: KanbanGroup[];
hasValidGroupBy: boolean; hasValidGroupBy: boolean;
@@ -19,7 +18,7 @@ export function useKanbanColumns(
const groupable = prop && (prop.type === "select" || prop.type === "status"); const groupable = prop && (prop.type === "select" || prop.type === "status");
if (!groupable || !prop || !view) { if (!groupable || !prop || !view) {
return { groupByPropertyId, groupByProperty: undefined, columns: [], allGroups: [], hasValidGroupBy: false }; return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
} }
const typeOptions = prop.typeOptions as SelectTypeOptions; const typeOptions = prop.typeOptions as SelectTypeOptions;
@@ -48,6 +47,6 @@ export function useKanbanColumns(
}); });
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden); const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
return { groupByPropertyId, groupByProperty: prop, columns, allGroups, hasValidGroupBy: true }; return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
}, [base, view]); }, [base, view]);
} }
@@ -76,21 +76,6 @@
cursor: grabbing; cursor: grabbing;
} }
.columnTitleButton {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 6px;
cursor: pointer;
}
.columnTitleButton:hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.cardList { .cardList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx"; import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx"; import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -7,7 +9,6 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx"; import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts"; import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() { export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery(); const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -19,7 +20,9 @@ export default function Billing() {
return ( return (
<> <>
<DocumentTitle title="Billing" /> <Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" /> <SettingsTitle title="Billing" />
<BillingTrial /> <BillingTrial />
-1
View File
@@ -22,5 +22,4 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
AI_CONTROLS: 'ai:controls',
} as const; } as const;
+5 -2
View File
@@ -1,3 +1,5 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react"; import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
@@ -7,7 +9,6 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx"; import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() { export default function License() {
const [entitlements] = useAtom(entitlementAtom); const [entitlements] = useAtom(entitlementAtom);
@@ -20,7 +21,9 @@ export default function License() {
return ( return (
<> <>
<DocumentTitle title="License" /> <Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" /> <SettingsTitle title="License" />
<ActivateLicenseForm /> <ActivateLicenseForm />
@@ -1,16 +1,17 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core"; import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query"; import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types"; import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table"; import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() { export default function VerifiedPages() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -67,7 +68,11 @@ export default function VerifiedPages() {
return ( return (
<> <>
<DocumentTitle title={t("Verified pages")} /> <Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Verified pages")} /> <SettingsTitle title={t("Verified pages")} />
+7 -2
View File
@@ -1,13 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx"; import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() { export default function CloudLogin() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<DocumentTitle title={t("Login")} /> <Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm /> <CloudLoginForm />
</> </>
@@ -1,11 +1,14 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx"; import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react"; import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { getAppName } from "@/lib/config.ts";
export default function CreateWorkspace() { export default function CreateWorkspace() {
return ( return (
<> <>
<DocumentTitle title="Create Workspace" /> <Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>
); );
@@ -1,4 +1,5 @@
import { isCloud } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { import {
Alert, Alert,
@@ -36,7 +37,6 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types"; import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5; const SCIM_TOKEN_LIMIT = 5;
@@ -64,7 +64,9 @@ export default function Security() {
return ( return (
<> <>
<DocumentTitle title="Security" /> <Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <EnforceMfa />
@@ -41,7 +41,6 @@ export default function ReadonlyTemplateEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={template.content} content={template.content}
/> />
@@ -22,6 +22,8 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks"; import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions"; import { templateExtensions } from "@/features/editor/extensions/extensions";
import { import {
@@ -42,7 +44,6 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css"; import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() { export default function TemplateEditor() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -87,7 +88,6 @@ export default function TemplateEditor() {
const editor = useEditor({ const editor = useEditor({
extensions: templateExtensions, extensions: templateExtensions,
content: "", content: "",
textDirection: "auto",
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
@@ -247,7 +247,11 @@ export default function TemplateEditor() {
return ( return (
<> <>
<DocumentTitle title={t("Edit template")} /> <Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
{editorToolbarEnabled && editor && ( {editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode /> <FixedToolbar editor={editor} templateMode />
@@ -13,9 +13,11 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { import {
useGetTemplatesQuery, useGetTemplatesQuery,
useDeleteTemplateMutation, useDeleteTemplateMutation,
@@ -29,7 +31,6 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal"; import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() { export default function TemplateList() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -101,7 +102,11 @@ export default function TemplateList() {
return ( return (
<> <>
<DocumentTitle title={t("Templates")} /> <Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<Container size="900" pt="xl"> <Container size="900" pt="xl">
<Group justify="space-between" mb="xl"> <Group justify="space-between" mb="xl">
@@ -1,59 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypePpt,
IconFileTypeXls,
IconFileZip,
IconMovie,
IconMusic,
IconPhoto,
type Icon,
} from "@tabler/icons-react";
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
".pdf": { icon: IconFileTypePdf, color: "red" },
".doc": { icon: IconFileTypeDocx, color: "blue" },
".docx": { icon: IconFileTypeDocx, color: "blue" },
".xls": { icon: IconFileTypeXls, color: "teal" },
".xlsx": { icon: IconFileTypeXls, color: "teal" },
".csv": { icon: IconFileTypeCsv, color: "teal" },
".ppt": { icon: IconFileTypePpt, color: "orange" },
".pptx": { icon: IconFileTypePpt, color: "orange" },
".zip": { icon: IconFileZip, color: "gray" },
".rar": { icon: IconFileZip, color: "gray" },
".7z": { icon: IconFileZip, color: "gray" },
".tar": { icon: IconFileZip, color: "gray" },
".gz": { icon: IconFileZip, color: "gray" },
};
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
{ prefix: "image/", icon: IconPhoto, color: "grape" },
{ prefix: "video/", icon: IconMovie, color: "violet" },
{ prefix: "audio/", icon: IconMusic, color: "pink" },
];
interface AttachmentFileIconProps {
fileExt?: string;
mimeType?: string;
}
export function AttachmentFileIcon({
fileExt,
mimeType,
}: AttachmentFileIconProps) {
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
const byMime = mimeType
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
: undefined;
const { icon: FileIcon, color } = byExt ??
byMime ?? { icon: IconFile, color: "gray" };
return (
<ThemeIcon variant="light" color={color} size={40} radius="md">
<FileIcon size={22} stroke={1.5} />
</ThemeIcon>
);
}
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Anchor,
Center,
Group,
Loader,
Modal,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { SearchInput } from "@/components/common/search-input.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
import { formatBytes } from "@/lib";
import { getFileUrl } from "@/lib/config.ts";
import { formattedDate } from "@/lib/time.ts";
interface PageAttachmentsModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageAttachmentsModal({
pageId,
open,
onClose,
}: PageAttachmentsModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={open}
onClose={onClose}
title={t("Attachments")}
size={800}
closeButtonProps={{ "aria-label": t("Close") }}
>
<PageAttachmentsList pageId={pageId} />
</Modal>
);
}
function PageAttachmentsList({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const {
data,
isLoading,
isError,
isFetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageAttachmentsQuery(pageId, search);
const attachments = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetching) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetching]);
const handleSearch = useCallback((value: string) => setSearch(value), []);
return (
<>
<SearchInput
onSearch={handleSearch}
placeholder={t("Search attachments...")}
/>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : isError ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{t("Error loading attachments.")}
</Text>
</Center>
) : attachments.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
{search
? t("No results found")
: t("No attachments on this page yet.")}
</Text>
</Center>
) : (
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
{attachments.map((attachment) => (
<AttachmentRow key={attachment.id} attachment={attachment} />
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea.Autosize>
)}
</>
);
}
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
const { t } = useTranslation();
const fileUrl = getFileUrl(attachment.url);
return (
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
<AttachmentFileIcon
fileExt={attachment.fileExt}
mimeType={attachment.mimeType}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Anchor
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
fw={500}
c="inherit"
truncate="end"
style={{ display: "block" }}
>
{attachment.fileName}
</Anchor>
<Text size="xs" c="dimmed" mt={2} truncate="end">
{formatBytes(Number(attachment.fileSize))}
{" · "}
{formattedDate(new Date(attachment.createdAt))}
</Text>
</div>
{attachment.creator && (
<Tooltip
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
withArrow
>
<CustomAvatar
avatarUrl={attachment.creator.avatarUrl}
name={attachment.creator.name}
size="sm"
/>
</Tooltip>
)}
<Tooltip label={t("Download attachment")} withArrow>
<ActionIcon
component="a"
href={fileUrl}
download={attachment.fileName}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
aria-label={t("Download {{name}}", { name: attachment.fileName })}
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
</Group>
);
}
@@ -1,25 +0,0 @@
import {
InfiniteData,
keepPreviousData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
import { IPagination } from "@/lib/types.ts";
export function usePageAttachmentsQuery(
pageId: string,
search?: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
return useInfiniteQuery({
queryKey: ["page-attachments", pageId, search],
queryFn: ({ pageParam }) =>
getPageAttachments(pageId, { cursor: pageParam, query: search }),
enabled: !!pageId,
gcTime: 0,
placeholderData: keepPreviousData,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -3,17 +3,7 @@ import loadImage from "blueimp-load-image";
import { import {
AvatarIconType, AvatarIconType,
IAttachment, IAttachment,
IPageAttachment,
} from "@/features/attachments/types/attachment.types.ts"; } from "@/features/attachments/types/attachment.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getPageAttachments(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPageAttachment>> {
const req = await api.post("/pages/attachments", { pageId, ...params });
return req.data;
}
async function compressAndResizeIcon( async function compressAndResizeIcon(
file: File, file: File,
@@ -1,5 +1,4 @@
export { export {
getPageAttachments,
uploadIcon, uploadIcon,
uploadUserAvatar, uploadUserAvatar,
uploadSpaceIcon, uploadSpaceIcon,
@@ -15,15 +15,6 @@ export interface IAttachment {
deletedAt: string | null; deletedAt: string | null;
} }
export interface IPageAttachment extends IAttachment {
url: string;
creator: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
}
export enum AvatarIconType { export enum AvatarIconType {
AVATAR = "avatar", AVATAR = "avatar",
SPACE_ICON = "space-icon", SPACE_ICON = "space-icon",
@@ -103,7 +103,6 @@ const CommentEditor = forwardRef(
}, },
content: defaultContent, content: defaultContent,
editable, editable,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
@@ -16,14 +16,6 @@ export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false); export const showLinkMenuAtom = atom(false);
export type LightboxRequest = {
src: string;
type: "image" | "video";
} | null;
const initialLightboxRequest: LightboxRequest = null;
export const lightboxRequestAtom = atom(initialLightboxRequest);
// Current page's edit mode — initialized from the user's saved preference on // Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server. // first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit); export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
@@ -1,46 +0,0 @@
import {
HocuspocusProviderWebsocket,
WebSocketStatus,
} from "@hocuspocus/provider";
import { getCollaborationUrl } from "@/lib/config.ts";
const RELEASE_GRACE_MS = 5000;
let socket: HocuspocusProviderWebsocket | null = null;
let editorCount = 0;
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
export function getCollabSocket(): HocuspocusProviderWebsocket {
if (!socket) {
socket = new HocuspocusProviderWebsocket({
url: getCollaborationUrl(),
autoConnect: false,
});
}
return socket;
}
export function acquireCollabSocket(): void {
editorCount++;
if (releaseTimer) {
clearTimeout(releaseTimer);
releaseTimer = null;
}
const collabSocket = getCollabSocket();
collabSocket.shouldConnect = true;
if (collabSocket.status === WebSocketStatus.Disconnected) {
collabSocket.connect();
}
}
export function releaseCollabSocket(): void {
editorCount--;
if (editorCount > 0) return;
if (releaseTimer) clearTimeout(releaseTimer);
releaseTimer = setTimeout(() => {
releaseTimer = null;
if (editorCount === 0) {
socket?.disconnect();
}
}, RELEASE_GRACE_MS);
}
@@ -2,8 +2,6 @@ import type { Editor, Range } from "@tiptap/core";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import i18n from "@/i18n.ts";
import { getApiErrorMessage } from "@/lib/api-error";
function findBaseEmbedPlaceholderPos( function findBaseEmbedPlaceholderPos(
editor: Editor, editor: Editor,
@@ -52,7 +50,7 @@ export async function insertBaseEmbedBlock(
return true; return true;
}) })
.run(); .run();
} catch (err) { } catch {
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey); const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
if (pos !== null) { if (pos !== null) {
editor editor
@@ -64,9 +62,6 @@ export async function insertBaseEmbedBlock(
}) })
.run(); .run();
} }
notifications.show({ notifications.show({ message: "Failed to create base", color: "red" });
message: getApiErrorMessage(err, i18n.t("Failed to create base")),
color: "red",
});
} }
} }
@@ -23,21 +23,11 @@ import {
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
isCellSelection,
isEditorReady,
isTextSelected,
} from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
showAiMenuAtom, import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -227,12 +217,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group> <ActionIcon.Group>
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip <Tooltip key={index} label={t(item.name)} withArrow>
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon <ActionIcon
key={index} key={index}
variant="default" variant="default"
@@ -241,9 +226,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)} aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })} className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => onClick={() => isEditorReady(props.editor) && item.command()}
isEditorReady(props.editor) && item.command()
}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -273,9 +256,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px" radius="6px"
aria-label={t(commentItem.name)} aria-label={t(commentItem.name)}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => onClick={() => isEditorReady(props.editor) && commentItem.command()}
isEditorReady(props.editor) && commentItem.command()
}
> >
<IconMessage size={16} stroke={2} /> <IconMessage size={16} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -129,7 +129,8 @@ function handleColorKeyNav(
grid: "text" | "highlight", grid: "text" | "highlight",
) { ) {
const cols = COLOR_GRID_COLS; const cols = COLOR_GRID_COLS;
const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length; const total =
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols; const col = index % cols;
if (e.key === "ArrowRight") { if (e.key === "ArrowRight") {
@@ -162,7 +163,8 @@ function handleColorKeyNav(
if (prev >= 0) { if (prev >= 0) {
focusSwatch(grid, prev); focusSwatch(grid, prev);
} else if (grid === "highlight") { } else if (grid === "highlight") {
const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols; const lastRowStart =
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1)); focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
} }
return; return;
@@ -220,7 +222,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow withArrow
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow withinPortal={false}> <Tooltip label={t("Text color")} withArrow>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
@@ -397,8 +399,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const lastRowStart = const lastRowStart =
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) * Math.floor(
COLOR_GRID_COLS; (HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
) * COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart); focusSwatch("highlight", lastRowStart);
} }
}} }}
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom); const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return ( return (
<Tooltip label={t("Add link")} withArrow withinPortal={false}> <Tooltip label={t("Add link")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
@@ -91,12 +91,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen} onChange={setIsOpen}
> >
<Menu.Target> <Menu.Target>
<Tooltip <Tooltip label={t("Text align")} withArrow disabled={isOpen}>
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button <Button
variant="default" variant="default"
style={{ border: "none", height: "34px" }} style={{ border: "none", height: "34px" }}
@@ -1,176 +0,0 @@
import type { Editor } from "@tiptap/react";
import type { Node as PMNode } from "@tiptap/pm/model";
import Lightbox, { type Slide } from "yet-another-react-lightbox";
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useEffect, useMemo, useState } from "react";
import i18n from "@/i18n.ts";
import { useTranslation } from "react-i18next";
type LightboxViewProps = {
editor: Editor;
open: boolean;
src: string;
type: "image" | "video";
onClose: () => void;
};
function getVideoMimeType(src: string) {
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
switch (extension) {
case "webm":
return "video/webm";
case "ogv":
return "video/ogg";
case "mov":
return "video/quicktime";
case "m4v":
return "video/x-m4v";
default:
return "video/mp4";
}
}
function getFilename(src: string) {
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
if (!filename) return i18n.t("Media");
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
const caption = alt || filename;
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
title: caption,
download: { url: src, filename },
};
} else {
return {
type: "image",
src,
alt: alt || undefined,
title: caption,
download: { url: src, filename },
};
}
}
const LIGHTBOX_NODE_TYPES: Record<string, "image" | "video"> = {
image: "image",
video: "video",
drawio: "image",
excalidraw: "image",
};
// video is excluded: clicks there operate the native controls
const CLICK_TO_EXPAND_NODE_TYPES = new Set(["image", "drawio", "excalidraw"]);
export function getLightboxClickRequest(node: PMNode): LightboxRequest {
if (!CLICK_TO_EXPAND_NODE_TYPES.has(node.type.name)) return null;
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!src) return null;
return { src: getFileUrl(src), type: "image" };
}
function getPageMedia(editor: Editor): Slide[] {
const media: Slide[] = [];
editor.state.doc.descendants((node) => {
const type = LIGHTBOX_NODE_TYPES[node.type.name];
if (!type) return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return;
media.push(getMedia(rawSrc, type, node.attrs.alt));
});
return media;
}
export default function LightboxView({
editor,
open,
src,
type,
onClose,
}: LightboxViewProps) {
const { i18n: i18nInstance } = useTranslation();
const selectedSlide = useMemo(
() => getMedia(src, type),
[src, type, i18nInstance.language]
);
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setLoadedMediaKey(null);
const frame = requestAnimationFrame(() => {
const slides = getPageMedia(editor);
setPageSlides(slides);
setLoadedMediaKey(`${type}:${src}`);
});
return () => cancelAnimationFrame(frame);
}, [editor, open, type, src]);
const slides = loadedMediaKey === `${type}:${src}` ? pageSlides : [selectedSlide];
const index = useMemo(() => {
if (!(pageSlides.length > 0)) {
return 0;
}
const idx = slides.findIndex((slide) =>
type === "video"
? "sources" in slide && slide.sources.some((s) => s.src === src)
: "src" in slide && slide.src === src
);
return idx >= 0 ? idx : 0;
}, [slides, src, type]);
return (
<Lightbox
open={open}
close={onClose}
index={index}
slides={slides}
plugins={[Captions, Download, Fullscreen, Video, Zoom]}
styles={{ container: { backgroundColor: "rgba(0, 0, 0, 0.8)" } }}
captions={{ descriptionTextAlign: "center" }}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
maxZoomPixelRatio: 4,
maxZoom: 4,
supports: ["video"],
}}
/>
);
}
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -25,7 +24,6 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
@@ -41,12 +39,10 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -332,23 +328,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -26,7 +25,6 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
@@ -39,7 +37,6 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
@@ -50,7 +47,6 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] = const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null); useState<ExcalidrawImperativeAPI>(null);
@@ -363,23 +359,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -6,7 +6,6 @@
z-index: 99; z-index: 99;
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 45px;
background: var(--mantine-color-body); background: var(--mantine-color-body);
border-bottom: 1px solid border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
@@ -31,6 +31,8 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
return ( return (
<> <>
<div <div
@@ -47,8 +49,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<div className={classes.divider} /> <div className={classes.divider} />
</> </>
)} */} )} */}
{editor && state && (
<>
<BlockTypeGroup editor={editor} /> <BlockTypeGroup editor={editor} />
<div className={classes.divider} /> <div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} /> <InlineMarksGroup editor={editor} state={state} />
@@ -65,8 +65,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
<MoreInsertsGroup editor={editor} templateMode={templateMode} /> <MoreInsertsGroup editor={editor} templateMode={templateMode} />
<div className={classes.divider} /> <div className={classes.divider} />
<HistoryGroup editor={editor} state={state} /> <HistoryGroup editor={editor} state={state} />
</>
)}
</div> </div>
</div> </div>
<div className={classes.spacer} aria-hidden /> <div className={classes.spacer} aria-hidden />
@@ -1,6 +1,6 @@
import { FC } from "react"; import { FC } from "react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { ActionIcon, Badge, Menu, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { import {
IconAppWindow, IconAppWindow,
IconCalendar, IconCalendar,
@@ -12,7 +12,6 @@ import {
IconMathFunction, IconMathFunction,
IconRotate2, IconRotate2,
IconSitemap, IconSitemap,
IconSuperscript,
IconTable, IconTable,
IconTag, IconTag,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -33,9 +32,6 @@ import {
} from "@/components/icons"; } from "@/components/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed"; import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface Props { interface Props {
editor: Editor; editor: Editor;
@@ -44,8 +40,6 @@ interface Props {
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => { export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const hasBases = useHasFeature(Feature.BASES);
const upgradeLabel = useUpgradeLabel();
const setEmbed = (provider: string) => const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run(); editor.chain().focus().setEmbed({ provider }).run();
@@ -112,48 +106,20 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
</Menu.Item> </Menu.Item>
)} )}
{!templateMode && ( {!templateMode && (
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
<Menu.Item <Menu.Item
leftSection={<IconTable size={16} />} leftSection={<IconTable size={16} />}
aria-disabled={!hasBases} onClick={() => insertBaseEmbedBlock(editor)}
closeMenuOnClick={hasBases}
style={{ opacity: hasBases ? undefined : 0.7 }}
rightSection={
!hasBases && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)
}
onClick={() => {
if (hasBases) insertBaseEmbedBlock(editor);
}}
> >
{t("Base (Inline)")} {t("Base (Inline)")}
</Menu.Item> </Menu.Item>
</Tooltip>
)} )}
{!templateMode && ( {!templateMode && (
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
<Menu.Item <Menu.Item
leftSection={<IconLayoutKanban size={16} />} leftSection={<IconLayoutKanban size={16} />}
aria-disabled={!hasBases} onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
closeMenuOnClick={hasBases}
style={{ opacity: hasBases ? undefined : 0.7 }}
rightSection={
!hasBases && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)
}
onClick={() => {
if (hasBases) insertBaseEmbedBlock(editor, { template: "kanban" });
}}
> >
{t("Kanban")} {t("Kanban")}
</Menu.Item> </Menu.Item>
</Tooltip>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -271,12 +237,6 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
> >
{t("Math block")} {t("Math block")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconSuperscript size={16} />}
onClick={() => editor.chain().focus().addFootnote().run()}
>
{t("Footnote")}
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react"; import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -17,19 +16,16 @@ import {
IconDownload, IconDownload,
IconRefresh, IconRefresh,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -211,23 +207,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -10,7 +10,6 @@ import {
Paper, Paper,
ScrollArea, ScrollArea,
Text, Text,
Tooltip,
UnstyledButton, UnstyledButton,
VisuallyHidden, VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
@@ -19,7 +18,6 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
const CommandList = ({ const CommandList = ({
items, items,
@@ -39,12 +37,11 @@ const CommandList = ({
const [selectionAnnouncement, setSelectionAnnouncement] = useState(""); const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const hasBases = useHasFeature(Feature.BASES); const hasBases = useHasFeature(Feature.BASES);
const upgradeLabel = useUpgradeLabel(); // Title must match the "Base (Inline)" item in menu-items.ts. Without the
// Without the bases entitlement the item stays visible but inert; an // bases entitlement the item stays visible but disabled; an expired license
// expired license the client can't detect falls through to a handled // the client can't detect falls through to a handled create failure.
// create failure.
const isItemDisabled = (item: SlashMenuItemType) => const isItemDisabled = (item: SlashMenuItemType) =>
!hasBases && item.requiresBases === true; !hasBases && item.title === "Base (Inline)";
const flatItems = useMemo(() => { const flatItems = useMemo(() => {
return Object.values(items).flat(); return Object.values(items).flat();
@@ -155,22 +152,18 @@ const CommandList = ({
const itemIndex = flatIndex; const itemIndex = flatIndex;
const disabled = isItemDisabled(item); const disabled = isItemDisabled(item);
return ( return (
<Tooltip
key={itemIndex}
label={upgradeLabel}
disabled={!disabled}
position="right"
>
<UnstyledButton <UnstyledButton
data-item-index={itemIndex} data-item-index={itemIndex}
key={itemIndex}
id={`slash-command-option-${itemIndex}`} id={`slash-command-option-${itemIndex}`}
role="option" role="option"
aria-selected={itemIndex === selectedIndex} aria-selected={itemIndex === selectedIndex}
aria-disabled={disabled} aria-disabled={disabled}
disabled={disabled}
onClick={() => selectItem(itemIndex)} onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex, [classes.selectedItem]: itemIndex === selectedIndex,
[classes.gatedItem]: disabled, [classes.disabledItem]: disabled,
})} })}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -195,7 +188,6 @@ const CommandList = ({
)} )}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Tooltip>
); );
})} })}
</div> </div>
@@ -30,7 +30,6 @@ import {
IconTag, IconTag,
IconMoodSmile, IconMoodSmile,
IconRotate2, IconRotate2,
IconSuperscript,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -178,16 +177,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setPageBreak().run(), editor.chain().focus().deleteRange(range).setPageBreak().run(),
}, },
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "reference", "citation", "note"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
editor.commands.addFootnote();
},
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -377,7 +366,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert an inline base on this page", description: "Insert an inline base on this page",
searchTerms: ["base", "database", "table", "grid", "spreadsheet"], searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
icon: IconTable, icon: IconTable,
requiresBases: true,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range }); insertBaseEmbedBlock(editor, { range });
}, },
@@ -387,7 +375,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert a kanban board on this page", description: "Insert a kanban board on this page",
searchTerms: ["kanban", "board", "cards", "status", "task", "database"], searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
icon: IconLayoutKanban, icon: IconLayoutKanban,
requiresBases: true,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range, template: "kanban" }); insertBaseEmbedBlock(editor, { range, template: "kanban" });
}, },
@@ -26,6 +26,7 @@
} }
} }
.gatedItem { .disabledItem {
opacity: 0.7; opacity: 0.45;
cursor: not-allowed;
} }
@@ -21,7 +21,6 @@ export type SlashMenuItemType = {
searchTerms: string[]; searchTerms: string[];
command: (props: CommandProps) => void; command: (props: CommandProps) => void;
disable?: (editor: ReturnType<typeof useEditor>) => boolean; disable?: (editor: ReturnType<typeof useEditor>) => boolean;
requiresBases?: true;
}; };
export type SlashMenuGroupedItemsType = { export type SlashMenuGroupedItemsType = {
@@ -13,21 +13,14 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils"; import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { extractPageSlugId } from "@/lib";
export default function SubpagesView(props: NodeViewProps) { export default function SubpagesView(props: NodeViewProps) {
const { editor } = props; const { editor } = props;
const { spaceSlug, shareId, pageSlug } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
// @ts-ignore //@ts-ignore
const storagePageId = editor.storage.pageId; const currentPageId = editor.storage.pageId;
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
if (shareId){
currentPageId = routePageId;
}
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -40,7 +40,6 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={content as any} content={content as any}
/> />
@@ -1,7 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -16,17 +15,14 @@ import {
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload, IconDownload,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -187,23 +183,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -1,11 +1,9 @@
import { markInputRule } from "@tiptap/core"; import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code"; import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { CharacterCount, UndoRedo } from "@tiptap/extensions"; import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Placeholder } from "@/features/editor/extensions/placeholder";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -64,9 +62,6 @@ import {
TransclusionReference, TransclusionReference,
TableView, TableView,
BaseEmbed as BaseEmbedNode, BaseEmbed as BaseEmbedNode,
Footnotes,
Footnote,
FootnoteReference,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -136,7 +131,6 @@ lowlight.register("scala", scala);
// @ts-ignore // @ts-ignore
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
document: false,
heading: false, heading: false,
undoRedo: false, undoRedo: false,
link: false, link: false,
@@ -148,9 +142,6 @@ export const mainExtensions = [
codeBlock: false, codeBlock: false,
code: false, code: false,
}), }),
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule. // Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule // before the opening backtick as part of the match, causing markInputRule
@@ -203,20 +194,17 @@ export const mainExtensions = [
return i18n.t("Toggle title"); return i18n.t("Toggle title");
} }
if (node.type.name === "paragraph") { if (node.type.name === "paragraph") {
const doc = editor.state.doc; const $pos = editor.state.doc.resolve(pos);
if (pos >= 0 && pos <= doc.content.size) { const parentName = $pos.parent.type.name;
const parentName = doc.resolve(pos).parent.type.name;
if ( if (
parentName === "column" || parentName === "column" ||
parentName === "tableCell" || parentName === "tableCell" ||
parentName === "tableHeader" || parentName === "tableHeader" ||
parentName === "callout" || parentName === "callout" ||
parentName === "blockquote" || parentName === "blockquote"
parentName === "footnote"
) { ) {
return i18n.t("Write..."); return i18n.t("Write...");
} }
}
return i18n.t('Write anything. Enter "/" for commands'); return i18n.t('Write anything. Enter "/" for commands');
} }
}, },
@@ -426,9 +414,6 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({ AutoJoiner.configure({
elementsToJoin: [], elementsToJoin: [],
}), }),
@@ -1,64 +0,0 @@
import { isNodeEmpty } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Placeholder as TiptapPlaceholder } from "@tiptap/extensions";
export const Placeholder = TiptapPlaceholder.extend({
addProseMirrorPlugins() {
const editor = this.editor;
const options = this.options;
const dataAttribute = `data-${options.dataAttribute || "placeholder"}`;
return [
new Plugin({
key: new PluginKey("docmostPlaceholder"),
props: {
decorations: (state) => {
if (options.showOnlyWhenEditable && !editor.isEditable) {
return null;
}
const { doc, selection } = state;
const { anchor } = selection;
const decorations: Decoration[] = [];
const isEmptyDoc = editor.isEmpty;
doc.descendants((node, pos) => {
if (!node.type.isTextblock) {
return options.includeChildren;
}
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
const isEmpty = !node.isLeaf && isNodeEmpty(node);
if ((hasAnchor || !options.showOnlyCurrent) && isEmpty) {
const emptyNodeClass =
typeof options.emptyNodeClass === "function"
? options.emptyNodeClass({ editor, node, pos, hasAnchor })
: options.emptyNodeClass;
const classes = [emptyNodeClass];
if (isEmptyDoc) {
classes.push(options.emptyEditorClass);
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(" "),
[dataAttribute]:
typeof options.placeholder === "function"
? options.placeholder({ editor, node, pos, hasAnchor })
: options.placeholder,
}),
);
}
return options.includeChildren;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,7 @@
import { getCollaborationUrl } from "@/lib/config.ts";
const useCollaborationURL = (): string => {
return getCollaborationUrl();
};
export default useCollaborationURL;
+139 -188
View File
@@ -2,22 +2,20 @@ import "@/features/editor/styles/index.css";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import { import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters, onStatelessParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import {
HocuspocusProviderWebsocketComponent,
HocuspocusRoom,
useHocuspocusEvent,
useHocuspocusProvider,
} from "@hocuspocus/provider-react";
import { import {
Editor, Editor,
EditorContent, EditorContent,
@@ -30,10 +28,10 @@ import {
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom, currentPageEditModeAtom,
lightboxRequestAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
yjsSyncedAtom, yjsSyncedAtom,
@@ -54,9 +52,6 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx"; import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
import { import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
@@ -81,11 +76,6 @@ import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
acquireCollabSocket,
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -101,19 +91,64 @@ export default function PageEditor({
canComment, canComment,
}: PageEditorProps) { }: PageEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const collaborationURL = useCollaborationUrl();
const { pageSlug } = useParams(); const isComponentMounted = useRef(false);
const slugId = extractPageSlugId(pageSlug); const editorRef = useRef<Editor | null>(null);
const [socket] = useState(getCollabSocket);
const hasCollabToken = !!collabQuery?.token;
useEffect(() => { useEffect(() => {
if (!hasCollabToken) return; isComponentMounted.current = true;
acquireCollabSocket(); }, []);
return () => releaseCollabSocket();
}, [hasCollabToken]);
const handleStateless = ({ payload }: onStatelessParameters) => { const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try { try {
const message = JSON.parse(payload); const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return; if (message?.type !== "page.updated" || !message.updatedAt) return;
@@ -131,99 +166,52 @@ export default function PageEditor({
// ignore unrelated stateless messages // ignore unrelated stateless messages
} }
}; };
const onAuthenticationFailedHandler = () => {
const handleAuthenticationFailed = () => {
const payload = jwtDecode(collabQuery?.token); const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000; const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp; const isTokenExpired = now >= payload.exp;
if (isTokenExpired) { if (isTokenExpired) {
refetchCollabToken(); refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
} }
}; };
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
return ( local.on("synced", onLocalSyncedHandler);
<TransclusionLookupProvider> providersRef.current = { socket, local, remote };
{collabQuery?.token ? ( setProvidersReady(true);
<HocuspocusProviderWebsocketComponent websocketProvider={socket}> } else {
<HocuspocusRoom setProvidersReady(true);
name={`page.${pageId}`} }
token={collabQuery.token} // Only destroy on final unmount
flushDelay={500}
onStateless={handleStateless}
onAuthenticationFailed={handleAuthenticationFailed}
>
<CollabPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</HocuspocusRoom>
</HocuspocusProviderWebsocketComponent>
) : (
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
)}
</TransclusionLookupProvider>
);
}
function CollabPageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const provider = useHocuspocusProvider();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
useEffect(() => {
const local = new IndexeddbPersistence(
provider.configuration.name,
provider.document,
);
local.on("synced", () => setIsLocalSynced(true));
return () => { return () => {
local.destroy(); providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
}; };
}, [provider]); }, [pageId]);
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state));
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status));
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
const socket = provider.configuration.websocketProvider; if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if ( if (
isIdle && isIdle &&
@@ -240,32 +228,28 @@ function CollabPageEditor({
resetIdle(); resetIdle();
socket.connect(); socket.connect();
} }
}, [isIdle, documentState, provider, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!currentUser?.user) { if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions; return mainExtensions;
} }
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)]; const remoteProvider = providersRef.current.remote;
}, [provider, currentUser?.user]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { return [
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
if (pageData) { ];
queryClient.setQueryData(["pages", slugId], { }, [providersReady, currentUser?.user]);
...pageData,
content: newContent,
});
}
}, 3000);
const editor = useEditor( const editor = useEditor(
{ {
extensions, extensions,
editable, editable,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
@@ -321,22 +305,6 @@ function CollabPageEditor({
return handleFileDrop(editorRef.current, event, moved, pageId); return handleFileDrop(editorRef.current, event, moved, pageId);
}, },
handleClickOn: (view, _pos, node) => {
if (view.editable) return false;
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
handleDoubleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
@@ -358,16 +326,6 @@ function CollabPageEditor({
[pageId, editable, extensions], [pageId, editable, extensions],
); );
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
editorRef.current = editor;
}
}, [editor, pageId, setEditor]);
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
editor, editor,
selector: (ctx) => { selector: (ctx) => {
@@ -375,6 +333,17 @@ function CollabPageEditor({
}, },
}); });
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const handleActiveCommentEvent = (event) => { const handleActiveCommentEvent = (event) => {
const { commentId, resolved } = event.detail; const { commentId, resolved } = event.detail;
@@ -407,7 +376,6 @@ function CollabPageEditor({
setActiveCommentId(null); setActiveCommentId(null);
setShowCommentPopup(false); setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]); }, [pageId]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
@@ -448,11 +416,21 @@ function CollabPageEditor({
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) {
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
}
return ( return (
<TransclusionLookupProvider>
{showStatic ? (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}> <div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
@@ -478,19 +456,13 @@ function CollabPageEditor({
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
</div> </div>
)} )}
{editor && !editorIsEditable && (editable || canComment) && ( {editor &&
<ReadonlyBubbleMenu editor={editor} /> !editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)} )}
{editor && (
<LightboxView
editor={editor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && ( {showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly /> <CommentDialog editor={editor} pageId={pageId} readOnly />
)} )}
@@ -502,28 +474,7 @@ function CollabPageEditor({
style={{ paddingBottom: "20vh" }} style={{ paddingBottom: "20vh" }}
></div> ></div>
</div> </div>
); )}
} </TransclusionLookupProvider>
function StaticPageEditor({
content,
ariaLabel,
}: {
content: any;
ariaLabel: string;
}) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
); );
} }
@@ -1,27 +1,15 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { import React, { useCallback, useEffect, useMemo, useRef } from "react";
useCallback, import { EditorProvider } from "@tiptap/react";
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Editor, EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
lightboxRequestAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -45,8 +33,6 @@ export default function ReadonlyPageEditor({
shareId, shareId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorCreated = useRef(false);
@@ -63,11 +49,6 @@ export default function ReadonlyPageEditor({
isComponentMounted.current = true; isComponentMounted.current = true;
}, []); }, []);
useEffect(() => {
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, shareId]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const excludedExtensions = new Set([ const excludedExtensions = new Set([
"uniqueID", "uniqueID",
@@ -104,7 +85,6 @@ export default function ReadonlyPageEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions} extensions={titleExtensions}
content={title} content={title}
></EditorProvider> ></EditorProvider>
@@ -113,22 +93,8 @@ export default function ReadonlyPageEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={extensions} extensions={extensions}
content={content} content={content}
editorProps={
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}
: undefined
}
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
@@ -137,22 +103,12 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
setContentEditor(editor);
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
<div style={{ paddingBottom: "20vh" }}></div> <div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider> </TransclusionLookupProvider>
); );
@@ -1,26 +0,0 @@
.ProseMirror sup a.footnote-ref {
color: var(--mantine-primary-color-filled);
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.ProseMirror sup:has(a.footnote-ref) {
padding: 0 1px;
}
.ProseMirror ol.footnotes {
margin-top: 2rem;
padding-top: 0.75rem;
font-size: 0.875rem;
color: var(--mantine-color-dimmed);
list-style-type: decimal;
}
.ProseMirror ol.footnotes:has(li) {
border-top: 1px solid var(--mantine-color-default-border);
}
.ProseMirror ol.footnotes li p {
margin: 0.15rem 0;
}
@@ -18,4 +18,3 @@
@import "./columns.css"; @import "./columns.css";
@import "./status.css"; @import "./status.css";
@import "./base-embed.css"; @import "./base-embed.css";
@import "./footnotes.css";
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5) var(--mantine-color-dark-5)
); );
font-weight: bold; font-weight: bold;
text-align: start; text-align: left;
} }
.column-resize-handle { .column-resize-handle {
@@ -86,7 +86,6 @@ export function TitleEditor({
}, },
editable: editable, editable: editable,
content: title, content: title,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
@@ -39,7 +39,6 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }), queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled, enabled,
staleTime: 30 * 1000, staleTime: 30 * 1000,
placeholderData: keepPreviousData
}); });
} }
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true); export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number }; export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>( export const diffCountsAtom = atom<DiffCounts | null>(null);
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
@@ -1,7 +1,7 @@
.history { .history {
display: flex; display: block;
align-items: center;
width: 100%; width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover { @mixin hover {
@@ -12,28 +12,6 @@
} }
} }
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor { .historyEditor {
:global(.ProseMirror) { :global(.ProseMirror) {
padding: 0 !important; padding: 0 !important;
@@ -99,8 +77,3 @@
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -31,7 +31,6 @@ export function HistoryEditor({
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
textDirection: "auto",
}); });
useEffect(() => { useEffect(() => {
@@ -171,6 +170,7 @@ export function HistoryEditor({
} }
const total = addedCount + deletedCount; const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total }); setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({ editor.setOptions({
@@ -1,21 +1,10 @@
import { import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5; const MAX_VISIBLE_AVATARS = 5;
@@ -26,13 +15,6 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void; onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void; onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
} }
const HistoryItem = memo(function HistoryItem({ const HistoryItem = memo(function HistoryItem({
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
onHover, onHover,
onHoverEnd, onHoverEnd,
isActive, isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) { }: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index); onSelect(historyItem.id, index);
} }, [onSelect, historyItem.id, index]);
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
@@ -69,42 +37,22 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0; const hasContributors = contributors && contributors.length > 0;
return ( return (
<div
className={clsx(classes.history, { [classes.active]: isActive })}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<UnstyledButton <UnstyledButton
p="xs" p="xs"
onClick={handleClick} onClick={handleClick}
className={classes.historyButton} onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Text size="sm">{date}</Text> <Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<Group gap={6} wrap="nowrap" mt={4}> <Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? ( {hasContributors ? (
<> <>
<Tooltip.Group openDelay={300} closeDelay={100}> <Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}> <Avatar.Group spacing={8}>
{contributors {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
.slice(0, MAX_VISIBLE_AVATARS) <Tooltip key={contributor.id} label={contributor.name} withArrow>
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar <CustomAvatar
size="sm" size="sm"
avatarUrl={contributor.avatarUrl} avatarUrl={contributor.avatarUrl}
@@ -115,9 +63,7 @@ const HistoryItem = memo(function HistoryItem({
{contributors.length > MAX_VISIBLE_AVATARS && ( {contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip <Tooltip
withArrow withArrow
label={contributors label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div> <div key={c.id}>{c.name}</div>
))} ))}
> >
@@ -148,36 +94,6 @@ const HistoryItem = memo(function HistoryItem({
)} )}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</div>
); );
}); });
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { import {
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const { const {
data: pageHistoryData, data: pageHistoryData,
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string, index: number) => { (id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id); setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}, },
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair], [historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
); );
useEffect(() => { useEffect(() => {
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover} onHover={handleHover}
onHoverEnd={clearPrefetchTimeout} onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId} isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/> />
))} ))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />} {hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -213,28 +148,7 @@ function HistoryList({ pageId }: Props) {
)} )}
</ScrollArea> </ScrollArea>
{compareMode ? ( {canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={handleCancelCompare}
>
{t("Cancel")}
</Button>
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button>
</Group>
</>
) : (
canRestore && (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
@@ -245,12 +159,11 @@ function HistoryList({ pageId }: Props) {
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={() => confirmRestore()}> <Button size="compact-md" onClick={confirmRestore}>
{t("Restore")} {t("Restore")}
</Button> </Button>
</Group> </Group>
</> </>
)
)} )}
</div> </div>
); );
@@ -1,6 +1,5 @@
import { import {
ActionIcon, ActionIcon,
CloseButton,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom, diffCountsAtom,
highlightChangesAtom, highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view"; import HistoryView from "@/features/page-history/components/history-view";
import { useMemo, useRef } from "react"; import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useDiffNavigation, useDiffNavigation,
useHistoryReset, useHistoryReset,
} from "@/features/page-history/hooks"; } from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom); const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId); useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } = const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav> </nav>
<div style={{ position: "relative", flex: 1 }}> <div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea <ScrollArea
h={650} h={650}
w="100%" w="100%"
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef} viewportRef={scrollViewportRef}
> >
<div className={classes.sidebarRightSection}> <div className={classes.sidebarRightSection}>
{comparePair ? ( {activeHistoryId && <HistoryView />}
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div> </div>
</ScrollArea> </ScrollArea>
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && ( {activeHistoryId && activeHistoryPrevId && (
<Paper <Paper
shadow="md" shadow="md"
radius="xl" radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}> <Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button> <Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group> </Group>
)} )}
@@ -7,29 +7,21 @@ import {
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
interface Props { function HistoryView() {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const activeId = useAtomValue(activeHistoryIdAtom); const historyId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom); const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const { const {
data, data,
isLoading: isLoadingCurrent, isLoading: isLoadingCurrent,
isError: isErrorCurrent, isError: isErrorCurrent,
} = usePageHistoryQuery(resolvedId); } = usePageHistoryQuery(historyId);
const { const {
data: prevData, data: prevData,
isLoading: isLoadingPrev, isLoading: isLoadingPrev,
isError: isErrorPrev, isError: isErrorPrev,
} = usePageHistoryQuery(resolvedPrevId); } = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) { if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
@@ -3,45 +3,22 @@ import { useEffect } from "react";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom, diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
/** /**
* Resets history state when pageId changes. * Resets history state when pageId changes.
* Clears active selection, diff counts, and compare state. * Clears active selection and diff counts.
* Compare state also resets on unmount so reopening the modal starts clean.
*/ */
export function useHistoryReset(pageId: string) { export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom); const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => { useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId(""); setActiveHistoryId("");
setActiveHistoryPrevId(""); setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null); setDiffCounts(null);
resetCompare(); }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
} }
@@ -1,4 +1,4 @@
import { useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
@@ -9,8 +9,7 @@ import {
activeHistoryIdAtom, activeHistoryIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
const { t } = useTranslation(); const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom); const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom); const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom); const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms); const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -39,19 +40,8 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
); );
const handleRestore = useCallback( const handleRestore = useCallback(() => {
async (historyId: string) => { if (!activeHistoryData) return;
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
if ( if (
!mainEditor || !mainEditor ||
mainEditor.isDestroyed || mainEditor.isDestroyed ||
@@ -64,26 +54,20 @@ export function useHistoryRestore() {
mainEditorTitle mainEditorTitle
.chain() .chain()
.clearContent() .clearContent()
.setContent(historyData.title, { emitUpdate: true }) .setContent(activeHistoryData.title, { emitUpdate: true })
.run(); .run();
mainEditor mainEditor
.chain() .chain()
.clearContent() .clearContent()
.setContent(historyData.content) .setContent(activeHistoryData.content)
.run(); .run();
setHistoryModalOpen(false); setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") }); notifications.show({ message: t("Successfully restored") });
}, }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
const confirmRestore = useCallback(() => {
modals.openConfirmModal({ modals.openConfirmModal({
title: t("Please confirm your action"), title: t("Please confirm your action"),
children: ( children: (
@@ -94,11 +78,9 @@ export function useHistoryRestore() {
</Text> </Text>
), ),
labels: { confirm: t("Confirm"), cancel: t("Cancel") }, labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId), onConfirm: handleRestore,
}); });
}, }, [t, handleRestore]);
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore }; return { canRestore, confirmRestore };
} }
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
}); });
} }
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});

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