Compare commits

..
Author SHA1 Message Date
Philipinho 14840ef065 fix: remove client compression script 2026-08-25 15:43:47 +01:00
Philipinho 83d7ee4460 chore: pnpm minor version 2026-08-25 04:34:11 +01:00
Philipinho fc90fbd8c0 chore: update pnpm 2026-08-25 04:31:23 +01:00
Philip Okugbe a0ec93506c chore: compress and lazy load client code (#2425) 2026-08-25 03:29:56 +01:00
Philipinho a415b86193 fix: clean up unaccent usage in index 2026-08-25 03:28:59 +01:00
Philipinho 702f8b7a41 fix: local editor cache 2026-08-25 02:54:38 +01:00
Salihu 95cef61927 fix: rework filtering behaviour (#2413)
* rework filtering behaviour

* fix classification bug
2026-08-25 02:52:24 +01:00
SalihuandPhilipinho 7bd73f77a4 feat: lightbox media display (#2420)
* lightbox media display init

* revert uninteded changes

* load page media more accurately

* restrict lock file changes to installed packages

* revert changes in lock file

* update lockfile

* - support diagrams
- support readonly mode
- use softer backdrop
- other enhancments

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 02:43:19 +01:00
Salihu 3a25336d3d fix: display subpages correctly on first load (#2423) 2026-08-25 01:39:55 +01:00
SalihuandPhilipinho 917195b2f2 feat: enhanced search filters (#2398)
* advanced search filters

* improve labelId filtering in query

* index label ids on typesense

* index label ids on typesense

* filters on AI search

* refactor: detect typesense schema drift from the declared collection schema

* feat: show secondary search filters only when applied

Created by and Labels now stay hidden until they hold a value; a Filter
button at the right end of the bar lists the hidden ones and opens the
picked filter's dropdown immediately.

* fix: keep applied search filters in the order they were added

* feat: add title-only search mode

Adds a titleOnly flag to page search: the Postgres driver matches
titles via trigram-indexed unaccented LIKE ranked by word_similarity,
and a Match filter in the search spotlight switches between everything
and title-only matching.

* refactor: make title-only search a toggle chip

* fix: drop stale results when the search content type changes

* feat: add trigram index on attachment file names

* feat: extend title-only search to attachments

The titleOnly flag now matches attachment file names via the
separator-normalized trigram expression, and the toggle chip stays
available in attachment mode as File name only.

* refactor: keep the title-only label uniform across search modes

* feat: index txt attachments for content search

* feat: pin the current user at the top of the creator filter

* refactor: dim the you suffix in the creator filter

* feat: browse label-filtered pages without a query

Selecting labels now lists their pages newest-first before any text is
typed, on both the Postgres and Typesense drivers.

* fix: match any selected label instead of requiring all

* fix: use a gray spotlight selection instead of primary blue

* feat: include search results in the spotlight tab order

* feat: show last updated time on search results

* fix: stop empty-state flicker while the search query debounces

* feat: browse creator-filtered results without a query

Selecting a creator with an empty query now lists their pages (or
attachments in attachment mode) newest-first, with the same space and
page permission filtering as typed search.

* fix: show Untitled for pages without a title in search results

* quick page sharing fix

* minor search filter fixes

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 01:39:33 +01:00
Philip Okugbe cd597f0161 chore: remove unused npm modules in Docke build (#2422) 2026-08-24 20:32:50 +01:00
Philip Okugbe b7f0b063dc feat(ee): add enterprise ai chat controls (#2421)
* feat(ee): add enterprise ai chat controls

* feat: add comment listing tool to ai chat

* fix: scope comment endpoints to workspace and skip trashed pages
2026-08-24 20:29:21 +01:00
Salihu cd9c166927 Merge pull request #2417 from docmost/fix/checkbox-filtering
fix(ee): checkbox filtering
2026-08-23 21:21:02 +01:00
Philip Okugbe 549cf7c005 fix: skip pgvector table check when enabling AI search on turbopuffer (#2416) 2026-08-22 13:20:35 +01:00
Philip Okugbe e14f499f3d fix: pass tls to redis in throttle module (#2415) 2026-08-22 03:46:00 +01:00
Salihu b814bd0f12 fix: checkbox filtering 2026-08-21 19:29:10 +01:00
Salihu b86abd3d40 Revert "fix: checkbox filtering"
This reverts commit 3b858746e3.
2026-08-21 19:23:48 +01:00
Salihu 3b858746e3 fix: checkbox filtering 2026-08-21 19:21:14 +01:00
Philipinho 66b424a3b8 fix: preserve hash in vimeo embed url 2026-08-20 22:25:45 +01:00
Philipinho ab43031375 fix race issue 2026-08-19 21:46:01 +01:00
Michael LohrandPhilipinho 8c2c49ea6d fix: handle empty password, missing port, and TLS in Redis URL parsing (#2021)
* fix: handle empty password, missing port, and TLS in Redis URL parsing
---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-19 21:05:44 +01:00
Philip Okugbe 8913d20aa0 chore: package updates (#2412) 2026-08-19 19:59:00 +01:00
Philip Okugbe 232beda471 feat(ee): turbopuffer ai vector search driver (#2402)
* feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration

* feat(ai): carry workspace and target space ids on vector lifecycle events

* feat(ai): add vector driver interface and turbopuffer request helpers

* feat(ai): add pgvector driver behind the vector driver interface

* refactor(ai): route vector reads and writes through the vector driver

* feat(ai): add turbopuffer vector driver

* feat(ai): rebuild turbopuffer namespaces when the embedding model changes

* feat(ai): warm the vector namespace cache on session start

* fix(ai): harden turbopuffer misconfiguration and reset failure paths

* fix(ai): collapse blank-line runs in extracted page text

* fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window

* sync

* fix(ai): store real embedding dimensions instead of serialized vector length

* fix(ai): filter search hits by the page's current space at query time

* fix(ai): retry the page moved-to-space vector patch job

* sync

* feat(ai): pre-warm the vector namespace

* fix(ai): pass AI_VECTOR_DRIVER through the client build config
2026-08-17 21:18:16 +01:00
Philip Okugbe 911c1057d6 feat(server): add global encryption module (AES-256-GCM) (#2400)
Provides an injectable EncryptionService that encrypts/decrypts strings
with AES-256-GCM using a key derived from APP_SECRET with domain
separation.
2026-08-16 12:35:37 +01:00
Philipinho d136864ef1 sync 2026-08-16 11:46:50 +01:00
Philip Okugbe c093c18bf3 fix(client): keep space switcher dropdown inside its popover (#2392) 2026-08-14 02:08:28 +01:00
Philip Okugbe ea59912c7e feat(editor): auto-detect text direction for RTL support (#2389) 2026-08-12 23:48:03 +01:00
Philipinho db3ff54da1 fix base node 2026-08-12 16:47:32 +01:00
Philip Okugbe 9414a38215 feat: page attachments endpoint and modal (#2386)
* feat: page attachments endpoint

* feat: attachments modal
2026-08-12 14:04:33 +01:00
Philipinho 089286f6cf fix: delete pnpm cache 2026-08-12 12:33:34 +01:00
Philip Okugbe 737cd67965 feat: compare two page versions (#2385) 2026-08-12 12:31:02 +01:00
Philip Okugbe a0b2ac6ae3 feat: footnotes (#2384)
* feat: footnotes

* feat: proper DOCX support
2026-08-12 12:30:30 +01:00
Philip Okugbe 7439da2f6e chore: package updates (#2383)
* update

* update versions

* package updates
2026-08-10 17:56:12 +01:00
Philip Okugbe 305fd40686 fix: browser tab showing url instead of page title (#2382) 2026-08-10 17:38:03 +01:00
Philipinho 4bd51d7404 fix: self-reference parent page 2026-08-10 17:37:12 +01:00
176 changed files with 4631 additions and 984 deletions
+11 -2
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base FROM node:26-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.15.1 RUN npm install -g pnpm@11.23.0
FROM base AS builder FROM base AS builder
@@ -18,6 +18,15 @@ 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
@@ -42,7 +51,7 @@ RUN chown -R node:node /app
USER node USER node
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod && rm -rf /home/node/.cache/pnpm
RUN mkdir -p /app/data/storage RUN mkdir -p /app/data/storage
+2 -1
View File
@@ -52,7 +52,7 @@
"mantine-form-zod-resolver": "1.3.0", "mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1", "mermaid": "11.16.1",
"mitt": "3.0.1", "mitt": "3.0.1",
"nanoid": "3.3.17", "nanoid": "3.3.18",
"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",
@@ -64,6 +64,7 @@
"react-router-dom": "7.18.2", "react-router-dom": "7.18.2",
"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": {
@@ -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 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 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 & MCP": "KI & MCP", "AI & MCP": "KI & MCP",
"AI": "KI", "AI": "KI",
"MCP": "MCP", "MCP": "MCP",
@@ -294,6 +294,7 @@
"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",
@@ -387,6 +388,8 @@
"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.",
@@ -696,7 +699,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 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 is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -1289,5 +1292,24 @@
"{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected", "{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} 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 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 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 & MCP": "IA y MCP", "AI & MCP": "IA y MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -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 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 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 & MCP": "IA & MCP", "AI & MCP": "IA & MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -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 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 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 & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -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 only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", "AI is available in the Docmost paid editions. 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",
@@ -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 only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI is available in the Docmost paid editions. 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",
@@ -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 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 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 & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
@@ -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 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 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 & MCP": "IA e MCP", "AI & MCP": "IA e MCP",
"AI": "IA", "AI": "IA",
"MCP": "MCP", "MCP": "MCP",
@@ -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 only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.",
"AI & MCP": "ИИ и MCP", "AI & MCP": "ИИ и MCP",
"AI": "ИИ", "AI": "ИИ",
"MCP": "MCP", "MCP": "MCP",
@@ -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 only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.",
"AI & MCP": "ШІ та MCP", "AI & MCP": "ШІ та MCP",
"AI": "ШІ", "AI": "ШІ",
"MCP": "MCP", "MCP": "MCP",
@@ -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 only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 在 Docmost 企业版中提供。请联系 sales@docmost.com。", "AI is available in the Docmost paid editions. 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",
+77 -45
View File
@@ -1,60 +1,92 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
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 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 Layout from "@/components/layouts/global/layout.tsx";
import InviteSignup from "@/pages/auth/invite-signup.tsx"; import { Error404 } from "@/components/ui/error-404.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";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; const LoginPage = lazy(() => import("@/pages/auth/login"));
import SpaceTrash from "@/pages/space/space-trash.tsx"; const Home = lazy(() => import("@/pages/dashboard/home"));
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; const Page = lazy(() => import("@/pages/page/page"));
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; const AccountSettings = lazy(
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; () => import("@/pages/settings/account/account-settings"),
import BasePage from "@/ee/base/pages/base-page.tsx"; );
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; const WorkspaceMembers = lazy(
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; () => import("@/pages/settings/workspace/workspace-members"),
import TemplateList from "@/ee/template/pages/template-list"; );
import TemplateEditor from "@/ee/template/pages/template-editor"; const WorkspaceSettings = lazy(
import FavoritesPage from "@/pages/favorites/favorites-page"; () => import("@/pages/settings/workspace/workspace-settings"),
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; );
import VerifyEmail from "@/ee/pages/verify-email.tsx"; const Groups = lazy(() => import("@/pages/settings/group/groups"));
import LabelPage from "@/pages/label/label-page"; const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
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 />} />
@@ -135,6 +167,6 @@ export default function App() {
<Route path="*" element={<Error404 />} /> <Route path="*" element={<Error404 />} />
</Routes> </Routes>
</> </Suspense>
); );
} }
@@ -1,17 +1,30 @@
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, { ReactNode, useEffect } from "react"; import React, { lazy, ReactNode, Suspense, 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();
@@ -68,17 +81,19 @@ export default function Aside() {
</Group> </Group>
)} )}
{tab === "comments" || tab === "chat" ? ( <Suspense fallback={null}>
component {tab === "comments" || tab === "chat" ? (
) : ( component
<ScrollArea ) : (
style={{ height: "85vh" }} <ScrollArea
scrollbarSize={5} style={{ height: "85vh" }}
type="scroll" scrollbarSize={5}
> type="scroll"
<div style={{ paddingBottom: "200px" }}>{component}</div> >
</ScrollArea> <div style={{ paddingBottom: "200px" }}>{component}</div>
)} </ScrollArea>
)}
</Suspense>
</> </>
)} )}
</Box> </Box>
@@ -11,7 +11,10 @@ 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";
@@ -126,7 +129,11 @@ export default function GlobalAppShell({
)} )}
{isSpaceRoute && <SpaceSidebar />} {isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />} {isSettingsRoute && <SettingsSidebar />}
{isAiRoute && <AiChatSidebar />} {isAiRoute && (
<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}>
@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
export const CheckboxMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";
@@ -0,0 +1,45 @@
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");
});
});
@@ -0,0 +1,29 @@
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>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
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 (
<> <>
<Helmet> <DocumentTitle title={t("404 page not found")} />
<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}>
@@ -0,0 +1,74 @@
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>
);
}
@@ -0,0 +1,74 @@
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,7 +6,10 @@ import {
IconFileText, IconFileText,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input"; import { useAtomValue } from "jotai";
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";
@@ -14,6 +17,7 @@ type Suggestion = {
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
prompt: string; prompt: string;
write?: boolean;
}; };
const SUGGESTIONS: Suggestion[] = [ const SUGGESTIONS: Suggestion[] = [
@@ -26,6 +30,7 @@ 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} />,
@@ -36,6 +41,7 @@ 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,
}, },
]; ];
@@ -47,9 +53,13 @@ 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) => {
onSend(prompt, [], []); inputRef.current?.prefill(prompt);
}; };
return ( return (
@@ -62,6 +72,7 @@ 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}
@@ -73,7 +84,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.map((s) => ( {SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => (
<button <button
key={s.text} key={s.text}
type="button" type="button"
@@ -1,4 +1,12 @@
import { useCallback, useId, useRef, useEffect, useState } from "react"; import {
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";
@@ -35,6 +43,10 @@ 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>();
@@ -89,7 +101,7 @@ function editorJsonToText(json: any): string {
return text; return text;
} }
export default function ChatInput({ const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
isStreaming, isStreaming,
onSend, onSend,
onStop, onStop,
@@ -100,7 +112,7 @@ export default function ChatInput({
variant = "card", variant = "card",
showDisclaimer = true, showDisclaimer = true,
chatId, chatId,
}: Props) { }: Props, ref) {
const chatIdRef = useRef(chatId); const chatIdRef = useRef(chatId);
chatIdRef.current = chatId; chatIdRef.current = chatId;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -255,6 +267,7 @@ export default 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,
@@ -269,6 +282,19 @@ export default 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;
@@ -428,4 +454,6 @@ export default function ChatInput({
)} )}
</> </>
); );
} });
export default ChatInput;
+24 -7
View File
@@ -1,5 +1,3 @@
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,20 +5,27 @@ 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, Stack, Tabs } from "@mantine/core"; import { Alert, Collapse, 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();
@@ -40,9 +45,7 @@ export default function AiSettings() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="AI settings" />
<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}>
@@ -64,7 +67,7 @@ export default function AiSettings() {
mb="lg" mb="lg"
> >
{t( {t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI is available in the Docmost paid editions. Contact sales@docmost.com.",
)} )}
</Alert> </Alert>
)} )}
@@ -73,6 +76,20 @@ 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,6 +15,14 @@ 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,10 +1,9 @@
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 { getAppName, getAppUrl } from "@/lib/config"; import { 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";
@@ -17,6 +16,7 @@ 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,11 +49,7 @@ export default function UserApiKeys() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("API keys")} />
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
@@ -1,9 +1,7 @@
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";
@@ -15,6 +13,7 @@ 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();
@@ -47,11 +46,7 @@ export default function WorkspaceApiKeys() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("API management")} />
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} /> <SettingsTitle title={t("API management")} />
@@ -10,11 +10,9 @@ 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 {
@@ -26,6 +24,7 @@ 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";
@@ -97,11 +96,7 @@ export default function AuditLogs() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Audit log")} />
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} /> <SettingsTitle title={t("Audit log")} />
@@ -9,6 +9,7 @@ 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 {
@@ -52,6 +53,9 @@ 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";
} }
@@ -70,6 +74,10 @@ 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,
@@ -121,6 +129,32 @@ 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"
@@ -199,11 +233,18 @@ 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) setDraft(null); if (opened) {
}, [opened]); setDraftConditions(conditions);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
const handleStartDraft = useCallback(() => { const handleStartDraft = useCallback(() => {
const firstProperty = properties[0]; const firstProperty = properties[0];
@@ -216,14 +257,21 @@ export function ViewFilterConfigPopover({
}, [properties]); }, [properties]);
const handleSaveDraft = useCallback(() => { const handleSaveDraft = useCallback(() => {
if (!draft) return; const nextConditions = draft
onChange([...conditions, draft]); ? [...draftConditions, draft]
: draftConditions;
onChange(nextConditions);
setDraft(null); setDraft(null);
}, [draft, conditions, onChange]); setUnSaved(false)
}, [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) => {
@@ -272,17 +320,19 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback( const handleRemove = useCallback(
(index: number) => { (index: number) => {
onChange(conditions.filter((_, i) => i !== index)); setUnSaved(true);
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);
onChange( setUnSaved(true)
conditions.map((f, i) => { setDraftConditions((current) =>
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);
@@ -302,15 +352,16 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[conditions, properties, onChange], [properties],
); );
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;
onChange( setUnSaved(true)
conditions.map((f, i) => { setDraftConditions((current) =>
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),
@@ -320,16 +371,17 @@ export function ViewFilterConfigPopover({
}), }),
); );
}, },
[conditions, properties, onChange], [properties],
); );
const handleValueChange = useCallback( const handleValueChange = useCallback(
(index: number, value: unknown) => { (index: number, value: unknown) => {
onChange( setUnSaved(true)
conditions.map((f, i) => (i === index ? { ...f, value } : f)), setDraftConditions((current) =>
current.map((f, i) => (i === index ? { ...f, value } : f)),
); );
}, },
[conditions, onChange], [],
); );
return ( return (
@@ -362,13 +414,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")} {t("Filter by")}
</Text> </Text>
{conditions.length === 0 && !draft && ( {draftConditions.length === 0 && !draft && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("No filters applied")} {t("No filters applied")}
</Text> </Text>
)} )}
{conditions.map((condition, index) => { {draftConditions.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,
@@ -471,14 +523,6 @@ 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>
); );
})()} })()}
@@ -492,6 +536,20 @@ 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>
+2 -5
View File
@@ -1,5 +1,3 @@
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";
@@ -9,6 +7,7 @@ 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();
@@ -20,9 +19,7 @@ export default function Billing() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="Billing" />
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" /> <SettingsTitle title="Billing" />
<BillingTrial /> <BillingTrial />
+1
View File
@@ -22,4 +22,5 @@ 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;
+2 -5
View File
@@ -1,5 +1,3 @@
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";
@@ -9,6 +7,7 @@ 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);
@@ -21,9 +20,7 @@ export default function License() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="License" />
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" /> <SettingsTitle title="License" />
<ActivateLicenseForm /> <ActivateLicenseForm />
@@ -1,17 +1,16 @@
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();
@@ -68,11 +67,7 @@ export default function VerifiedPages() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Verified pages")} />
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Verified pages")} /> <SettingsTitle title={t("Verified pages")} />
+2 -7
View File
@@ -1,18 +1,13 @@
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 (
<> <>
<Helmet> <DocumentTitle title={t("Login")} />
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm /> <CloudLoginForm />
</> </>
@@ -1,14 +1,11 @@
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 { getAppName } from "@/lib/config.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CreateWorkspace() { export default function CreateWorkspace() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="Create Workspace" />
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>
); );
@@ -1,5 +1,4 @@
import { Helmet } from "react-helmet-async"; import { isCloud } from "@/lib/config.ts";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import { import {
Alert, Alert,
@@ -37,6 +36,7 @@ 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,9 +64,7 @@ export default function Security() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="Security" />
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <EnforceMfa />
@@ -41,6 +41,7 @@ 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,8 +22,6 @@ 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 {
@@ -44,6 +42,7 @@ 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();
@@ -88,6 +87,7 @@ 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,11 +247,7 @@ export default function TemplateEditor() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Edit template")} />
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
{editorToolbarEnabled && editor && ( {editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode /> <FixedToolbar editor={editor} templateMode />
@@ -13,11 +13,9 @@ 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,
@@ -31,6 +29,7 @@ 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();
@@ -102,11 +101,7 @@ export default function TemplateList() {
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Templates")} />
<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">
@@ -0,0 +1,59 @@
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>
);
}
@@ -0,0 +1,191 @@
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>
);
}
@@ -0,0 +1,25 @@
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,7 +3,17 @@ 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,4 +1,5 @@
export { export {
getPageAttachments,
uploadIcon, uploadIcon,
uploadUserAvatar, uploadUserAvatar,
uploadSpaceIcon, uploadSpaceIcon,
@@ -15,6 +15,15 @@ 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,6 +103,7 @@ 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,6 +16,14 @@ 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);
@@ -23,11 +23,21 @@ 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 { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext"; import {
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 { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; import {
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom"; showAiMenuAtom,
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;
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group> <ActionIcon.Group>
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow> <Tooltip
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon <ActionIcon
key={index} key={index}
variant="default" variant="default"
@@ -226,7 +241,9 @@ 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={() => isEditorReady(props.editor) && item.command()} onClick={() =>
isEditorReady(props.editor) && item.command()
}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -256,7 +273,9 @@ 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={() => isEditorReady(props.editor) && commentItem.command()} onClick={() =>
isEditorReady(props.editor) && commentItem.command()
}
> >
<IconMessage size={16} stroke={2} /> <IconMessage size={16} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -129,8 +129,7 @@ function handleColorKeyNav(
grid: "text" | "highlight", grid: "text" | "highlight",
) { ) {
const cols = COLOR_GRID_COLS; const cols = COLOR_GRID_COLS;
const total = const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols; const col = index % cols;
if (e.key === "ArrowRight") { if (e.key === "ArrowRight") {
@@ -163,8 +162,7 @@ function handleColorKeyNav(
if (prev >= 0) { if (prev >= 0) {
focusSwatch(grid, prev); focusSwatch(grid, prev);
} else if (grid === "highlight") { } else if (grid === "highlight") {
const lastRowStart = const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
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;
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow withArrow
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow withinPortal={false}>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}> <Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px"> <Stack gap="md" p="2px">
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Text color")} {t("Text color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => { {TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => { const applyTextColor = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetColor(); editor.commands.unsetColor();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.setColor(color || "") .setColor(color || "")
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A A
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Highlight color")} {t("Highlight color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => { {HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => { const applyHighlight = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.toggleMark("highlight", { .toggleMark("highlight", {
color: color || "", color: color || "",
colorName: name.toLowerCase() || "", colorName: name.toLowerCase() || "",
}) })
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)} )}
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Button <Button
variant="default" variant="default"
fullWidth fullWidth
data-color-grid="remove" data-color-grid="remove"
className={classes.removeColor} className={classes.removeColor}
onClick={() => { onClick={() => {
if (isEditorReady(editor)) { if (isEditorReady(editor)) {
editor.commands.unsetColor(); editor.commands.unsetColor();
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} }
setIsOpen(false); setIsOpen(false);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const lastRowStart = const lastRowStart =
Math.floor( Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS, COLOR_GRID_COLS;
) * COLOR_GRID_COLS; focusSwatch("highlight", lastRowStart);
focusSwatch("highlight", lastRowStart); }
} }}
}} >
> {t("Remove color")}
{t("Remove color")} </Button>
</Button> </Stack>
</Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom); const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return ( return (
<Tooltip label={t("Add link")} withArrow> <Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen} onChange={setIsOpen}
> >
<Menu.Target> <Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}> <Tooltip
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" }}
@@ -0,0 +1,176 @@
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,6 +1,7 @@
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 {
@@ -24,6 +25,7 @@ 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";
@@ -39,10 +41,12 @@ 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);
@@ -328,6 +332,23 @@ 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,6 +1,7 @@
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 {
@@ -25,6 +26,7 @@ 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";
@@ -37,6 +39,7 @@ 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(() =>
@@ -47,6 +50,7 @@ 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);
@@ -359,6 +363,23 @@ 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}
@@ -12,6 +12,7 @@ import {
IconMathFunction, IconMathFunction,
IconRotate2, IconRotate2,
IconSitemap, IconSitemap,
IconSuperscript,
IconTable, IconTable,
IconTag, IconTag,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -270,6 +271,12 @@ 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,6 +1,7 @@
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 {
@@ -16,16 +17,19 @@ 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,
@@ -207,6 +211,23 @@ 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}
@@ -30,6 +30,7 @@ import {
IconTag, IconTag,
IconMoodSmile, IconMoodSmile,
IconRotate2, IconRotate2,
IconSuperscript,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -177,6 +178,16 @@ 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.",
@@ -13,14 +13,21 @@ 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 } = useParams(); const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//@ts-ignore // @ts-ignore
const currentPageId = editor.storage.pageId; const storagePageId = 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,6 +40,7 @@ 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,6 +1,7 @@
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 {
@@ -15,14 +16,17 @@ 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,
@@ -183,6 +187,23 @@ 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,5 +1,6 @@
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";
@@ -63,6 +64,9 @@ 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,
@@ -132,6 +136,7 @@ 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,
@@ -143,6 +148,9 @@ 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,7 +211,8 @@ export const mainExtensions = [
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...");
} }
@@ -417,6 +426,9 @@ export const mainExtensions = [
}).configure(), }).configure(),
Columns, Columns,
Column, Column,
Footnotes,
Footnote,
FootnoteReference,
AutoJoiner.configure({ AutoJoiner.configure({
elementsToJoin: [], elementsToJoin: [],
}), }),
+44 -11
View File
@@ -33,6 +33,7 @@ import { useAtom, useAtomValue } from "jotai";
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,
@@ -53,6 +54,9 @@ 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,
@@ -184,6 +188,7 @@ function CollabPageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -245,10 +250,22 @@ function CollabPageEditor({
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)]; return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
}, [provider, currentUser?.user]); }, [provider, currentUser?.user]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...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: {
@@ -304,6 +321,22 @@ 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) {
@@ -342,17 +375,6 @@ 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;
@@ -385,6 +407,7 @@ 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;
@@ -458,6 +481,15 @@ function CollabPageEditor({
{editor && !editorIsEditable && (editable || canComment) && ( {editor && !editorIsEditable && (editable || canComment) && (
<ReadonlyBubbleMenu editor={editor} /> <ReadonlyBubbleMenu editor={editor} />
)} )}
{editor && (
<LightboxView
editor={editor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && ( {showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly /> <CommentDialog editor={editor} pageId={pageId} readOnly />
@@ -484,6 +516,7 @@ function StaticPageEditor({
<EditorProvider <EditorProvider
editable={false} editable={false}
immediatelyRender={true} immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions} extensions={mainExtensions}
content={content} content={content}
editorProps={{ editorProps={{
@@ -1,15 +1,27 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, {
import { EditorProvider } from "@tiptap/react"; useCallback,
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 { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import {
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;
@@ -33,6 +45,8 @@ 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);
@@ -49,6 +63,11 @@ 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",
@@ -85,6 +104,7 @@ 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>
@@ -93,8 +113,22 @@ 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) {
@@ -103,12 +137,22 @@ 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>
); );
@@ -0,0 +1,26 @@
.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,3 +18,4 @@
@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: left; text-align: start;
} }
.column-resize-handle { .column-resize-handle {
@@ -86,6 +86,7 @@ export function TitleEditor({
}, },
editable: editable, editable: editable,
content: title, content: title,
textDirection: "auto",
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: false, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
@@ -39,6 +39,7 @@ 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,4 +6,13 @@ 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>(null); export const diffCountsAtom = atom<DiffCounts | 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: block; display: flex;
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,6 +12,28 @@
} }
} }
.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;
@@ -77,3 +99,8 @@
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,6 +31,7 @@ export function HistoryEditor({
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
textDirection: "auto",
}); });
useEffect(() => { useEffect(() => {
@@ -170,7 +171,6 @@ 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,10 +1,21 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import {
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;
@@ -15,6 +26,13 @@ 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({
@@ -24,10 +42,24 @@ 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(() => {
onSelect(historyItem.id, index); if (compareMode) {
}, [onSelect, historyItem.id, index]); onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
@@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0; const hasContributors = contributors && contributors.length > 0;
return ( return (
<UnstyledButton <div
p="xs" className={clsx(classes.history, { [classes.active]: isActive })}
onClick={handleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd} onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text> {compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Group gap={6} wrap="nowrap" mt={4}> <UnstyledButton
{hasContributors ? ( p="xs"
<> onClick={handleClick}
<Tooltip.Group openDelay={300} closeDelay={100}> className={classes.historyButton}
<Avatar.Group spacing={8}> >
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( <Text size="sm">{date}</Text>
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar <Group gap={6} wrap="nowrap" mt={4}>
size="sm" {hasContributors ? (
avatarUrl={contributor.avatarUrl} <>
name={contributor.name} <Tooltip.Group openDelay={300} closeDelay={100}>
/> <Avatar.Group spacing={8}>
</Tooltip> {contributors
))} .slice(0, MAX_VISIBLE_AVATARS)
{contributors.length > MAX_VISIBLE_AVATARS && ( .map((contributor) => (
<Tooltip <Tooltip
withArrow key={contributor.id}
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => ( label={contributor.name}
<div key={c.id}>{c.name}</div> withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))} ))}
> {contributors.length > MAX_VISIBLE_AVATARS && (
<Avatar size="sm" color="gray"> <Tooltip
+{contributors.length - MAX_VISIBLE_AVATARS} withArrow
</Avatar> label={contributors
</Tooltip> .slice(MAX_VISIBLE_AVATARS)
)} .map((c) => (
</Avatar.Group> <div key={c.id}>{c.name}</div>
</Tooltip.Group> ))}
{contributors.length === 1 && ( >
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name} {historyItem.lastUpdatedBy?.name}
</Text> </Text>
</>
)}
</Group>
</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>
<> )}
<CustomAvatar </div>
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
); );
}); });
@@ -6,8 +6,12 @@ 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 {
@@ -32,6 +36,9 @@ 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,
@@ -79,10 +86,58 @@ 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], [historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
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(() => {
@@ -138,6 +193,16 @@ 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 }} />}
@@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) {
)} )}
</ScrollArea> </ScrollArea>
{canRestore && ( {compareMode ? (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
onClick={() => setHistoryModalOpen(false)} onClick={handleCancelCompare}
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}> <Button
{t("Restore")} size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
</Button> </Button>
</Group> </Group>
</> </>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)} )}
</div> </div>
); );
@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
CloseButton,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -12,17 +13,20 @@ 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 { useRef } from "react"; import { useMemo, 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;
@@ -36,6 +40,28 @@ 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 } =
@@ -50,6 +76,25 @@ 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%"
@@ -57,11 +102,18 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef} viewportRef={scrollViewportRef}
> >
<div className={classes.sidebarRightSection}> <div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />} {comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
</div> </div>
</ScrollArea> </ScrollArea>
{activeHistoryId && activeHistoryPrevId && ( {(comparePair || (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,21 +7,29 @@ import {
activeHistoryPrevIdAtom, activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
function HistoryView() { interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const historyId = useAtomValue(activeHistoryIdAtom); const activeId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom); const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const { const {
data, data,
isLoading: isLoadingCurrent, isLoading: isLoadingCurrent,
isError: isErrorCurrent, isError: isErrorCurrent,
} = usePageHistoryQuery(historyId); } = usePageHistoryQuery(resolvedId);
const { const {
data: prevData, data: prevData,
isLoading: isLoadingPrev, isLoading: isLoadingPrev,
isError: isErrorPrev, isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId); } = usePageHistoryQuery(resolvedPrevId);
if (isLoadingCurrent || isLoadingPrev) { if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
@@ -3,22 +3,45 @@ 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 and diff counts. * Clears active selection, diff counts, and compare state.
* 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);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]); resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
} }
@@ -1,4 +1,4 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { 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,7 +9,8 @@ import {
activeHistoryIdAtom, activeHistoryIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { import {
pageEditorAtom, pageEditorAtom,
titleEditorAtom, titleEditorAtom,
@@ -25,8 +26,6 @@ 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);
@@ -40,47 +39,66 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page, SpaceCaslSubject.Page,
); );
const handleRestore = useCallback(() => { const handleRestore = useCallback(
if (!activeHistoryData) return; async (historyId: string) => {
if ( let historyData: IPageHistory;
!mainEditor || try {
mainEditor.isDestroyed || historyData = await fetchPageHistory(historyId);
!mainEditorTitle || } catch {
mainEditorTitle.isDestroyed notifications.show({
) { message: t("Error fetching page data."),
return; color: "red",
} });
return;
}
mainEditorTitle if (
.chain() !mainEditor ||
.clearContent() mainEditor.isDestroyed ||
.setContent(activeHistoryData.title, { emitUpdate: true }) !mainEditorTitle ||
.run(); mainEditorTitle.isDestroyed
) {
return;
}
mainEditor mainEditorTitle
.chain() .chain()
.clearContent() .clearContent()
.setContent(activeHistoryData.content) .setContent(historyData.title, { emitUpdate: true })
.run(); .run();
setHistoryModalOpen(false); mainEditor
notifications.show({ message: t("Successfully restored") }); .chain()
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); .clearContent()
.setContent(historyData.content)
.run();
const confirmRestore = useCallback(() => { setHistoryModalOpen(false);
modals.openConfirmModal({ notifications.show({ message: t("Successfully restored") });
title: t("Please confirm your action"), },
children: ( [mainEditor, mainEditorTitle, setHistoryModalOpen, t],
<Text size="sm"> );
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.", const confirmRestore = useCallback(
)} (historyId?: string) => {
</Text> const targetId = historyId ?? activeHistoryId;
), if (!targetId) return;
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore, modals.openConfirmModal({
}); title: t("Please confirm your action"),
}, [t, handleRestore]); children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
return { canRestore, confirmRestore }; return { canRestore, confirmRestore };
} }
@@ -23,6 +23,14 @@ 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>> {
@@ -0,0 +1,32 @@
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();
});
});
@@ -0,0 +1,18 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -11,6 +11,7 @@ import {
IconList, IconList,
IconMarkdown, IconMarkdown,
IconMessage, IconMessage,
IconPaperclip,
IconPrinter, IconPrinter,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
@@ -42,6 +43,7 @@ import {
import { formattedDate } from "@/lib/time.ts"; import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
import { import {
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
verificationOpened, verificationOpened,
{ open: openVerificationModal, close: closeVerificationModal }, { open: openVerificationModal, close: closeVerificationModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt); const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId); const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item> </Menu.Item>
)} )}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPaperclip size={16} />}
onClick={openAttachmentsModal}
>
{t("Attachments")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && ( {!readOnly && !page?.isBase && (
<PageVerificationMenuItem <PageVerificationMenuItem
pageId={page?.id} pageId={page?.id}
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
opened={verificationOpened} opened={verificationOpened}
onClose={closeVerificationModal} onClose={closeVerificationModal}
/> />
<PageAttachmentsModal
pageId={page.id}
open={attachmentsOpened}
onClose={closeAttachmentsModal}
/>
</> </>
); );
} }
@@ -0,0 +1,152 @@
import { ReactNode, useMemo, useState } from "react";
import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { useAtomValue } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
type CreatorFilterMenuProps = {
value: string | null;
onChange: (user: IUser | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
opened?: boolean;
onOpenChange?: (opened: boolean) => void;
};
export function CreatorFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
opened,
onOpenChange,
}: CreatorFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: false,
includePages: false,
preload: true,
});
const users: IUser[] = (suggestion?.users as IUser[]) ?? [];
const currentUser = useAtomValue(userAtom);
// pin the signed-in user on top so they never have to search themselves
const displayUsers = useMemo(() => {
if (!currentUser) return users;
const others = users.filter((user) => user.id !== currentUser.id);
const q = debouncedQuery.trim().toLowerCase();
const matchesQuery =
!q ||
currentUser.name?.toLowerCase().includes(q) ||
currentUser.email?.toLowerCase().includes(q);
return matchesQuery ? [currentUser as IUser, ...others] : users;
}, [users, currentUser, debouncedQuery]);
return (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a user")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
<Group flex="1" gap="xs">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Anyone")}
</Text>
</div>
{!value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
<Divider my="xs" />
{displayUsers.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No users found")}
</Text>
)}
{displayUsers.map((user) => (
<Menu.Item
key={user.id}
component={RadioMenuItem}
aria-checked={value === user.id}
onClick={() => onChange(user)}
>
<Group flex="1" gap="xs">
<CustomAvatar
avatarUrl={user.avatarUrl}
size={20}
name={user.name}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{user.name}
{user.id === currentUser?.id && (
<Text span size="sm" c="dimmed" fw={400}>
{" "}
({t("you")})
</Text>
)}
</Text>
{user.email && (
<Text size="xs" c="dimmed" truncate>
{user.email}
</Text>
)}
</div>
{value === user.id && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,127 @@
import { ReactNode, useMemo, useState } from "react";
import {
Group,
Menu,
ScrollArea,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
type LabelFilterMenuProps = {
value: string[];
onChange: (labelIds: string[]) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
opened?: boolean;
onOpenChange?: (opened: boolean) => void;
};
export function LabelFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
opened,
onOpenChange,
}: LabelFilterMenuProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true);
const labels = data?.items ?? [];
const selectedSet = useMemo(() => new Set(value), [value]);
const toggleLabel = (labelId: string) => {
if (selectedSet.has(labelId)) {
onChange(value.filter((id) => id !== labelId));
} else {
onChange([...value, labelId]);
}
};
return (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
closeOnItemClick={false}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a label")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
{labels.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No labels found")}
</Text>
)}
{labels.map((label) => {
const isChecked = selectedSet.has(label.id);
const color = getLabelColor(label.name, scheme);
return (
<Menu.Item
key={label.id}
type="button"
component={CheckboxMenuItem}
aria-checked={isChecked}
onClick={() => toggleLabel(label.id)}
>
<Group flex="1" gap="xs">
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: color.dot,
flexShrink: 0,
}}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{label.name}
</Text>
{isChecked && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -19,6 +19,7 @@ import {
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps { interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch; result: IPageSearch | IAttachmentSearch;
@@ -26,6 +27,14 @@ interface SearchResultItemProps {
showSpace?: boolean; showSpace?: boolean;
} }
// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and
// React never writes -1 back because the prop value never changes
const makeActionTabbable = (el: HTMLElement | null) => {
if (el) {
el.tabIndex = 0;
}
};
export function SearchResultItem({ export function SearchResultItem({
result, result,
isAttachmentResult, isAttachmentResult,
@@ -46,6 +55,7 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
attachmentResult.space.slug, attachmentResult.space.slug,
@@ -59,8 +69,13 @@ export function SearchResultItem({
<IconFile size={16} /> <IconFile size={16} />
</Center> </Center>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text>{attachmentResult.fileName}</Text> <Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{attachmentResult.fileName}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(attachmentResult.updatedAt)}
</Text>
</Group>
<Text size="xs" opacity={0.6}> <Text size="xs" opacity={0.6}>
{attachmentResult.space.name} {attachmentResult.page.title} {attachmentResult.space.name} {attachmentResult.page.title}
</Text> </Text>
@@ -96,6 +111,7 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
pageResult.space.slug, pageResult.space.slug,
@@ -107,8 +123,13 @@ export function SearchResultItem({
<Group wrap="nowrap" w="100%"> <Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center> <Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text>{pageResult.title}</Text> <Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{pageResult.title || t("Untitled")}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(pageResult.updatedAt)}
</Text>
</Group>
{showSpace && pageResult.space && ( {showSpace && pageResult.space && (
<Badge variant="light" size="xs" color="gray"> <Badge variant="light" size="xs" color="gray">
@@ -17,3 +17,10 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
} }
} }
.filterButtonActive {
color: var(--mantine-color-blue-light-color);
&:hover {
color: var(--mantine-color-blue-light-color);
}
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import cx from "clsx";
import { import {
Button, Button,
Menu, Menu,
@@ -11,18 +12,24 @@ import {
import { import {
IconChevronDown, IconChevronDown,
IconBuilding, IconBuilding,
IconPlus,
IconFileDescription, IconFileDescription,
IconCheck, IconCheck,
IconUser,
IconTag,
IconLetterCase,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu";
import { RadioMenuItem } from "@/components/ui/radio-menu-item"; import { RadioMenuItem } from "@/components/ui/radio-menu-item";
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 classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
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 { LabelFilterMenu } from "./label-filter-menu";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
@@ -40,9 +47,17 @@ export function SearchSpotlightFilters({
const { t } = useTranslation(); const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null, spaceId || null
); );
const [contentType, setContentType] = useState<string | null>("page"); const [contentType, setContentType] = useState<string | null>("page");
const [selectedCreatorId, setSelectedCreatorId] = useState<string | null>(null);
const [selectedCreatorName, setSelectedCreatorName] = useState<string | null>(
null
);
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [titleOnly, setTitleOnly] = useState(false);
const [openedFilter, setOpenedFilter] = useState<string | null>(null);
const [visibleFilters, setVisibleFilters] = useState<string[]>([]);
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -50,15 +65,6 @@ export function SearchSpotlightFilters({
? spacesData?.items.find((space) => space.id === selectedSpaceId) ? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null; : null;
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [ const contentTypeOptions = [
{ value: "page", label: t("Pages") }, { value: "page", label: t("Pages") },
{ {
@@ -68,38 +74,70 @@ export function SearchSpotlightFilters({
}, },
]; ];
useEffect(() => {
onFiltersChange?.({
spaceId: selectedSpaceId,
contentType,
creatorId: selectedCreatorId,
labelIds: selectedLabelIds,
titleOnly,
});
}, [
selectedSpaceId,
contentType,
selectedCreatorId,
selectedLabelIds,
titleOnly,
onFiltersChange,
]);
const handleSpaceSelect = (spaceId: string | null) => { const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId); setSelectedSpaceId(spaceId);
};
if (onFiltersChange) { const handleCreatorSelect = (user: { id: string; name: string } | null) => {
onFiltersChange({ setSelectedCreatorId(user?.id ?? null);
spaceId: spaceId, setSelectedCreatorName(user?.name ?? null);
contentType, };
});
const handleLabelsSelect = (labelIds: string[]) => {
setSelectedLabelIds(labelIds);
};
const handleChangeContentType = (value: string) => {
setContentType(value);
if (value === "attachment") {
setSelectedLabelIds([]);
} }
}; };
const handleFilterChange = (filterType: string, value: any) => { const onDemandFilters = [
let newSelectedSpaceId = selectedSpaceId; { key: "creator", label: t("Created by"), icon: IconUser, available: true },
let newContentType = contentType; {
key: "labels",
label: t("Labels"),
icon: IconTag,
available: contentType !== "attachment",
},
];
switch (filterType) { const isFilterVisible = (key: string) => {
case "spaceId": if (openedFilter === key) return true;
newSelectedSpaceId = value; if (key === "creator") return !!selectedCreatorId;
setSelectedSpaceId(value); if (key === "labels")
break; return contentType !== "attachment" && selectedLabelIds.length > 0;
case "contentType": return false;
newContentType = value; };
setContentType(value);
break;
}
if (onFiltersChange) { const orderedVisibleFilters = visibleFilters.filter(isFilterVisible);
onFiltersChange({ const addableFilters = onDemandFilters.filter(
spaceId: newSelectedSpaceId, (filter) => filter.available && !isFilterVisible(filter.key),
contentType: newContentType, );
});
} const revealFilter = (key: string) => {
setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]);
setOpenedFilter(key);
}; };
return ( return (
@@ -122,8 +160,17 @@ export function SearchSpotlightFilters({
color="blue" color="blue"
labelPosition="left" labelPosition="left"
styles={{ styles={{
root: { display: "flex", alignItems: "center" }, root: {
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, display: "flex",
alignItems: "center",
flexShrink: 0,
},
label: {
whiteSpace: "nowrap",
paddingRight: "8px",
fontSize: "13px",
fontWeight: 500,
},
}} }}
/> />
</div> </div>
@@ -181,7 +228,7 @@ export function SearchSpotlightFilters({
onClick={() => onClick={() =>
!option.disabled && !option.disabled &&
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleChangeContentType(option.value)
} }
disabled={ disabled={
option.disabled || (isAiMode && option.value === "attachment") option.disabled || (isAiMode && option.value === "attachment")
@@ -195,13 +242,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && {!option.disabled && isAiMode && option.value === "attachment" && (
isAiMode && <Text size="xs" mt={4}>
option.value === "attachment" && ( {t("AI Answers not available for attachments")}
<Text size="xs" mt={4}> </Text>
{t("AI Answers not available for attachments")} )}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} aria-hidden />} {contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group> </Group>
@@ -209,6 +254,126 @@ export function SearchSpotlightFilters({
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
{!isAiMode && (
<Button
variant={titleOnly ? "light" : "subtle"}
color={titleOnly ? "blue" : "gray"}
size="sm"
radius="xl"
leftSection={<IconLetterCase size={16} />}
className={cx(
classes.filterButton,
titleOnly && classes.filterButtonActive,
)}
fw={500}
aria-pressed={titleOnly}
onClick={() => setTitleOnly(!titleOnly)}
>
{t("Title only")}
</Button>
)}
{!isAiMode &&
orderedVisibleFilters.map((filterKey) => {
if (filterKey === "creator") {
return (
<CreatorFilterMenu
key="creator"
value={selectedCreatorId}
onChange={handleCreatorSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "creator"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "creator" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconUser size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedCreatorId
? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
: `${t("Created by")}: ${t("Anyone")}`}
</Button>
</CreatorFilterMenu>
);
}
if (filterKey === "labels") {
return (
<LabelFilterMenu
key="labels"
value={selectedLabelIds}
onChange={handleLabelsSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "labels"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "labels" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconTag size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedLabelIds.length > 0
? `${t("Labels")} (${selectedLabelIds.length})`
: t("Labels")}
</Button>
</LabelFilterMenu>
);
}
return null;
})}
{!isAiMode && addableFilters.length > 0 && (
<Menu
shadow="md"
width={200}
position="bottom-end"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<IconPlus size={16} />}
className={classes.filterButton}
style={{ marginLeft: "auto" }}
fw={500}
>
{t("Filter")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{addableFilters.map((filter) => (
<Menu.Item
key={filter.key}
leftSection={<filter.icon size={16} />}
onClick={() => revealFilter(filter.key)}
>
{filter.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button, VisuallyHidden } from "@mantine/core"; import { Group, Button, VisuallyHidden, Text } from "@mantine/core";
import React, { useState, useMemo, useEffect } from "react"; import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
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 { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
import { getAiVectorDriver } from "@/lib/config.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -26,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
spaceId?: string | null; spaceId?: string | null;
contentType?: string; contentType?: string;
creatorId?: string | null;
labelIds?: string[];
titleOnly?: boolean;
}>({ }>({
contentType: "page", contentType: "page",
}); });
@@ -43,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId; params.spaceId = filters.spaceId;
} }
if (filters.creatorId) {
params.creatorId = filters.creatorId;
}
if (filters.labelIds?.length) {
params.labelIds = filters.labelIds;
}
if (filters.titleOnly) {
params.titleOnly = true;
}
return params; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch( const {
data: searchResults,
isFetching,
} = useUnifiedSearch(
searchParams, searchParams,
!isAiMode // Disable regular search when in AI mode !isAiMode // Disable regular search when in AI mode
); );
@@ -83,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}, [aiSearchError, t]); }, [aiSearchError, t]);
const isFilterBrowse =
(filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId;
// while the debounce is pending the empty list is not a settled "no results"
const isQuerySettled = query === debouncedSearchQuery;
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing; filters.contentType === "attachment" && hasAttachmentIndexing;
@@ -96,10 +124,19 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/> />
)); ));
const handleFiltersChange = (newFilters: any) => { const handleSpotlightOpen = () => {
setFilters(newFilters); if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
}; };
const handleFiltersChange = useCallback((newFilters: any) => {
setFilters(newFilters);
}, [setFilters]);
const handleAskClick = () => { const handleAskClick = () => {
setIsAiMode(!isAiMode); setIsAiMode(!isAiMode);
}; };
@@ -115,6 +152,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root <Spotlight.Root
size="xl" size="xl"
maxHeight={600} maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore} store={searchSpotlightStore}
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
@@ -167,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult ? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available") ? t("No answer available")
: "" : ""
: query.length > 0 && !isLoading : (query.length > 0 || isFilterBrowse) && !isFetching
? resultItems.length === 0 ? resultItems.length === 0
? t("No results found") ? t("No results found")
: t("{{count}} results found", { count: resultItems.length }) : t("{{count}} results found", { count: resultItems.length })
@@ -194,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
</> </>
) : ( ) : (
<> <>
{query.length === 0 && resultItems.length === 0 && ( {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)} )}
{query.length > 0 && !isLoading && resultItems.length === 0 && ( {(query.length > 0 || isFilterBrowse) &&
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty> !isFetching &&
)} isQuerySettled &&
resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>} {resultItems.length > 0 && <>{resultItems}</>}
{(query.length > 0 || isFilterBrowse) &&
isFetching &&
resultItems.length === 0 && (
<Spotlight.Empty>
<Text size="sm" style={{ marginTop: 10 }}>
{t("Searching...")}
</Text>
</Spotlight.Empty>
)}
</> </>
)} )}
</Spotlight.ActionsList> </Spotlight.ActionsList>
@@ -39,6 +39,20 @@ export function useUnifiedSearch(
return await searchPage(backendParams); return await searchPage(backendParams);
} }
}, },
enabled: !!params.query && enabled, enabled:
(!!params.query ||
(params.labelIds?.length ?? 0) > 0 ||
!!params.creatorId) &&
enabled,
// keep previous results only within the same search type; page results
// rendered as attachments (or vice versa) crash on missing fields
placeholderData: (previousData, previousQuery) => {
if (!params.query && !params.labelIds?.length && !params.creatorId)
return undefined;
if (previousQuery && previousQuery.queryKey[1] !== searchType) {
return undefined;
}
return previousData;
},
}); });
} }
@@ -36,6 +36,9 @@ export interface IPageSearchParams {
query: string; query: string;
spaceId?: string; spaceId?: string;
shareId?: string; shareId?: string;
creatorId?: string;
labelIds?: string[];
titleOnly?: boolean;
} }
export interface IAttachmentSearch { export interface IAttachmentSearch {
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core"; import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
width?: number; width?: number;
opened?: boolean; opened?: boolean;
clearable?: boolean; clearable?: boolean;
withinPortal?: boolean;
} }
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -41,6 +42,7 @@ export function SpaceSelect({
width, width,
opened, opened,
clearable, clearable,
withinPortal = true,
}: SpaceSelectProps) { }: SpaceSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -50,9 +52,13 @@ export function SpaceSelect({
limit: 50, limit: 50,
}); });
const [data, setData] = useState([]); const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => { useEffect(() => {
if (spaces) { if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value) .filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => { .map((space: ISpace) => {
@@ -83,14 +89,19 @@ export function SpaceSelect({
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
clearable={clearable} clearable={clearable}
variant="filled" variant="filled"
onChange={(slug) => onChange={(slug) => {
onChange(spaces.items?.find((item) => item.slug === slug)) // options accumulate across fetches; resolve against everything
} // fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }} comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );
@@ -54,7 +54,9 @@ import {
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { searchSpotlight } from "@/features/search/constants"; import { searchSpotlight } from "@/features/search/constants";
import TemplatePickerModal from "@/ee/template/components/template-picker-modal"; const TemplatePickerModal = React.lazy(
() => import("@/ee/template/components/template-picker-modal"),
);
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
@@ -406,11 +408,13 @@ function SpaceMenu({
{hasTemplates && templatePickerOpened && ( {hasTemplates && templatePickerOpened && (
<ErrorBoundary fallbackRender={() => null}> <ErrorBoundary fallbackRender={() => null}>
<TemplatePickerModal <React.Suspense fallback={null}>
opened={templatePickerOpened} <TemplatePickerModal
onClose={closeTemplatePicker} opened={templatePickerOpened}
initialSpaceId={spaceId} onClose={closeTemplatePicker}
/> initialSpaceId={spaceId}
/>
</React.Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
</> </>
@@ -70,6 +70,7 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)} onChange={(space) => handleSelect(space.slug)}
width={300} width={300}
opened={true} opened={true}
withinPortal={false}
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -25,6 +25,8 @@ export interface IWorkspace {
generativeAi?: boolean; generativeAi?: boolean;
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
mcpEnabled?: boolean; mcpEnabled?: boolean;
aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean;
trashRetentionDays?: number; trashRetentionDays?: number;
restrictApiToAdmins?: boolean; restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean; allowMemberTemplates?: boolean;
@@ -51,6 +53,8 @@ export interface IWorkspaceAiSettings {
generative?: boolean; generative?: boolean;
mcp?: boolean; mcp?: boolean;
chat?: boolean; chat?: boolean;
chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean;
} }
export interface IWorkspaceSharingSettings { export interface IWorkspaceSharingSettings {
+4
View File
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD")); return castToBoolean(getConfigValue("CLOUD"));
} }
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl( export function getAvatarUrl(
avatarUrl: string, avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR, type: AvatarIconType = AvatarIconType.AVATAR,
@@ -1,13 +1,10 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form"; import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config"; import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { Helmet } from "react-helmet-async";
export default function ForgotPassword() { export default function ForgotPassword() {
return ( return (
<> <>
<Helmet> <DocumentTitle title="Forgot Password" />
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<ForgotPasswordForm /> <ForgotPasswordForm />
</> </>
); );
+2 -5
View File
@@ -1,16 +1,13 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx"; import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() { export default function InviteSignup() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Invitation Signup")} />
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<InviteSignUpForm /> <InviteSignUpForm />
</> </>
); );
+2 -7
View File
@@ -1,18 +1,13 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<Helmet> <DocumentTitle title={t("Login")} />
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<LoginForm /> <LoginForm />
</> </>
); );

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