Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efcca2e4fd | |||
| 3b57ce7c89 | |||
| 86687d2bf2 | |||
| 15f893184e | |||
| 4132dd597c | |||
| 8a2c457ab9 | |||
| dc3f7b0be4 | |||
| 88e057c76e | |||
| 6ff7f69137 | |||
| 481aae5507 | |||
| 12d68765d9 | |||
| 27a80b57f8 | |||
| 15bdf680d9 | |||
| 96faff7175 | |||
| 2959504470 | |||
| 37a88d171a | |||
| c86d556888 | |||
| e8efef235c | |||
| 04110884b4 | |||
| b88833277d | |||
| 186a0bf71b | |||
| 303b6a048f | |||
| 4aa4feb41f | |||
| 4cf47f4538 | |||
| ccf61f3f84 | |||
| d469558c1e | |||
| fca5619eb7 | |||
| b7f1dd3a6b | |||
| 89bf9f3078 | |||
| 5658d30484 | |||
| 89f13c3fbc | |||
| e0fdc0c178 | |||
| e44ce2714f | |||
| d99d321e4a | |||
| 4f202e4ab5 | |||
| d7fdcdec80 | |||
| 2dc35fa100 | |||
| 4f2fbf22d4 | |||
| d68e241f45 | |||
| 510199cf04 | |||
| aa5d52ad3e | |||
| 1867aa8bf6 | |||
| 2ff8720832 | |||
| b0f7c11fb3 | |||
| feb5167771 | |||
| 97a1248818 | |||
| b7d7ad4521 | |||
| 79df1229cb | |||
| 6191acfa14 | |||
| d86d51c27e | |||
| ef04c22aea | |||
| b6760c63c4 | |||
| 2b68879e72 | |||
| db32910634 | |||
| 33895b0607 | |||
| 830b5b4d45 | |||
| d7c4f0551e | |||
| 61a91cd086 | |||
| f010f6a83a | |||
| 13a7f1372f | |||
| 4295ea09f6 | |||
| ed0501a864 | |||
| aa0c37bd68 | |||
| a5858bc470 | |||
| 0402420fbc | |||
| 2be5e0d4ee | |||
| e02f0acc65 | |||
| adb1f27767 | |||
| 92c0e36e46 | |||
| 1c166c4736 | |||
| 66a754c9eb | |||
| 6cf8101ab3 | |||
| 0d6538ab1a | |||
| 812d0765b5 | |||
| 234bd13453 | |||
| affd2e6740 | |||
| 37e652ade5 | |||
| 38a847f305 | |||
| 7c80688698 | |||
| 0320e07bb1 | |||
| 1982a0ed1e | |||
| fcb2b08cf3 | |||
| e5ff58a73a | |||
| d3bb105366 | |||
| bbb1b5eb26 | |||
| 3e66aff1e2 | |||
| 89442f5154 | |||
| 12ef426541 | |||
| b7b99cb3b2 | |||
| 03c1e8c4ed | |||
| e41518a93d | |||
| 932c1ad5b7 | |||
| 82d065669d | |||
| f758091b2a | |||
| f4af4c3fc0 | |||
| 3b983a27f6 | |||
| 299a9ca3c8 | |||
| cea9be7926 | |||
| 31ed0df3f7 | |||
| a689cca7a0 | |||
| 537e45bc11 | |||
| bdc369fce0 | |||
| 2d8b470495 | |||
| c66c08fa78 | |||
| 6046d04375 | |||
| 5d8c11e741 | |||
| de60aa7e61 | |||
| c9fa6e20b3 | |||
| ec51ca7815 | |||
| 2b63137217 | |||
| 3227bc6059 | |||
| 73dc62bca3 | |||
| 3c74bb3dee | |||
| dbe6c2d6ba | |||
| fe18f22dc6 | |||
| fcef0c6b96 | |||
| 17f3158a3b | |||
| b74ca00bfd | |||
| c247d4c1e3 | |||
| 641ce142df | |||
| 1d2486455f | |||
| a0aea43e25 | |||
| 09c69d7a0f | |||
| 9943e104a5 | |||
| b16f1e5a55 | |||
| 24be90b95f | |||
| 3ecf27c6b0 | |||
| 980521f957 | |||
| fe44dc92a9 | |||
| fad410ef23 | |||
| 15b8908b1a | |||
| 8e15b22d8c | |||
| ec83fc82d5 | |||
| a573acedd0 | |||
| 93ff3f43b7 | |||
| cbf03fc956 | |||
| 68309247b5 | |||
| 474ff6c629 | |||
| c5e9be6b36 | |||
| 00e8c53852 | |||
| dba8e315ab | |||
| 81ae7a17a6 | |||
| 271f855761 | |||
| 3e6d915227 | |||
| a6a7e4370a | |||
| cc00e77dfb | |||
| 66c70c0e76 | |||
| 0e8b3bbfb3 | |||
| a3a9f35005 | |||
| 4056bd0104 | |||
| bd68e47e03 | |||
| d6068310b4 | |||
| e02661974e | |||
| 1113f17a43 | |||
| d42091ccb1 | |||
| 57efb91bd3 | |||
| da9b43681e | |||
| 4966f9b152 | |||
| e1bbceb9a6 | |||
| 895c1817ae | |||
| 642024ba9d | |||
| 147d028036 | |||
| 992691e6e0 | |||
| 9aaa6c731c |
+5
-5
@@ -1,6 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
dist
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/dist
|
||||
/data
|
||||
.env*
|
||||
.nx
|
||||
**/.env*
|
||||
**/.nx
|
||||
|
||||
+16
-1
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# options: local | s3
|
||||
# options: local | s3 | azure
|
||||
STORAGE_DRIVER=local
|
||||
|
||||
# S3 driver config
|
||||
@@ -21,6 +21,11 @@ AWS_S3_BUCKET=
|
||||
AWS_S3_ENDPOINT=
|
||||
AWS_S3_FORCE_PATH_STYLE=
|
||||
|
||||
# Azure Blob Storage driver config
|
||||
AZURE_STORAGE_ACCOUNT_NAME=
|
||||
AZURE_STORAGE_ACCOUNT_KEY=
|
||||
AZURE_STORAGE_CONTAINER=
|
||||
|
||||
# default: 50mb
|
||||
FILE_UPLOAD_SIZE_LIMIT=
|
||||
|
||||
@@ -43,8 +48,18 @@ POSTMARK_TOKEN=
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
|
||||
# Gotenberg URL for server-side PDF export
|
||||
GOTENBERG_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
|
||||
# Allow other sites to embed Docmost in an iframe.
|
||||
IFRAME_EMBED_ALLOWED=false
|
||||
|
||||
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
|
||||
# Example: https://intranet.example.com,https://portal.example.com
|
||||
IFRAME_ALLOWED_ORIGINS=
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
.env.prod
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
|
||||
# Copy packages
|
||||
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
|
||||
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
+78
-65
@@ -1,85 +1,98 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.70.3",
|
||||
"version": "0.95.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/react": "^5.0.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
||||
"@casl/react": "5.0.1",
|
||||
"@docmost/base-formula": "workspace:*",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@mantine/spotlight": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@mantine/core": "9.3.2",
|
||||
"@mantine/dates": "9.3.2",
|
||||
"@mantine/form": "9.3.2",
|
||||
"@mantine/hooks": "9.3.2",
|
||||
"@mantine/modals": "9.3.2",
|
||||
"@mantine/notifications": "9.3.2",
|
||||
"@mantine/spotlight": "9.3.2",
|
||||
"@slidoapp/emoji-mart": "5.8.7",
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "1.13.6",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^25.10.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jotai": "^2.18.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.14.3",
|
||||
"alfaaz": "1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
"clsx": "2.1.1",
|
||||
"file-saver": "2.0.5",
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"jotai": "2.20.1",
|
||||
"jotai-optics": "0.4.0",
|
||||
"js-cookie": "3.0.7",
|
||||
"jwt-decode": "4.0.0",
|
||||
"katex": "0.16.40",
|
||||
"lowlight": "^3.3.0",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "1.363.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"lowlight": "3.3.0",
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mitt": "3.0.1",
|
||||
"nanoid": "3.3.8",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "19.2.7",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^4.3.6"
|
||||
"react-dom": "19.2.7",
|
||||
"react-drawio": "1.0.7",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-helmet-async": "3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "7.18.0",
|
||||
"semver": "7.7.4",
|
||||
"socket.io-client": "4.8.3",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.94.4",
|
||||
"@types/blueimp-load-image": "^5.16.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@eslint/js": "9.28.0",
|
||||
"@tanstack/eslint-plugin-query": "5.94.4",
|
||||
"@testing-library/jest-dom": "6.6.0",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@types/blueimp-load-image": "5.16.6",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.1"
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"globals": "15.13.0",
|
||||
"jsdom": "25.0.0",
|
||||
"optics-ts": "2.4.1",
|
||||
"postcss": "8.5.14",
|
||||
"postcss-preset-mantine": "1.18.0",
|
||||
"postcss-simple-vars": "7.0.1",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
|
||||
import License from "@/ee/licence/pages/license.tsx";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
@@ -37,8 +38,16 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import BasePage from "@/ee/base/pages/base-page.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
import LabelPage from "@/pages/label/label-page";
|
||||
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -76,12 +85,22 @@ export default function App() {
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
<Route path={"/ai"} element={<AiChat />} />
|
||||
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||
<Route path={"/labels/:labelName"} element={<LabelPage />} />
|
||||
<Route path={"/templates"} element={<TemplateList />} />
|
||||
<Route
|
||||
path={"/templates/:templateId"}
|
||||
element={<TemplateEditor />}
|
||||
/>
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
<Route
|
||||
@@ -89,6 +108,8 @@ export default function App() {
|
||||
element={<Page />}
|
||||
/>
|
||||
|
||||
<Route path={"/base/:pageId"} element={<BasePage />} />
|
||||
|
||||
<Route path={"/settings"}>
|
||||
<Route path={"account/profile"} element={<AccountSettings />} />
|
||||
<Route
|
||||
@@ -107,6 +128,11 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
<Route
|
||||
path={"import/confluence"}
|
||||
element={<ConfluenceImportPage />}
|
||||
/>
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -80,6 +80,20 @@ export default function AvatarUploader({
|
||||
}
|
||||
};
|
||||
|
||||
const actionLabel = {
|
||||
[AvatarIconType.AVATAR]: t("Change avatar"),
|
||||
[AvatarIconType.SPACE_ICON]: t("Change space icon"),
|
||||
[AvatarIconType.WORKSPACE_ICON]: t("Change workspace icon"),
|
||||
}[type];
|
||||
|
||||
// Per WCAG 2.5.3 (Label in Name), the accessible name must include the
|
||||
// visible text. When no image is set, the avatar renders the name's
|
||||
// initials, so prepend the name to the action label.
|
||||
const ariaLabel =
|
||||
!currentImageUrl && fallbackName
|
||||
? `${fallbackName} – ${actionLabel}`
|
||||
: actionLabel;
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (disabled) return;
|
||||
|
||||
@@ -104,6 +118,8 @@ export default function AvatarUploader({
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
@@ -115,6 +131,8 @@ export default function AvatarUploader({
|
||||
size={size}
|
||||
avatarUrl={currentImageUrl}
|
||||
name={fallbackName}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="menu"
|
||||
style={{
|
||||
cursor: disabled || isLoading ? "default" : "pointer",
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
@@ -6,15 +6,21 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CopyProps {
|
||||
text: string;
|
||||
size?: MantineSize;
|
||||
color?: MantineColor;
|
||||
/** Override the accessible name (and tooltip) when not yet copied. Lets callers disambiguate adjacent copy buttons for screen readers. */
|
||||
label?: string;
|
||||
}
|
||||
export default function CopyTextButton({ text }: CopyProps) {
|
||||
export default function CopyTextButton({ text, size, label }: CopyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyLabel = label ?? t("Copy");
|
||||
|
||||
return (
|
||||
<CopyButton value={text} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
label={copied ? t("Copied") : copyLabel}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
@@ -22,6 +28,8 @@ export default function CopyTextButton({ text }: CopyProps) {
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
size={size}
|
||||
aria-label={copied ? t("Copied") : copyLabel}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
exportPage,
|
||||
exportPageToDocx,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
@@ -32,17 +40,25 @@ export default function ExportModal({
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDocx = format === ExportFormat.Docx;
|
||||
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||
const blockedByLicense = isDocx && !docxEntitled;
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
if (format === ExportFormat.Docx) {
|
||||
await exportPageToDocx({ pageId: id });
|
||||
} else {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
@@ -81,17 +97,22 @@ export default function ExportModal({
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
<ExportFormatSelection
|
||||
format={format}
|
||||
onChange={handleChange}
|
||||
includeDocx={type === "page"}
|
||||
docxEntitled={docxEntitled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === "page" && (
|
||||
{type === "page" && !isDocx && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
@@ -143,7 +164,16 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
disabled={blockedByLicense}
|
||||
data-disabled={blockedByLicense || undefined}
|
||||
>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
@@ -154,23 +184,49 @@ export default function ExportModal({
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
includeDocx?: boolean;
|
||||
docxEntitled?: boolean;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
function ExportFormatSelection({
|
||||
format,
|
||||
onChange,
|
||||
includeDocx,
|
||||
docxEntitled,
|
||||
}: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
...(includeDocx
|
||||
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
data={data}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||
comboboxProps={{ width: 200 }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
renderOption={({ option }) =>
|
||||
option.value === "docx" && !docxEntitled ? (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Badge size="xs" mt={4}>
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm">{option.label}</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import { IconFileDescription, IconTable } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
icon?: string | null;
|
||||
isBase?: boolean;
|
||||
};
|
||||
|
||||
export function PageListIcon({ icon, isBase }: Props) {
|
||||
if (icon) {
|
||||
return <>{icon}</>;
|
||||
}
|
||||
return (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,20 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
@@ -23,7 +25,8 @@ interface Props {
|
||||
|
||||
export default function RecentChanges({ spaceId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
@@ -33,58 +36,69 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
return <Text>{t("Failed to fetch recent pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages && pages.items.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id} className={rowClasses.row}>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
<UnstyledButton
|
||||
className={rowClasses.link}
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
<Group wrap="nowrap">
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space.name}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{formattedDate(page.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconFiles}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SearchInputProps {
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
debounceDelay?: number;
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
debounceDelay = 500,
|
||||
onSearch,
|
||||
}: SearchInputProps) {
|
||||
@@ -28,6 +30,7 @@ export function SearchInput({
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder={placeholder || t("Search...")}
|
||||
aria-label={ariaLabel || placeholder || t("Search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, rem } from "@mantine/core";
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
export function IconGroupCircle() {
|
||||
return (
|
||||
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
padding-right: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
@@ -16,6 +29,9 @@
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import classes from "./app-header.module.css";
|
||||
import React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
@@ -23,8 +33,11 @@ import {
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
const links = [
|
||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||
];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,10 +47,12 @@ export function AppHeader() {
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
const location = useLocation();
|
||||
const toggleAside = useToggleAside();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
@@ -49,39 +64,44 @@ export function AppHeader() {
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
<Group wrap="nowrap">
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt="Docmost"
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ userSelect: "none" }}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Group ml={50} gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
@@ -98,6 +118,49 @@ export function AppHeader() {
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
{aiChatEnabled && (
|
||||
<>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to="/ai"
|
||||
className={classes.link}
|
||||
visibleFrom="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("AI Chat")}
|
||||
</UnstyledButton>
|
||||
<Tooltip label={t("AI Chat")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
hiddenFrom="sm"
|
||||
aria-label={t("AI Chat")}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
return;
|
||||
}
|
||||
if (isPageRoute) {
|
||||
e.preventDefault();
|
||||
toggleAside("chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconSparkles size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<NotificationPopover />
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
|
||||
@@ -27,5 +27,3 @@
|
||||
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { ReactNode } from "react";
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAsideOpen) return;
|
||||
document.getElementById(ASIDE_PANEL_ID)?.focus();
|
||||
}, [isAsideOpen, tab]);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
component = (
|
||||
<CommentErrorBoundary>
|
||||
<CommentListWithTabs />
|
||||
</CommentErrorBoundary>
|
||||
);
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
component = <TableOfContents editor={pageEditor} />;
|
||||
title = "Table of contents";
|
||||
break;
|
||||
case "chat":
|
||||
component = <AsideChatPanel />;
|
||||
title = "AI Chat";
|
||||
break;
|
||||
case "details":
|
||||
component = <PageDetailsAside />;
|
||||
title = "Details";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
@@ -34,12 +57,24 @@ export default function Aside() {
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
{tab !== "chat" && (
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tab === "comments" ? (
|
||||
<CommentListWithTabs />
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
component
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
@@ -10,22 +11,27 @@ import {
|
||||
sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useTrialEndAction();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
@@ -72,24 +78,23 @@ export default function GlobalAppShell({
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
const isSpaceRoute = location.pathname.startsWith("/s/");
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
const isSpacesRoute = location.pathname === "/spaces";
|
||||
const isAiRoute = location.pathname.startsWith("/ai");
|
||||
const isPageRoute = location.pathname.includes("/p/");
|
||||
const hideSidebar = isHomeRoute || isSpacesRoute;
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
<>
|
||||
<SkipToMain />
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={
|
||||
!hideSidebar && {
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}
|
||||
}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
},
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
@@ -102,30 +107,61 @@ export default function GlobalAppShell({
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!hideSidebar && (
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
aria-label={
|
||||
isSpaceRoute
|
||||
? t("Space navigation")
|
||||
: isSettingsRoute
|
||||
? t("Settings navigation")
|
||||
: isAiRoute
|
||||
? t("AI navigation")
|
||||
: t("Main navigation")
|
||||
}
|
||||
>
|
||||
{isSpaceRoute && (
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
<AppShell.Main>
|
||||
)}
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={850}>{children}</Container>
|
||||
<Container size={900} pb={80}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppShell.Main>
|
||||
|
||||
{isPageRoute && (
|
||||
<AppShell.Aside className={classes.aside} p="md" withBorder={false}>
|
||||
<AppShell.Aside
|
||||
id={ASIDE_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
className={classes.aside}
|
||||
p="md"
|
||||
withBorder={false}
|
||||
aria-label={
|
||||
asideTab === "comments"
|
||||
? t("Comments")
|
||||
: asideTab === "toc"
|
||||
? t("Table of contents")
|
||||
: asideTab === "chat"
|
||||
? t("AI Chat")
|
||||
: asideTab === "details"
|
||||
? t("Details")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
.navbar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@mixin hover {
|
||||
background-color: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(16px);
|
||||
height: rem(16px);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.spaceItem {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
min-height: 30px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
IconStar,
|
||||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
IconTemplate,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
const hasTemplates = useHasFeature(Feature.TEMPLATES);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const mainNavItems = [
|
||||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
{
|
||||
label: "Templates",
|
||||
icon: IconTemplate,
|
||||
path: "/templates",
|
||||
disabled: !hasTemplates,
|
||||
},
|
||||
];
|
||||
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
|
||||
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
|
||||
const sortedFavoriteSpaces = [...favoriteSpaces]
|
||||
.filter((fav) => fav.space)
|
||||
.sort((a, b) => {
|
||||
const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" });
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
const [inviteOpened, { open: openInvite, close: closeInvite }] =
|
||||
useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<ScrollArea w="100%" style={{ flex: 1 }}>
|
||||
<div className={classes.section}>
|
||||
{mainNavItems.map((item) =>
|
||||
item.disabled ? (
|
||||
<Tooltip
|
||||
key={item.label}
|
||||
label={upgradeLabel}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
data-disabled
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
className={classes.link}
|
||||
data-active={active === item.path || undefined}
|
||||
aria-current={active === item.path ? "page" : undefined}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||
{t("Favorite spaces appear here")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{sortedFavoriteSpaces.slice(0, 10).map((fav) => (
|
||||
<Link
|
||||
key={fav.id}
|
||||
className={classes.spaceItem}
|
||||
to={getSpaceUrl(fav.space!.slug)}
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={fav.space!.name}
|
||||
avatarUrl={fav.space!.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{fav.space!.name}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
{sortedFavoriteSpaces.length > 10 && (
|
||||
<Link
|
||||
className={classes.spaceItem}
|
||||
to="/spaces"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("View all")}
|
||||
</Text>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
<div className={classes.bottomSection}>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
onClick={openInvite}
|
||||
>
|
||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Invite People")}</span>
|
||||
</UnstyledButton>
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith("/settings") || undefined}
|
||||
aria-current={active.startsWith("/settings") ? "page" : undefined}
|
||||
to="/settings/account/profile"
|
||||
onClick={handleNavClick}
|
||||
>
|
||||
<IconSettings className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Settings")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="550"
|
||||
opened={inviteOpened}
|
||||
onClose={closeInvite}
|
||||
title={t("Invite new members")}
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<ScrollArea h="80%">
|
||||
<WorkspaceInviteForm onClose={closeInvite} />
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||
|
||||
export const desktopAsideAtom = atom<boolean>(false);
|
||||
|
||||
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
|
||||
type AsideStateType = {
|
||||
tab: string;
|
||||
isAsideOpen: boolean;
|
||||
|
||||
@@ -15,9 +15,16 @@ import {
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
||||
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -36,11 +43,20 @@ export default function TopMenu() {
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
|
||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
||||
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
||||
const [
|
||||
createOpened,
|
||||
{ open: openCreate, close: closeCreate },
|
||||
] = useDisclosure(false);
|
||||
|
||||
if (!user || !workspace) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
@@ -115,6 +131,26 @@ export default function TopMenu() {
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
{personalSpace ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={getSpaceUrl(personalSpace.slug)}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Personal space")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
hasPersonalSpaces &&
|
||||
settingEnabled && (
|
||||
<Menu.Item
|
||||
onClick={openCreate}
|
||||
leftSection={<IconUser size={16} />}
|
||||
>
|
||||
{t("Create personal space")}
|
||||
</Menu.Item>
|
||||
)
|
||||
)}
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
@@ -160,5 +196,8 @@ export default function TopMenu() {
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||
import { getShares } from "@/features/share/services/share-service.ts";
|
||||
import { getApiKeys } from "@/ee/api-key";
|
||||
import { getAuditLogs } from "@/ee/audit/services/audit-service";
|
||||
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
|
||||
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
|
||||
|
||||
export const prefetchWorkspaceMembers = () => {
|
||||
const params: QueryParams = { limit: 100, query: "" };
|
||||
@@ -89,3 +91,18 @@ export const prefetchAuditLogs = () => {
|
||||
queryFn: () => getAuditLogs(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchVerifiedPages = () => {
|
||||
const params = { limit: 50 };
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["verification-list", params],
|
||||
queryFn: () => getVerificationList(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchScimTokens = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["scim-token-list", { cursor: undefined }],
|
||||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
IconFileImport,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
|
||||
@@ -30,11 +32,13 @@ import {
|
||||
prefetchBilling,
|
||||
prefetchGroups,
|
||||
prefetchLicense,
|
||||
prefetchScimTokens,
|
||||
prefetchShares,
|
||||
prefetchSpaces,
|
||||
prefetchSsoProviders,
|
||||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -48,6 +52,7 @@ type DataItem = {
|
||||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
show?: () => boolean;
|
||||
};
|
||||
|
||||
type DataGroup = {
|
||||
@@ -95,6 +100,12 @@ const groupedData: DataGroup[] = [
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
{
|
||||
label: "Verified pages",
|
||||
icon: IconShieldCheck,
|
||||
path: "/settings/verifications",
|
||||
feature: Feature.PAGE_VERIFICATION,
|
||||
},
|
||||
{
|
||||
label: "API management",
|
||||
icon: IconKey,
|
||||
@@ -116,6 +127,14 @@ const groupedData: DataGroup[] = [
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
icon: IconFileImport,
|
||||
path: "/settings/import/confluence",
|
||||
feature: Feature.CONFLUENCE_API_IMPORT,
|
||||
role: "admin",
|
||||
show: () => isBetaConfluenceImporter(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -149,6 +168,7 @@ export default function SettingsSidebar() {
|
||||
entitlements?.features?.includes(f) ?? false;
|
||||
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.show && !item.show()) return false;
|
||||
if (item.env === "cloud" && !isCloud()) return false;
|
||||
if (item.env === "selfhosted" && isCloud()) return false;
|
||||
if (item.role === "admin" && !isAdmin) return false;
|
||||
@@ -196,7 +216,10 @@ export default function SettingsSidebar() {
|
||||
}
|
||||
break;
|
||||
case "Security & SSO":
|
||||
prefetchHandler = prefetchSsoProviders;
|
||||
prefetchHandler = () => {
|
||||
prefetchSsoProviders();
|
||||
prefetchScimTokens();
|
||||
};
|
||||
break;
|
||||
case "Public sharing":
|
||||
prefetchHandler = prefetchShares;
|
||||
@@ -210,37 +233,14 @@ export default function SettingsSidebar() {
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const isDisabled = isItemDisabled(item);
|
||||
const linkElement = (
|
||||
<Link
|
||||
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
data-disabled={isDisabled || undefined}
|
||||
key={item.label}
|
||||
to={isDisabled ? "#" : item.path}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
@@ -250,12 +250,41 @@ export default function SettingsSidebar() {
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
{linkElement}
|
||||
<span
|
||||
className={classes.link}
|
||||
data-disabled
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return linkElement;
|
||||
return (
|
||||
<Link
|
||||
onMouseEnter={prefetchHandler}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -273,7 +302,7 @@ export default function SettingsSidebar() {
|
||||
}}
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label="Back"
|
||||
aria-label={t("Back")}
|
||||
>
|
||||
<IconArrowLeft stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Divider, Title } from '@mantine/core';
|
||||
export default function SettingsTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Title order={3}>
|
||||
<Title order={1} size="h3">
|
||||
{title}
|
||||
</Title>
|
||||
<Divider my="md" />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
gap: var(--mantine-spacing-md);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track > * {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.root:hover .arrow.visible,
|
||||
.arrow.visible:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: -14px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./card-carousel.module.css";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export default function CardCarousel({ children, ariaLabel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 1);
|
||||
setCanScrollRight(el.scrollLeft < maxScroll - 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(updateScrollState);
|
||||
observer.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateScrollState, children]);
|
||||
|
||||
const scrollBy = (direction: 1 | -1) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={classes.track}
|
||||
onScroll={updateScrollState}
|
||||
{...(ariaLabel ? { role: "region", "aria-label": ariaLabel } : {})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowLeft} ${canScrollLeft ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t("Scroll left")}
|
||||
tabIndex={canScrollLeft ? 0 : -1}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${classes.arrow} ${classes.arrowRight} ${canScrollRight ? classes.visible : ""}`}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t("Scroll right")}
|
||||
tabIndex={canScrollRight ? 0 : -1}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Focus styling for list-style tables (recent changes, favorites, all
|
||||
* spaces, groups, verified pages, shares).
|
||||
*
|
||||
* Per WAI-ARIA Authoring Practices and Adrian Roselli's guidance on table
|
||||
* accessibility (https://adrianroselli.com/2020/02/block-links-cards-clickable-regions-etc.html),
|
||||
* data tables should not be made fully clickable. Only the title cell is the
|
||||
* link, and that link is what receives Tab focus.
|
||||
*
|
||||
* - `.row` adds a subtle background tint when the row contains the focused
|
||||
* element, so keyboard users can see which row they're inspecting.
|
||||
* - `.link` adds a visible :focus-visible outline on the title link itself.
|
||||
*
|
||||
* No stretched-link pseudo here on purpose: absolutely-positioned pseudos
|
||||
* inside table cells cause column reflow on focus in Chromium.
|
||||
*/
|
||||
|
||||
.row:focus-within {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { Avatar, MantineColor } from "@mantine/core";
|
||||
import { getAvatarUrl } from "@/lib/config.ts";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
@@ -16,19 +16,68 @@ interface CustomAvatarProps {
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
||||
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
||||
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
||||
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||
"blue.8",
|
||||
"cyan.9",
|
||||
"grape.9",
|
||||
"indigo.8",
|
||||
"pink.8",
|
||||
"red.8",
|
||||
"violet.7",
|
||||
];
|
||||
|
||||
function hashName(input: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function pickInitialsColor(name: string) {
|
||||
return SAFE_INITIALS_COLORS[hashName(name) % SAFE_INITIALS_COLORS.length];
|
||||
}
|
||||
|
||||
function sanitizeInitialsSource(name: string) {
|
||||
const sanitized = name.replace(/[^\p{L}\p{N}\s]/gu, " ").trim();
|
||||
return sanitized || name;
|
||||
}
|
||||
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const isInitials = !color || color === "initials";
|
||||
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const hue = pickedColor.split(".")[0];
|
||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||
|
||||
const resolvedColor = variant === "filled" ? pickedColor : hue;
|
||||
|
||||
const placeholderStyles =
|
||||
isInitials && variant !== "filled"
|
||||
? {
|
||||
placeholder: {
|
||||
color: `var(--mantine-color-${hue}-9)`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
src={avatarLink}
|
||||
name={name}
|
||||
name={initialsSource}
|
||||
alt={name}
|
||||
color="initials"
|
||||
color={resolvedColor}
|
||||
variant={variant}
|
||||
styles={placeholderStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Group, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
DestinationPickerModalProps,
|
||||
DestinationSelection,
|
||||
} from "./destination-picker.types";
|
||||
|
||||
export function DestinationPickerModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
actionLabel,
|
||||
onSelect,
|
||||
loading,
|
||||
excludePageId,
|
||||
pageLimit,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setSelection(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size={550}
|
||||
padding="lg"
|
||||
yOffset="10vh"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{title}</Modal.Title>
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<DestinationPicker
|
||||
onSelectionChange={setSelection}
|
||||
excludePageId={excludePageId}
|
||||
pageLimit={pageLimit}
|
||||
initialSpaceId={initialSpaceId}
|
||||
searchSpacesOnly={searchSpacesOnly}
|
||||
/>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selection && onSelect(selection)}
|
||||
disabled={!selection}
|
||||
loading={loading}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
transition: background-color 150ms ease;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border-left: 2px solid var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.spaceRow {
|
||||
composes: row;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pageRow {
|
||||
composes: row;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
composes: row;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconSearch, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { DestinationSelection } from "./destination-picker.types";
|
||||
import { SpaceRow } from "./space-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type DestinationPickerProps = {
|
||||
onSelectionChange: (selection: DestinationSelection | null) => void;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
|
||||
export function DestinationPicker({
|
||||
onSelectionChange,
|
||||
excludePageId,
|
||||
pageLimit = 15,
|
||||
initialSpaceId,
|
||||
searchSpacesOnly,
|
||||
}: DestinationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<DestinationSelection | null>(null);
|
||||
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const searchEnabled =
|
||||
!searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2;
|
||||
|
||||
const { data: searchData, isLoading: searchLoading } =
|
||||
useSearchSuggestionsQuery({
|
||||
query: searchEnabled ? debouncedQuery : "",
|
||||
includePages: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const isSearching = !!searchEnabled;
|
||||
|
||||
const filteredSpaces = useMemo(() => {
|
||||
const items = spacesData?.items ?? [];
|
||||
if (!searchSpacesOnly || !debouncedQuery) return items;
|
||||
const fold = (s: string) =>
|
||||
s
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLocaleLowerCase();
|
||||
const term = fold(debouncedQuery);
|
||||
return items.filter((s) => fold(s.name).includes(term));
|
||||
}, [spacesData, searchSpacesOnly, debouncedQuery]);
|
||||
|
||||
const selectedId =
|
||||
selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null;
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: DestinationSelection | null) => {
|
||||
setSelection(next);
|
||||
onSelectionChange(next);
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const handleSearchResultClick = (page: Partial<IPage>) => {
|
||||
if (!page.space || !page.id) return;
|
||||
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space: page.space,
|
||||
});
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleSelectSpace = useCallback(
|
||||
(space: ISpace) => {
|
||||
updateSelection({ type: "space", spaceId: space.id, space });
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback(
|
||||
(page: Partial<IPage>, space: ISpace) => {
|
||||
if (!page.id) return;
|
||||
updateSelection({
|
||||
type: "page",
|
||||
spaceId: page.spaceId ?? space.id,
|
||||
pageId: page.id,
|
||||
page,
|
||||
space,
|
||||
});
|
||||
},
|
||||
[updateSelection],
|
||||
);
|
||||
|
||||
// Pre-select space when initialSpaceId is set and spaces have loaded.
|
||||
// Only runs once: skip if user has already made a selection.
|
||||
useEffect(() => {
|
||||
if (!initialSpaceId || selection) return;
|
||||
const match = spacesData?.items?.find((s) => s.id === initialSpaceId);
|
||||
if (match) {
|
||||
updateSelection({ type: "space", spaceId: match.id, space: match });
|
||||
requestAnimationFrame(() => {
|
||||
const el = viewportRef.current?.querySelector<HTMLElement>(
|
||||
`[data-space-id="${match.id}"]`,
|
||||
);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
}, [initialSpaceId, selection, spacesData, updateSelection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
aria-label={
|
||||
searchSpacesOnly
|
||||
? t("Search spaces...")
|
||||
: t("Search pages and spaces...")
|
||||
}
|
||||
variant="filled"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
h="50vh"
|
||||
offsetScrollbars
|
||||
className={classes.scrollArea}
|
||||
viewportRef={viewportRef}
|
||||
>
|
||||
{isSearching ? (
|
||||
searchLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : searchData?.pages && searchData.pages.length > 0 ? (
|
||||
searchData.pages.map(
|
||||
(page) =>
|
||||
page && (
|
||||
<div
|
||||
key={page.id}
|
||||
className={classes.searchResult}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSearchResultClick(page)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSearchResultClick(page);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.pageTitle}>
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
{page.space && (
|
||||
<div className={classes.spaceName}>
|
||||
{page.space.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className={classes.emptyState}>{t("No results found")}</div>
|
||||
)
|
||||
) : spacesLoading ? (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : filteredSpaces.length === 0 ? (
|
||||
<div className={classes.emptyState}>
|
||||
{searchSpacesOnly && debouncedQuery
|
||||
? t("No spaces found")
|
||||
: t("No results found")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSpaces.map((space) => (
|
||||
<SpaceRow
|
||||
key={space.id}
|
||||
space={space}
|
||||
limit={pageLimit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectSpace={handleSelectSpace}
|
||||
onSelectPage={handleSelectPage}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
export type DestinationSelection =
|
||||
| { type: "space"; spaceId: string; space: ISpace }
|
||||
| {
|
||||
type: "page";
|
||||
spaceId: string;
|
||||
pageId: string;
|
||||
page: Partial<IPage>;
|
||||
space: Partial<ISpace>;
|
||||
};
|
||||
|
||||
export type DestinationPickerModalProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
onSelect: (selection: DestinationSelection) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
excludePageId?: string;
|
||||
pageLimit?: number;
|
||||
initialSpaceId?: string;
|
||||
searchSpacesOnly?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { PageRow } from "./page-row";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageChildrenProps = {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectPage: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageChildren({
|
||||
spaceId,
|
||||
pageId,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectPage,
|
||||
}: PageChildrenProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({
|
||||
queryKey: ["destination-pages", spaceId, pageId ?? "root"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({
|
||||
spaceId,
|
||||
pageId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IPage>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
{pageId ? t("No pages inside") : t("No pages in this space")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages.map((page) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={depth}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelect={onSelectPage}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div
|
||||
className={classes.loadMore}
|
||||
onClick={() => fetchNextPage()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{t("Load more")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type PageRowProps = {
|
||||
page: Partial<IPage>;
|
||||
depth: number;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelect: (page: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export function PageRow({
|
||||
page,
|
||||
depth,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelect,
|
||||
}: PageRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isExcluded = page.id === excludePageId;
|
||||
const isSelected = page.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.pageRow,
|
||||
isSelected && classes.selected,
|
||||
isExcluded && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (!isExcluded) onSelect(page);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={rowClasses}
|
||||
style={{ paddingLeft: depth * 20 + 12 }}
|
||||
role="button"
|
||||
tabIndex={isExcluded ? -1 : 0}
|
||||
aria-disabled={isExcluded || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<ActionIcon
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<div className={classes.iconWrapper}>
|
||||
{page.icon ? (
|
||||
page.icon
|
||||
) : (
|
||||
<ActionIcon
|
||||
component="div"
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
size={22}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.pageTitle}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && page.hasChildren && (
|
||||
<PageChildren
|
||||
spaceId={page.spaceId}
|
||||
pageId={page.id}
|
||||
depth={depth + 1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={onSelect}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { KeyboardEvent, useState } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconChevronRight, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { SpaceRole } from "@/lib/types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
type SpaceRowProps = {
|
||||
space: ISpace;
|
||||
limit: number;
|
||||
selectedId: string | null;
|
||||
excludePageId?: string;
|
||||
onSelectSpace: (space: ISpace) => void;
|
||||
onSelectPage: (page: Partial<IPage>, space: ISpace) => void;
|
||||
};
|
||||
|
||||
export function SpaceRow({
|
||||
space,
|
||||
limit,
|
||||
selectedId,
|
||||
excludePageId,
|
||||
onSelectSpace,
|
||||
onSelectPage,
|
||||
}: SpaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const writable =
|
||||
!!space.membership?.role && space.membership.role !== SpaceRole.READER;
|
||||
const isSelected = space.id === selectedId;
|
||||
|
||||
const rowClasses = [
|
||||
classes.spaceRow,
|
||||
isSelected && classes.selected,
|
||||
!writable && classes.disabled,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const handleSelect = () => {
|
||||
if (writable) onSelectSpace(space);
|
||||
};
|
||||
|
||||
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
className={rowClasses}
|
||||
data-space-id={space.id}
|
||||
role="button"
|
||||
tabIndex={writable ? 0 : -1}
|
||||
aria-disabled={!writable || undefined}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
{writable ? (
|
||||
<ActionIcon
|
||||
className={`${classes.chevron} ${expanded ? classes.chevronExpanded : ""}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={expanded ? t("Collapse") : t("Expand")}
|
||||
aria-expanded={expanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<div style={{ width: 20, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
size={22}
|
||||
/>
|
||||
|
||||
<div className={classes.pageTitle}>{space.name}</div>
|
||||
|
||||
{!writable && (
|
||||
<IconLock
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{writable ? (
|
||||
rowContent
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t("You don't have permission to create pages here")}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<div>{rowContent}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{expanded && writable && (
|
||||
<PageChildren
|
||||
spaceId={space.id}
|
||||
depth={1}
|
||||
limit={limit}
|
||||
selectedId={selectedId}
|
||||
excludePageId={excludePageId}
|
||||
onSelectPage={(page) => onSelectPage(page, space)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
@@ -7,9 +7,24 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
const Picker = React.lazy(() => import("@emoji-mart/react"));
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Load the picker module AND the emoji data in parallel inside the lazy
|
||||
// resolution, then bind the data into the component. React.lazy only finishes
|
||||
// suspending once both are in memory, so the Suspense boundary hides the
|
||||
// Remove button until the Picker can render with real content.
|
||||
const Picker = React.lazy(async () => {
|
||||
const [pickerModule, dataModule] = await Promise.all([
|
||||
import("@slidoapp/emoji-mart-react"),
|
||||
import("@slidoapp/emoji-mart-data"),
|
||||
]);
|
||||
const PickerComp = pickerModule.default;
|
||||
const data = dataModule.default;
|
||||
return {
|
||||
default: (props: any) => <PickerComp {...props} data={data} />,
|
||||
};
|
||||
});
|
||||
|
||||
export interface EmojiPickerInterface {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
icon: ReactNode;
|
||||
@@ -19,6 +34,7 @@ export interface EmojiPickerInterface {
|
||||
size?: string;
|
||||
variant?: string;
|
||||
c?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +66,38 @@ function EmojiPicker({
|
||||
}
|
||||
});
|
||||
|
||||
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
|
||||
// makes the browser scroll every scrollable ancestor of the search input to
|
||||
// bring it on screen — including the page editor's scroll container, so the
|
||||
// page jumps to the top whenever the picker is opened from a scrolled-down
|
||||
// position. The search input lives inside the <em-emoji-picker> custom
|
||||
// element's shadow root, so we poll for it after the dropdown mounts and
|
||||
// focus it ourselves with preventScroll.
|
||||
useEffect(() => {
|
||||
if (!opened || !dropdown) return;
|
||||
let cancelled = false;
|
||||
let rafId = 0;
|
||||
const tryFocus = (attempts: number) => {
|
||||
if (cancelled) return;
|
||||
const pickerEl = dropdown.querySelector("em-emoji-picker");
|
||||
const input = pickerEl?.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (attempts < 60) {
|
||||
rafId = requestAnimationFrame(() => tryFocus(attempts + 1));
|
||||
}
|
||||
};
|
||||
rafId = requestAnimationFrame(() => tryFocus(0));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [opened, dropdown]);
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
onEmojiSelect(emoji);
|
||||
handlers.close();
|
||||
@@ -70,11 +118,15 @@ function EmojiPicker({
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Popover.Target ref={setTarget}>
|
||||
<ActionIcon
|
||||
c={actionIconProps?.c || "gray"}
|
||||
variant={actionIconProps?.variant || "transparent"}
|
||||
<ActionIcon
|
||||
c={actionIconProps?.c || "gray"}
|
||||
variant={actionIconProps?.variant || "transparent"}
|
||||
size={actionIconProps?.size}
|
||||
tabIndex={actionIconProps?.tabIndex}
|
||||
onClick={handlers.toggle}
|
||||
aria-label={t("Pick emoji")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={opened}
|
||||
>
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
@@ -82,7 +134,6 @@ function EmojiPicker({
|
||||
<Suspense fallback={null}>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Picker
|
||||
data={async () => (await import("@emoji-mart/data")).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
|
||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
||||
export const RadioMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<"button">
|
||||
>((props, ref) => (
|
||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
||||
));
|
||||
|
||||
RadioMenuItem.displayName = "RadioMenuItem";
|
||||
@@ -14,7 +14,14 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
|
||||
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
|
||||
({ opened, size = "sm", ...others }, ref) => {
|
||||
return (
|
||||
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
aria-expanded={opened}
|
||||
{...others}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
ref={ref}
|
||||
>
|
||||
{opened ? (
|
||||
<IconLayoutSidebarRightExpand />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.skipLink {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 9999;
|
||||
padding: 8px 16px;
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-text);
|
||||
border: 2px solid var(--mantine-color-blue-6);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
transform: translateY(-200%);
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.skipLink:focus {
|
||||
transform: translateY(0);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.skipLink {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./skip-to-main.module.css";
|
||||
|
||||
export const MAIN_CONTENT_ID = "main-content";
|
||||
|
||||
export function SkipToMain() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a href={`#${MAIN_CONTENT_ID}`} className={classes.skipLink}>
|
||||
{t("Skip to main content")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,39 @@
|
||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||
|
||||
The Docmost Enterprise License (the “Enterprise License”)
|
||||
Copyright (c) 2023-present Docmost, Inc
|
||||
|
||||
|
||||
With regard to the Docmost Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Docmost Subscription Terms of Service, available
|
||||
at https://docmost.com/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
|
||||
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
|
||||
Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Docmost
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Docmost Enterprise Edition subscription for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Docmost and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Docmost Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||
import { useChatStream } from "../hooks/use-chat-stream";
|
||||
import ChatMessageList from "./chat-message-list";
|
||||
import ChatEmptyState from "./chat-empty-state";
|
||||
import ChatInput from "./chat-input";
|
||||
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
export default function AiChatLayout() {
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||
|
||||
// If the URL points at a chat the user does not own, the info fetch 404s.
|
||||
// Bounce them back to /ai so they cannot interact with any chat UI (including
|
||||
// kicking off orphan uploads) tied to a chat they have no access to.
|
||||
useEffect(() => {
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
navigate("/ai", { replace: true });
|
||||
}
|
||||
}, [chatId, chatInfoQuery.isError, navigate]);
|
||||
const {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
} = useChatStream(chatId);
|
||||
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatInfoQuery.data?.messages) {
|
||||
hydrateFromServer(chatInfoQuery.data.messages);
|
||||
}
|
||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSentRef.current || chatId) return;
|
||||
const state = location.state as HomeAiPromptInitialState | null;
|
||||
if (!state?.initialContent && !state?.initialAttachments?.length) return;
|
||||
|
||||
autoSentRef.current = true;
|
||||
sendMessage(
|
||||
state.initialContent ?? "",
|
||||
state.initialMentions ?? [],
|
||||
state.initialAttachments ?? [],
|
||||
);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [chatId, location, navigate, sendMessage]);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming || !!chatId;
|
||||
|
||||
// While the redirect effect is running (or if the user is still on this
|
||||
// component for any reason) never render the chat UI for a forbidden chat.
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.main}>
|
||||
{hasMessages ? (
|
||||
<>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={classes.inputArea}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ChatEmptyState
|
||||
isStreaming={isStreaming}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { ActionIcon, Menu, TextInput } from "@mantine/core";
|
||||
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
type Props = {
|
||||
chat: AiChat;
|
||||
isActive: boolean;
|
||||
onDelete: (chatId: string, title: string | null) => void;
|
||||
onRename: (chatId: string, title: string) => void;
|
||||
};
|
||||
|
||||
function formatChatDate(
|
||||
isoString: string | Date,
|
||||
locale: string | undefined,
|
||||
): string {
|
||||
const date = new Date(isoString);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const ts = date.getTime();
|
||||
const sameYear = date.getFullYear() === now.getFullYear();
|
||||
|
||||
if (ts >= startOfToday) {
|
||||
return date.toLocaleTimeString(locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
if (sameYear) {
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AiChatSidebarItem({
|
||||
chat,
|
||||
isActive,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formattedDate = useMemo(
|
||||
() => formatChatDate(chat.updatedAt, i18n.language),
|
||||
[chat.updatedAt, i18n.language],
|
||||
);
|
||||
|
||||
const chatTitle = chat.title || t("Untitled chat");
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
// Wait for the input to be mounted before selecting.
|
||||
const id = window.setTimeout(() => inputRef.current?.select(), 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
}, [renaming]);
|
||||
|
||||
const startRename = useCallback(() => {
|
||||
setRenameValue(chat.title || "");
|
||||
setRenaming(true);
|
||||
}, [chat.title]);
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== chat.title) {
|
||||
onRename(chat.id, trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
}, [renameValue, chat.id, chat.title, onRename]);
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div className={classes.chatItem} data-active={isActive || undefined}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
placeholder={t("Chat name")}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
onBlur={submitRename}
|
||||
classNames={{ input: classes.chatItemRenameInput }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/ai/chat/${chat.id}`}
|
||||
className={classes.chatItem}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||
<div className={classes.chatItemActions}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
{t("Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(chat.id, chat.title);
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Text,
|
||||
TextInput,
|
||||
Loader,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useChatsQuery,
|
||||
useDeleteChatMutation,
|
||||
useUpdateChatTitleMutation,
|
||||
useSearchChatsQuery,
|
||||
} from "../queries/ai-chat-query";
|
||||
import AiChatSidebarItem from "./ai-chat-sidebar-item";
|
||||
import { groupChatsByAge } from "../utils/group-chats-by-age";
|
||||
import classes from "../styles/chat-sidebar.module.css";
|
||||
|
||||
export default function AiChatSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const chatsQuery = useChatsQuery();
|
||||
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||
const deleteMutation = useDeleteChatMutation();
|
||||
const renameMutation = useUpdateChatTitleMutation();
|
||||
|
||||
const chats = useMemo(() => {
|
||||
if (debouncedSearch) {
|
||||
return searchQuery.data || [];
|
||||
}
|
||||
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
|
||||
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
|
||||
|
||||
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
|
||||
const isSearching = Boolean(debouncedSearch);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) return;
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate("/ai");
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, title: string | null) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete chat"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
|
||||
title: title || t("Untitled"),
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
if (chatId === id) {
|
||||
navigate("/ai");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[deleteMutation, chatId, navigate, t],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(chatId: string, title: string) => {
|
||||
renameMutation.mutate({ chatId, title });
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className={classes.sidebar}>
|
||||
<div className={classes.header}>
|
||||
<h2 className={classes.title}>{t("AI Chat")}</h2>
|
||||
<Tooltip label={t("New chat")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/ai"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleNewChat}
|
||||
aria-label={t("New chat")}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={classes.searchInput}
|
||||
placeholder={t("Search chats...")}
|
||||
aria-label={t("Search chats")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<div className={classes.chatList}>
|
||||
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className={classes.chatListEmpty}>
|
||||
<IconMessageCircle2
|
||||
size={28}
|
||||
stroke={1.5}
|
||||
className={classes.chatListEmptyIcon}
|
||||
/>
|
||||
<div className={classes.chatListEmptyTitle}>
|
||||
{isSearching ? t("No chats found") : t("No conversations yet")}
|
||||
</div>
|
||||
<div className={classes.chatListEmptyHint}>
|
||||
{isSearching
|
||||
? t("Try a different search term.")
|
||||
: t("Start a new chat to see it here.")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSearching
|
||||
? chats.map((chat) => (
|
||||
<AiChatSidebarItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === chatId}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))
|
||||
: groupedChats.map((group) => (
|
||||
<div key={group.key} className={classes.chatGroup}>
|
||||
<h3 className={classes.chatGroupLabel}>{group.label}</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<AiChatSidebarItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === chatId}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!isSearching && (
|
||||
<>
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
{isFetchingNextPage && (
|
||||
<Center py="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../styles/aside-chat-panel.module.css";
|
||||
|
||||
type Props = {
|
||||
activeChatId: string | undefined;
|
||||
onSelect: (chatId: string) => void;
|
||||
};
|
||||
|
||||
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
|
||||
|
||||
const chatsQuery = useChatsQuery();
|
||||
const searchQuery = useSearchChatsQuery(debouncedSearch);
|
||||
|
||||
const isSearching = debouncedSearch.length > 0;
|
||||
const chats = isSearching
|
||||
? (searchQuery.data ?? [])
|
||||
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
|
||||
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextInput
|
||||
placeholder={t("Search chats...")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
size="xs"
|
||||
mb="xs"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{isSearching ? t("No chats found") : t("No chat history")}
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={300} scrollbars="y">
|
||||
<div className={classes.historyList}>
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={classes.historyItem}
|
||||
data-active={chat.id === activeChatId || undefined}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
>
|
||||
<span className={classes.historyItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
IconArrowsDiagonal,
|
||||
IconX,
|
||||
IconSparkles,
|
||||
IconFileText,
|
||||
IconLanguage,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useChatStream } from "../hooks/use-chat-stream";
|
||||
import { useChatInfoQuery } from "../queries/ai-chat-query";
|
||||
import ChatMessageList from "./chat-message-list";
|
||||
import ChatInput from "./chat-input";
|
||||
import AsideChatHistory from "./aside-chat-history";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/aside-chat-panel.module.css";
|
||||
|
||||
type QuickAction = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export default function AsideChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [chatId, setChatId] = useState<string | undefined>(undefined);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [contextPages, setContextPages] = useState<PageMention[]>([]);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
|
||||
const chatInfoQuery = useChatInfoQuery(chatId);
|
||||
const {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
} = useChatStream(chatId, {
|
||||
onChatCreated: (newChatId) => {
|
||||
setChatId(newChatId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !chatId) {
|
||||
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
|
||||
}
|
||||
}, [page, chatId]);
|
||||
|
||||
const handleRemoveContextPage = useCallback((pageId: string) => {
|
||||
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatInfoQuery.data?.messages) {
|
||||
hydrateFromServer(chatInfoQuery.data.messages);
|
||||
}
|
||||
}, [chatInfoQuery.data, hydrateFromServer]);
|
||||
|
||||
// Drop the open chatId if the current user lost access to it (404/403 on
|
||||
// the info fetch). Reverts the panel to a fresh chat instead of presenting
|
||||
// an input tied to a chat the user does not own.
|
||||
useEffect(() => {
|
||||
if (chatId && chatInfoQuery.isError) {
|
||||
setChatId(undefined);
|
||||
}
|
||||
}, [chatId, chatInfoQuery.isError]);
|
||||
|
||||
const handleNewChat = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setChatId(undefined);
|
||||
if (page) {
|
||||
setContextPages([
|
||||
{ id: page.id, title: page.title || "", slugId: page.slugId },
|
||||
]);
|
||||
}
|
||||
},
|
||||
[page],
|
||||
);
|
||||
|
||||
const handleSelectChat = useCallback((selectedChatId: string) => {
|
||||
setChatId(selectedChatId);
|
||||
setHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (chatId) {
|
||||
navigate(`/ai/chat/${chatId}`);
|
||||
} else {
|
||||
navigate("/ai");
|
||||
}
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [chatId, navigate, setAsideState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAsideState({ tab: "", isAsideOpen: false });
|
||||
}, [setAsideState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
|
||||
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
|
||||
sendMessage(content, mentions, attachments, contextPageId);
|
||||
},
|
||||
[sendMessage, contextPages],
|
||||
);
|
||||
|
||||
const handleQuickAction = useCallback(
|
||||
(prompt: string) => {
|
||||
handleSend(prompt, [], []);
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
|
||||
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
|
||||
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.toolbar}>
|
||||
<Popover
|
||||
opened={historyOpen}
|
||||
onChange={setHistoryOpen}
|
||||
position="bottom-start"
|
||||
width={280}
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.titleButton}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
>
|
||||
<span className={classes.titleText}>
|
||||
{chatInfoQuery.data?.chat?.title || t("New chat")}
|
||||
</span>
|
||||
<IconChevronDown size={16} stroke={1.75} />
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div className={classes.toolbarSpacer} />
|
||||
|
||||
<Tooltip label={t("New chat")} openDelay={250}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href="/ai"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("New chat")}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<IconPlus size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Open full page")} openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Open full page")}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<IconArrowsDiagonal size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("Close")} openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
aria-label={t("Close")}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<IconX size={20} stroke={1.75} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
|
||||
color: "var(--mantine-color-red-6)",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMessages ? (
|
||||
<>
|
||||
<div className={classes.messages} data-aside-chat>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
|
||||
<div className={classes.quickActions}>
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
className={classes.quickAction}
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
>
|
||||
<span className={classes.quickActionIcon}>{action.icon}</span>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.inputArea}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
onStop={stopGeneration}
|
||||
placeholder={t("Ask anything...")}
|
||||
autofocus={false}
|
||||
contextPages={contextPages}
|
||||
onRemoveContextPage={handleRemoveContextPage}
|
||||
variant="flat"
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
IconSparkles,
|
||||
IconSearch,
|
||||
IconFilePlus,
|
||||
IconEdit,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChatInput from "./chat-input";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
type Suggestion = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
const SUGGESTIONS: Suggestion[] = [
|
||||
{
|
||||
icon: <IconSearch size={16} />,
|
||||
text: "Search across all pages",
|
||||
prompt: "Search for pages about ",
|
||||
},
|
||||
{
|
||||
icon: <IconFilePlus size={16} />,
|
||||
text: "Create a new page",
|
||||
prompt: "Create a new page titled ",
|
||||
},
|
||||
{
|
||||
icon: <IconFileText size={16} />,
|
||||
text: "Summarize a page",
|
||||
prompt: "Summarize the page @",
|
||||
},
|
||||
{
|
||||
icon: <IconEdit size={16} />,
|
||||
text: "Update page content",
|
||||
prompt: "Update the page @",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSuggestionClick = (prompt: string) => {
|
||||
onSend(prompt, [], []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.emptyState}>
|
||||
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
|
||||
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
|
||||
<h1 className={classes.emptyStateTitle}>
|
||||
{t("What can I help you with?")}
|
||||
</h1>
|
||||
|
||||
<div className={classes.emptyStateInput}>
|
||||
<ChatInput
|
||||
isStreaming={isStreaming}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
placeholder={t("Ask anything... Use @ to mention pages")}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.suggestionsSection}>
|
||||
<h2 className={classes.suggestionsLabel}>{t("Get started")}</h2>
|
||||
<div className={classes.suggestionsGrid}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className={classes.suggestionCard}
|
||||
onClick={() => handleSuggestionClick(s.prompt)}
|
||||
>
|
||||
<span className={classes.suggestionIcon}>{s.icon}</span>
|
||||
<span className={classes.suggestionText}>{s.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { CharacterCount } from "@tiptap/extensions";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
import { uploadChatFile } from "../services/ai-chat-service";
|
||||
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-input.module.css";
|
||||
|
||||
type PendingAttachment = ChatAttachment & { uploading: boolean };
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
|
||||
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
|
||||
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
||||
|
||||
type Props = {
|
||||
isStreaming: boolean;
|
||||
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
|
||||
onStop: () => void;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
contextPages?: PageMention[];
|
||||
onRemoveContextPage?: (pageId: string) => void;
|
||||
variant?: "card" | "flat";
|
||||
showDisclaimer?: boolean;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
function extractMentions(json: any): PageMention[] {
|
||||
const mentions: PageMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
|
||||
if (!seen.has(node.attrs.entityId)) {
|
||||
seen.add(node.attrs.entityId);
|
||||
mentions.push({
|
||||
id: node.attrs.entityId,
|
||||
title: node.attrs.label || "",
|
||||
slugId: node.attrs.slugId || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function editorJsonToText(json: any): string {
|
||||
let text = "";
|
||||
|
||||
function walk(node: any) {
|
||||
if (node.type === "text") {
|
||||
text += node.text || "";
|
||||
} else if (node.type === "mention") {
|
||||
text += `@${node.attrs?.label || ""}`;
|
||||
} else if (node.type === "paragraph") {
|
||||
if (text.length > 0) text += "\n";
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
isStreaming,
|
||||
onSend,
|
||||
onStop,
|
||||
placeholder,
|
||||
autofocus = true,
|
||||
contextPages,
|
||||
onRemoveContextPage,
|
||||
variant = "card",
|
||||
showDisclaimer = true,
|
||||
chatId,
|
||||
}: Props) {
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
const { t } = useTranslation();
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const plusMenuId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
|
||||
const handleFileSelect = useCallback(async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
|
||||
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
|
||||
if (room <= 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t("You can attach up to {{max}} files per message.", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
}),
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = Array.from(files);
|
||||
const accepted = incoming.slice(0, room);
|
||||
|
||||
if (incoming.length > accepted.length) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
message: t(
|
||||
"Only the first {{n}} file(s) were added (max {{max}} per message).",
|
||||
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of accepted) {
|
||||
const tempId = `uploading-${Date.now()}-${Math.random()}`;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const placeholder: PendingAttachment = {
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
fileExt: ext,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
uploading: true,
|
||||
};
|
||||
|
||||
setPendingAttachments((prev) => [...prev, placeholder]);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadChatFile(file, chatIdRef.current);
|
||||
setPendingAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === tempId ? { ...uploaded, uploading: false } : a,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [pendingAttachments.length, t]);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!editor || editor.isDestroyed || isStreaming) return;
|
||||
const json = editor.getJSON();
|
||||
const text = editorJsonToText(json).trim();
|
||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||
if (!text && readyAttachments.length === 0) return;
|
||||
|
||||
const mentions = extractMentions(json);
|
||||
onSendRef.current(text, mentions, readyAttachments);
|
||||
editor.commands.clearContent();
|
||||
editor.commands.focus();
|
||||
setPendingAttachments([]);
|
||||
}, [isStreaming, pendingAttachments]);
|
||||
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 50000,
|
||||
}),
|
||||
LinkExtension,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => [],
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
role: "textbox",
|
||||
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
|
||||
"aria-multiline": "true",
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
|
||||
event.key,
|
||||
)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
const mentionPopup = document.querySelector("#mention");
|
||||
if (emojiCommand || mentionPopup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmitRef.current();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
setIsEmpty(!e.getText().trim());
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
|
||||
|
||||
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={wrapperClass} data-chat-input>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
multiple
|
||||
aria-label={t("Add files")}
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
/>
|
||||
|
||||
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
|
||||
<div className={classes.attachmentChips}>
|
||||
{contextPages?.map((page) => (
|
||||
<div key={page.id} className={classes.attachmentChip}>
|
||||
<IconFileText size={14} />
|
||||
<span className={classes.attachmentChipName}>
|
||||
{page.title || "Untitled"}
|
||||
</span>
|
||||
{onRemoveContextPage && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => onRemoveContextPage(page.id)}
|
||||
aria-label={`Remove ${page.title}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pendingAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
|
||||
>
|
||||
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
|
||||
<IconPhoto size={14} />
|
||||
) : (
|
||||
<IconFile size={14} />
|
||||
)}
|
||||
<span className={classes.attachmentChipName}>
|
||||
{attachment.fileName}
|
||||
</span>
|
||||
{!attachment.uploading && (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.attachmentChipRemove}
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
aria-label={`Remove ${attachment.fileName}`}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} className={classes.editorContent} />
|
||||
<div className={classes.actions}>
|
||||
<Popover
|
||||
opened={plusMenuOpen}
|
||||
onChange={setPlusMenuOpen}
|
||||
position="top-start"
|
||||
width={220}
|
||||
shadow="md"
|
||||
withRoles={false}
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.plusButton}
|
||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||
aria-label="Add content"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={plusMenuOpen}
|
||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
|
||||
title={
|
||||
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
|
||||
? t("Max {{max}} files per message", {
|
||||
max: MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IconPaperclip size={16} className={classes.plusMenuIcon} />
|
||||
{t("Add files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
editor?.commands.focus();
|
||||
setPlusMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
{t("Mention a page")}
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.stopButton}
|
||||
onClick={onStop}
|
||||
aria-label="Stop generation"
|
||||
>
|
||||
<IconPlayerStopFilled size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.sendButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconArrowUp size={16} stroke={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDisclaimer && (
|
||||
<div className={classes.disclaimer}>
|
||||
{t("AI-generated content may not be accurate.")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "@mantine/core";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatMessage from "./chat-message";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
function ChatMessageErrorFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.messageErrorFallback}>
|
||||
<IconAlertTriangle size={14} />
|
||||
<span>{t("Failed to render this message.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
messages: AiChatMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingToolCalls: AiChatToolCall[];
|
||||
};
|
||||
|
||||
const BOTTOM_THRESHOLD_PX = 32;
|
||||
const SCROLL_UP_THRESHOLD_PX = 5;
|
||||
const SMOOTH_SCROLL_SETTLE_MS = 600;
|
||||
|
||||
export default function ChatMessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
const prevScrollTopRef = useRef(0);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
|
||||
// Dedicated status-region announcement for screen readers. Rather than
|
||||
// putting aria-live on the whole transcript (which re-fires for every
|
||||
// streamed token), announce "AI is thinking…" when streaming starts and
|
||||
// the full assistant reply once streaming completes — a single, clean read.
|
||||
const [statusAnnouncement, setStatusAnnouncement] = useState("");
|
||||
const wasStreamingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const justStartedStreaming = isStreaming && !wasStreamingRef.current;
|
||||
const justFinishedStreaming = !isStreaming && wasStreamingRef.current;
|
||||
|
||||
if (justStartedStreaming) {
|
||||
setStatusAnnouncement(t("AI is thinking..."));
|
||||
} else if (justFinishedStreaming) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage?.role === "assistant" && lastMessage.content) {
|
||||
// Strip markdown punctuation so screen readers don't read symbols
|
||||
// like # * _ ` ~ aloud. A plain-text version is fine — the styled
|
||||
// version stays in the DOM for visual users.
|
||||
const plainText = lastMessage.content
|
||||
.replace(/[#*_`~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
setStatusAnnouncement(plainText);
|
||||
} else {
|
||||
setStatusAnnouncement("");
|
||||
}
|
||||
}
|
||||
|
||||
wasStreamingRef.current = isStreaming;
|
||||
}, [isStreaming, messages, t]);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
isAutoScrollingRef.current = true;
|
||||
const target = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTo({ top: target, behavior });
|
||||
prevScrollTopRef.current = target;
|
||||
isAtBottomRef.current = true;
|
||||
setShowScrollButton(false);
|
||||
|
||||
if (behavior === "smooth") {
|
||||
setTimeout(() => {
|
||||
isAutoScrollingRef.current = false;
|
||||
if (containerRef.current) {
|
||||
prevScrollTopRef.current = containerRef.current.scrollTop;
|
||||
}
|
||||
}, SMOOTH_SCROLL_SETTLE_MS);
|
||||
} else {
|
||||
isAutoScrollingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (isAutoScrollingRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const scrolledUp =
|
||||
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
|
||||
prevScrollTopRef.current = currentScrollTop;
|
||||
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - currentScrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
|
||||
if (scrolledUp) {
|
||||
isAtBottomRef.current = atBottom;
|
||||
} else if (atBottom) {
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
|
||||
setShowScrollButton(!atBottom);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// Instant scroll during streaming to keep up with rapid updates
|
||||
useEffect(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom("instant");
|
||||
}
|
||||
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
|
||||
|
||||
// Smooth scroll for new messages. Always force-scroll when the latest
|
||||
// message is from the user (they just sent it), even if they were reading
|
||||
// scrollback.
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastIsUser = lastMessage?.role === "user";
|
||||
if (lastIsUser || isAtBottomRef.current) {
|
||||
scrollToBottom("smooth");
|
||||
return;
|
||||
}
|
||||
|
||||
// No auto-scroll: recompute from actual layout so that chat switches to
|
||||
// content that doesn't overflow correctly hide the button even when no
|
||||
// scroll event fires.
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setShowScrollButton(!atBottom);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div className={classes.messageListWrapper}>
|
||||
{/* Single status region for chat announcements. Kept outside the
|
||||
scrolling transcript so changes here trigger one polite read per
|
||||
state change instead of re-announcing every streamed token. */}
|
||||
<VisuallyHidden role="status" aria-live="polite">
|
||||
{statusAnnouncement}
|
||||
</VisuallyHidden>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classes.messageList}
|
||||
aria-label={t("Chat transcript")}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<ErrorBoundary
|
||||
key={msg.id}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage message={msg} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
{isStreaming && (
|
||||
<ErrorBoundary
|
||||
resetKeys={[streamingContent, streamingToolCalls.length]}
|
||||
fallback={<ChatMessageErrorFallback />}
|
||||
>
|
||||
<ChatMessage
|
||||
message={{
|
||||
id: "streaming",
|
||||
chatId: "",
|
||||
role: "assistant",
|
||||
content: null,
|
||||
toolCalls: null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}}
|
||||
isStreaming
|
||||
streamingContent={streamingContent}
|
||||
streamingToolCalls={streamingToolCalls}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("Scroll to bottom")}
|
||||
className={classes.scrollToBottomButton}
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
>
|
||||
<IconArrowDown size={16} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFile,
|
||||
IconLoader2,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolGroup from "./chat-tool-group";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
|
||||
|
||||
const chatSanitizer = DOMPurify();
|
||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName !== "A") return;
|
||||
const href = node.getAttribute("href") || "";
|
||||
|
||||
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
|
||||
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
|
||||
const m = href.match(PAGE_PATH_RE);
|
||||
if (m) {
|
||||
node.setAttribute("href", m[0]);
|
||||
node.removeAttribute("target");
|
||||
node.removeAttribute("rel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
|
||||
|
||||
type Props = {
|
||||
message: AiChatMessage;
|
||||
isStreaming?: boolean;
|
||||
streamingContent?: string;
|
||||
streamingToolCalls?: AiChatToolCall[];
|
||||
};
|
||||
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleContentClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
if (message.role === "tool") return null;
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const content = isStreaming ? streamingContent : message.content;
|
||||
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
|
||||
|
||||
if (isUser) {
|
||||
const displayContent = (content || "").replace(
|
||||
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
|
||||
"",
|
||||
);
|
||||
const attachments =
|
||||
(message.metadata?.attachments as {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
}[]) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.userMessage}
|
||||
role="article"
|
||||
aria-label={t("You said:")}
|
||||
>
|
||||
<div className={classes.userBubble}>
|
||||
{attachments.length > 0 && (
|
||||
<div className={classes.messageAttachments}>
|
||||
{attachments.map((a) => (
|
||||
<span key={a.id} className={classes.messageAttachmentChip}>
|
||||
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
|
||||
<IconPhoto size={13} />
|
||||
) : (
|
||||
<IconFile size={13} />
|
||||
)}
|
||||
{a.fileName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{displayContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only label the article when there's something meaningful to announce.
|
||||
// Tool-only assistant turns (no text) shouldn't announce "Assistant said:" with empty content.
|
||||
const hasAnnouncableContent = Boolean(content);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.assistantMessage}
|
||||
role="article"
|
||||
aria-label={hasAnnouncableContent ? t("Assistant said:") : undefined}
|
||||
>
|
||||
<div className={classes.messageContent}>
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
|
||||
)}
|
||||
{content && (
|
||||
<div
|
||||
onClick={handleContentClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: chatSanitizer.sanitize(
|
||||
markdownToHtml(content) as string,
|
||||
{ ADD_ATTR: ["target", "rel"] },
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<>
|
||||
{!content && (
|
||||
<span className={classes.processingIndicator}>
|
||||
<IconLoader2 size={16} className={classes.processingSpinner} />
|
||||
Thinking
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.streamingCursor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isStreaming && message.content && (
|
||||
<div className={classes.messageActions}>
|
||||
<CopyTextButton
|
||||
text={message?.content}
|
||||
label={t("Copy assistant response")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconChevronDown,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
type Props = {
|
||||
toolCalls: AiChatToolCall[];
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const activeCall =
|
||||
isStreaming && toolCalls.length > 0
|
||||
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
|
||||
: undefined;
|
||||
|
||||
const activeLabel = activeCall
|
||||
? TOOL_LABELS[activeCall.name] || activeCall.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={classes.toolGroup}>
|
||||
<div
|
||||
className={classes.toolGroupHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setExpanded((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeLabel ? (
|
||||
<IconLoader2 size={12} className={classes.processingSpinner} />
|
||||
) : expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span className={classes.toolGroupLabel}>
|
||||
{activeLabel ? `${activeLabel}…` : `Steps ${toolCalls.length}`}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolGroupSteps}>
|
||||
{toolCalls.map((tc) => (
|
||||
<ChatToolResult key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
|
||||
import type { AiChatToolCall } from "../types/ai-chat.types";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
list_spaces: "Listed spaces",
|
||||
search_pages: "Searched pages",
|
||||
get_page: "Read page",
|
||||
create_page: "Created page",
|
||||
update_page: "Updated page",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
toolCall: AiChatToolCall;
|
||||
};
|
||||
|
||||
export default function ChatToolResult({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
|
||||
|
||||
return (
|
||||
<div className={classes.toolStep}>
|
||||
<div
|
||||
className={classes.toolStepRow}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<span className={classes.toolStepBullet}>·</span>
|
||||
{expanded ? (
|
||||
<IconChevronDown size={12} />
|
||||
) : (
|
||||
<IconChevronRight size={12} />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={classes.toolStepDetails}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(
|
||||
{ args: toolCall.args, result: toolCall.result },
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
export default function EnableAiChat() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="md">{t("AI Chat")}</Text>
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
{t("Beta")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AiChatToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AiChatToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
|
||||
const hasAccess = useHasFeature(Feature.AI);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err: any) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle AI Chat")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { sendChatMessage } from "../services/ai-chat-service";
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
AiChatToolCall,
|
||||
ChatAttachment,
|
||||
PageMention,
|
||||
} from "../types/ai-chat.types";
|
||||
|
||||
type ChatStreamOptions = {
|
||||
onChatCreated?: (chatId: string) => void;
|
||||
};
|
||||
|
||||
export function useChatStream(
|
||||
chatId: string | undefined,
|
||||
options?: ChatStreamOptions,
|
||||
) {
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
|
||||
[],
|
||||
);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
||||
const [isRetryable, setIsRetryable] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const currentChatIdRef = useRef(chatId);
|
||||
currentChatIdRef.current = chatId;
|
||||
// Tracks which chatId the local `messages` state currently represents.
|
||||
// Set when we seed from a server fetch AND when we optimistically own a
|
||||
// freshly-created chat after `chat_created`. This is the single authority
|
||||
// marker that keeps server-state effects from clobbering in-flight streams.
|
||||
const hydratedChatIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Reset local state when the consumer switches to a different chat.
|
||||
// Skip the reset if the new chatId is one the hook itself already claimed
|
||||
// during a new-chat flow — in that case our optimistic state is the truth.
|
||||
useEffect(() => {
|
||||
if (chatId && chatId === hydratedChatIdRef.current) return;
|
||||
hydratedChatIdRef.current = undefined;
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
}, [chatId]);
|
||||
|
||||
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
|
||||
const forId = currentChatIdRef.current;
|
||||
if (!forId) return;
|
||||
if (hydratedChatIdRef.current === forId) return;
|
||||
hydratedChatIdRef.current = forId;
|
||||
setMessages(msgs);
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
|
||||
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
|
||||
|
||||
setError(null);
|
||||
setErrorCode(null);
|
||||
setIsRetryable(false);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
setStreamingToolCalls([]);
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (mentions.length) {
|
||||
metadata.mentionedPageIds = mentions.map((m) => m.id);
|
||||
}
|
||||
if (attachments.length) {
|
||||
metadata.attachments = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
fileExt: a.fileExt,
|
||||
}));
|
||||
}
|
||||
|
||||
const userMessage: AiChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "user",
|
||||
content,
|
||||
toolCalls: null,
|
||||
metadata: Object.keys(metadata).length ? metadata : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
const attachmentIds = attachments.map((a) => a.id);
|
||||
|
||||
const abortController = sendChatMessage(
|
||||
{
|
||||
chatId: currentChatIdRef.current,
|
||||
content,
|
||||
mentionedPageIds: mentions.map((m) => m.id),
|
||||
...(contextPageId && { contextPageId }),
|
||||
...(attachmentIds.length && { attachmentIds }),
|
||||
},
|
||||
(event: AiChatStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case "chat_created":
|
||||
currentChatIdRef.current = event.chatId;
|
||||
// Claim authority over this new chatId so when the consumer's
|
||||
// prop catches up via navigation/onChatCreated, the reset effect
|
||||
// sees a match and preserves our optimistic messages.
|
||||
hydratedChatIdRef.current = event.chatId;
|
||||
if (options?.onChatCreated) {
|
||||
options.onChatCreated(event.chatId);
|
||||
} else {
|
||||
navigate(`/ai/chat/${event.chatId}`, { replace: true });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
break;
|
||||
case "content":
|
||||
setStreamingContent((prev) => prev + event.text);
|
||||
break;
|
||||
case "tool_call":
|
||||
setStreamingToolCalls((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
case "tool_result":
|
||||
setStreamingToolCalls((prev) =>
|
||||
prev.map((tc) =>
|
||||
tc.id === event.id ? { ...tc, result: event.result } : tc,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "done": {
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
const assistantMessage: AiChatMessage = {
|
||||
id: event.messageId,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length
|
||||
? currentToolCalls
|
||||
: null,
|
||||
metadata: event.usage ? { tokenUsage: event.usage } : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
setIsStreaming(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["ai-chat", currentChatIdRef.current],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
setError(event.message);
|
||||
setErrorCode(event.code || null);
|
||||
setIsRetryable(event.retryable || false);
|
||||
setIsStreaming(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
(errorMsg) => {
|
||||
setError(errorMsg);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
() => {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
);
|
||||
|
||||
abortRef.current = abortController;
|
||||
},
|
||||
[isStreaming, navigate, queryClient],
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
|
||||
setStreamingContent((currentContent) => {
|
||||
setStreamingToolCalls((currentToolCalls) => {
|
||||
if (currentContent || currentToolCalls.length > 0) {
|
||||
const partialMessage: AiChatMessage = {
|
||||
id: `stopped-${Date.now()}`,
|
||||
chatId: currentChatIdRef.current || "",
|
||||
role: "assistant",
|
||||
content: currentContent || null,
|
||||
toolCalls: currentToolCalls.length ? currentToolCalls : null,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, partialMessage]);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return "";
|
||||
});
|
||||
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
streamingContent,
|
||||
streamingToolCalls,
|
||||
isStreaming,
|
||||
error,
|
||||
errorCode,
|
||||
isRetryable,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AiChatLayout from "../components/ai-chat-layout";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import classes from "../styles/ai-chat.module.css";
|
||||
|
||||
export default function AiChat() {
|
||||
const { t } = useTranslation();
|
||||
const { chatId } = useParams<{ chatId: string }>();
|
||||
|
||||
return (
|
||||
<div className={classes.layout}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[chatId]}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<EmptyState
|
||||
icon={IconAlertTriangle}
|
||||
title={t("Failed to load chat. An error occurred.")}
|
||||
action={
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
mt="xs"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
{t("Try again")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<AiChatLayout />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
listChats,
|
||||
getChatInfo,
|
||||
deleteChat,
|
||||
updateChatTitle,
|
||||
searchChats,
|
||||
} from "../services/ai-chat-service";
|
||||
|
||||
export function useChatsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["ai-chats"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listChats({ cursor: pageParam, limit: 30 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChatInfoQuery(chatId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chat", chatId],
|
||||
queryFn: () => getChatInfo(chatId!),
|
||||
enabled: !!chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chatId: string) => deleteChat(chatId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChatTitleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
|
||||
updateChatTitle(chatId, title),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSearchChatsQuery(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["ai-chats-search", query],
|
||||
queryFn: () => searchChats(query),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import api from "@/lib/api-client.ts";
|
||||
import type {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatStreamEvent,
|
||||
ChatAttachment,
|
||||
} from "../types/ai-chat.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export async function createChat(): Promise<AiChat> {
|
||||
const req = await api.post<AiChat>("/ai/chats/create");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function listChats(params?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<AiChat>> {
|
||||
const req = await api.post("/ai/chats", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getChatInfo(
|
||||
chatId: string,
|
||||
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
|
||||
const req = await api.post("/ai/chats/info", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai/chats/delete", { chatId });
|
||||
}
|
||||
|
||||
export async function updateChatTitle(
|
||||
chatId: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await api.post("/ai/chats/update", { chatId, title });
|
||||
}
|
||||
|
||||
export async function searchChats(query: string): Promise<AiChat[]> {
|
||||
const req = await api.post("/ai/chats/search", { query });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadChatFile(
|
||||
file: File,
|
||||
chatId?: string,
|
||||
): Promise<ChatAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (chatId) {
|
||||
formData.append("chatId", chatId);
|
||||
}
|
||||
return await api.post("/ai/chats/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
}
|
||||
|
||||
export function sendChatMessage(
|
||||
params: {
|
||||
chatId?: string;
|
||||
content: string;
|
||||
mentionedPageIds?: string[];
|
||||
contextPageId?: string;
|
||||
attachmentIds?: string[];
|
||||
},
|
||||
onEvent: (event: AiChatStreamEvent) => void,
|
||||
onError?: (error: string) => void,
|
||||
onComplete?: () => void,
|
||||
): AbortController {
|
||||
const abortController = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/ai/chats/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: abortController.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = `HTTP error ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
errorMessage = parsed.message || errorMessage;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
onError?.("Response body is not readable");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as AiChatStreamEvent;
|
||||
onEvent(parsed);
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
onError?.(error.message);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return abortController;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageListWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.messageErrorFallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.scrollToBottomButton {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.scrollToBottomButton:hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.scrollToBottomButton:active {
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.emptyStateBrand {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyStateInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.suggestionsSection {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.suggestionsLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.suggestionCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestionText {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 var(--mantine-spacing-sm) 0;
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.toolbarSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.titleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
max-width: 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleButton:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.titleText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--mantine-spacing-sm) 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: var(--mantine-font-size-lg);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quickAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.quickActionIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.historyList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.historyItemTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 16px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: light-dark(
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.07),
|
||||
0 2px 40px 4px rgba(0, 0, 0, 0.5)
|
||||
);
|
||||
transition:
|
||||
border-color 150ms,
|
||||
box-shadow 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
box-shadow: light-dark(
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.09),
|
||||
0 4px 48px 6px rgba(0, 0, 0, 0.6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapperFlat {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
border-radius: 12px;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
box-shadow: none;
|
||||
transition: border-color 150ms;
|
||||
|
||||
&:focus-within {
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.attachmentChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
|
||||
.attachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.attachmentChipUploading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.attachmentChipName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentChipRemove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
border-radius: 50%;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
overflow: hidden;
|
||||
|
||||
:global(.ProseMirror) {
|
||||
outline: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 14px 18px 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-height: 24px;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
:global(.ProseMirror p) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: var(--mantine-color-placeholder);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 12px 10px;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms, opacity 150ms;
|
||||
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
|
||||
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
&:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
transition: color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
}
|
||||
|
||||
.plusButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
||||
transition: color 150ms, background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 150ms;
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plusMenuIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
|
||||
@mixin hover {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
.message {
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
composes: message;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.userBubble {
|
||||
max-width: 75%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px;
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[data-aside-chat] .userBubble {
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.userBubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messageAttachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.messageAttachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistantMessage {
|
||||
composes: message;
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.messageContent p {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
|
||||
.messageContent p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.messageContent ul,
|
||||
.messageContent ol {
|
||||
margin: 0.5em 0 0.75em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.messageContent li {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.messageContent h1,
|
||||
.messageContent h2,
|
||||
.messageContent h3 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.messageContent h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.messageContent h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.messageContent h3 {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.messageContent pre {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
overflow-x: auto;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.messageContent code {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.messageContent pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.messageContent blockquote {
|
||||
border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: var(--mantine-spacing-md);
|
||||
margin: 0.75em 0;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.messageContent a {
|
||||
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
|
||||
text-decoration: none;
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent a[href^="/s/"],
|
||||
.messageContent a[href^="/p/"] {
|
||||
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
@mixin light {
|
||||
border-bottom: 0.05em solid var(--mantine-color-dark-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-bottom: 0.05em solid var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
text-decoration: none;
|
||||
@mixin light {
|
||||
border-bottom-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
@mixin dark {
|
||||
border-bottom-color: var(--mantine-color-dark-0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent hr {
|
||||
border: none;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.toolGroup {
|
||||
margin: 6px 0;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.toolGroupHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
line-height: 1.4;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.toolGroupHeader:hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.toolGroupLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolGroupSteps {
|
||||
margin-top: 4px;
|
||||
padding-left: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolStep {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.toolStepRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
line-height: 1.5;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.toolStepRow:hover {
|
||||
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.toolStepBullet {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toolStepDetails {
|
||||
margin-top: 4px;
|
||||
margin-left: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.processingIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.processingSpinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.streamingCursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.chatList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chatGroup + .chatGroup {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.chatGroupLabel {
|
||||
margin: 0;
|
||||
padding: 4px var(--mantine-spacing-xs);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chatListEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chatListEmptyIcon {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.chatListEmptyTitle {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
|
||||
.chatListEmptyHint {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px var(--mantine-spacing-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
user-select: none;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.chatItemTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatItemDate {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: nowrap;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.chatItemRenameInput {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatItem:hover .chatItemDate,
|
||||
.chatItem:focus-within .chatItemDate {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chatItemActions {
|
||||
position: absolute;
|
||||
right: var(--mantine-spacing-xs);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chatItem:hover .chatItemActions,
|
||||
.chatItem:focus-within .chatItemActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatItemActions :global(.mantine-ActionIcon-root):focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type AiChat = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AiChatToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
export type AiChatMessage = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
toolCalls: AiChatToolCall[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AiChatStreamEvent =
|
||||
| { type: 'chat_created'; chatId: string }
|
||||
| { type: 'content'; text: string }
|
||||
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
|
||||
| { type: 'tool_result'; id: string; result: unknown }
|
||||
| { type: 'done'; messageId: string; usage?: Record<string, number> }
|
||||
| { type: 'error'; message: string; code?: string; retryable?: boolean };
|
||||
|
||||
export type PageMention = {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
spaceSlug?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AiChat } from "../types/ai-chat.types";
|
||||
|
||||
export type ChatGroup = { key: string; label: string; chats: AiChat[] };
|
||||
|
||||
export function groupChatsByAge(
|
||||
chats: AiChat[],
|
||||
t: (key: string) => string,
|
||||
): ChatGroup[] {
|
||||
if (chats.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
|
||||
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const buckets: Record<string, ChatGroup> = {
|
||||
today: { key: "today", label: t("Today"), chats: [] },
|
||||
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
|
||||
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
|
||||
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
|
||||
older: { key: "older", label: t("Older"), chats: [] },
|
||||
};
|
||||
|
||||
for (const chat of chats) {
|
||||
const ts = new Date(chat.updatedAt).getTime();
|
||||
if (ts >= startOfToday) buckets.today.chats.push(chat);
|
||||
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
|
||||
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
|
||||
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
|
||||
else buckets.older.chats.push(chat);
|
||||
}
|
||||
|
||||
return [
|
||||
buckets.today,
|
||||
buckets.yesterday,
|
||||
buckets.last7,
|
||||
buckets.last30,
|
||||
buckets.older,
|
||||
].filter((b) => b.chats.length > 0);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, TextInput } from "@mantine/core";
|
||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconArrowUp } from "@tabler/icons-react";
|
||||
@@ -14,7 +21,7 @@ import { ResultPreview } from "./result-preview.tsx";
|
||||
import classes from "./ai-menu.module.css";
|
||||
import { marked } from "marked";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface EditorAiMenuProps {
|
||||
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
});
|
||||
}, [prompt, output, activeCommandSet]);
|
||||
const updateMenuPlacement = useCallback(() => {
|
||||
if (!editor || !showAiMenu) return;
|
||||
if (!isEditorReady(editor) || !showAiMenu) return;
|
||||
|
||||
const { view } = editor;
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
const handleGenerate = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
if (!editor || isLoading) return;
|
||||
if (!isEditorReady(editor) || isLoading) return;
|
||||
|
||||
let command: CommandItem | null = item || null;
|
||||
|
||||
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
return setActiveCommandSet("main");
|
||||
}
|
||||
if (item.id === "result-replace") {
|
||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
||||
const chain = editor.chain().focus();
|
||||
|
||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
||||
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-insert-below") {
|
||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const handleClose = () => setShowAiMenu(false);
|
||||
const observer = new ResizeObserver(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
|
||||
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
|
||||
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
|
||||
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
|
||||
import { Alert, Stack, Tabs } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
@@ -71,6 +72,7 @@ export default function AiSettings() {
|
||||
<Stack gap="md">
|
||||
{!isCloud() && <EnableAiSearch />}
|
||||
<EnableGenerativeAi />
|
||||
<EnableAiChat />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ export function ApiKeyCreatedModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("API key created")}
|
||||
title={t("{{credential}} created", { credential: t("API key") })}
|
||||
size="lg"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
@@ -41,7 +42,8 @@ export function ApiKeyCreatedModal({
|
||||
color="red"
|
||||
>
|
||||
{t(
|
||||
"Make sure to copy your API key now. You won't be able to see it again!",
|
||||
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
|
||||
{ credential: t("API key") },
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
@@ -64,7 +66,7 @@ export function ApiKeyCreatedModal({
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={onClose} mt="md">
|
||||
{t("I've saved my API key")}
|
||||
{t("I've saved my {{credential}}", { credential: t("API key") })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IApiKey } from "@/ee/api-key";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
|
||||
interface ApiKeyTableProps {
|
||||
apiKeys: IApiKey[];
|
||||
@@ -23,10 +23,11 @@ export function ApiKeyTable({
|
||||
onRevoke,
|
||||
}: ApiKeyTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return t("Never");
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) => {
|
||||
@@ -44,7 +45,7 @@ export function ApiKeyTable({
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
<Table.Th>{t("Expires")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
<Table.Th aria-label={t("Action")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
@@ -106,7 +107,11 @@ export function ApiKeyTable({
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("API key menu")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateApiKeyModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [expirationOption, setExpirationOption] = useState<string>("30");
|
||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
|
||||
const getExpirationLabel = (days: number) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
const formatted = date.toLocaleDateString("en-US", {
|
||||
const formatted = date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
@@ -105,8 +105,9 @@ export function CreateApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create API Key")}
|
||||
title={t("Create {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -30,12 +30,15 @@ export function RevokeApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Revoke API key")}
|
||||
title={t("Revoke {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("Are you sure you want to revoke this API key")}{" "}
|
||||
{t("Are you sure you want to revoke this {{credential}}", {
|
||||
credential: t("API key"),
|
||||
})}{" "}
|
||||
<strong>{apiKey?.name}</strong>?
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -53,8 +53,9 @@ export function UpdateApiKeyModal({
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Update API key")}
|
||||
title={t("Update {{credential}}", { credential: t("API key") })}
|
||||
size="md"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
|
||||
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
|
||||
mutationFn: (data) => createApiKey(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("API key created successfully") });
|
||||
notifications.show({
|
||||
message: t("{{credential}} created successfully", {
|
||||
credential: t("API key"),
|
||||
}),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["api-key-list"].includes(item.queryKey[0] as string),
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
|
||||
{expandable && (
|
||||
<Table.Tr className={classes.detailRow}>
|
||||
<Table.Td colSpan={4} p={0}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Collapse expanded={isExpanded}>
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
|
||||
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"api_key.updated": "Updated API key",
|
||||
"api_key.deleted": "Deleted API key",
|
||||
|
||||
"scim_token.created": "Created SCIM token",
|
||||
"scim_token.updated": "Updated SCIM token",
|
||||
"scim_token.deleted": "Deleted SCIM token",
|
||||
|
||||
"space.created": "Created space",
|
||||
"space.updated": "Updated space",
|
||||
"space.deleted": "Deleted space",
|
||||
@@ -58,6 +62,13 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
"page.verified": "Verified page",
|
||||
"page.approval_requested": "Requested page approval",
|
||||
"page.approval_rejected": "Rejected page approval",
|
||||
"page.marked_obsolete": "Marked page as obsolete",
|
||||
|
||||
"share.created": "Created share link",
|
||||
"share.deleted": "Deleted share link",
|
||||
@@ -136,6 +147,13 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
{ value: "page.verified", label: "Verified page" },
|
||||
{ value: "page.approval_requested", label: "Requested page approval" },
|
||||
{ value: "page.approval_rejected", label: "Rejected page approval" },
|
||||
{ value: "page.marked_obsolete", label: "Marked page as obsolete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -160,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "api_key.deleted", label: "Deleted API key" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SCIM token",
|
||||
items: [
|
||||
{ value: "scim_token.created", label: "Created SCIM token" },
|
||||
{ value: "scim_token.updated", label: "Updated SCIM token" },
|
||||
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "License",
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
|
||||
|
||||
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
|
||||
// the same page don't share UI state.
|
||||
|
||||
export const activeViewIdAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<string | null>(null),
|
||||
);
|
||||
|
||||
export const editingCellAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<EditingCell>(null),
|
||||
);
|
||||
|
||||
export type FormulaEditorTarget = {
|
||||
propertyId: string;
|
||||
rowId: string | null;
|
||||
} | null;
|
||||
|
||||
export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<FormulaEditorTarget>(null),
|
||||
);
|
||||
|
||||
export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<string | null>(null),
|
||||
);
|
||||
|
||||
export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<boolean>(false),
|
||||
);
|
||||
|
||||
export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<number>(0),
|
||||
);
|
||||
|
||||
export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<Set<string>>(new Set<string>()),
|
||||
);
|
||||
|
||||
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<number | null>(null),
|
||||
);
|
||||
|
||||
export const focusedCellAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<FocusedCell>(null),
|
||||
);
|
||||
|
||||
export type PendingTypeInsert = {
|
||||
rowId: string;
|
||||
propertyId: string;
|
||||
char: string;
|
||||
} | null;
|
||||
|
||||
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const formulaRecomputeAtom = atom<Record<string, string[]>>({});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import type { RowReferences } from "@/ee/base/types/base.types";
|
||||
|
||||
// Per-base normalized store of resolved reference entities, hydrated from each
|
||||
// rows-page `references`. Keyed by pageId, matching base-atoms.ts.
|
||||
export const referenceStoreAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<RowReferences>({ users: {}, pages: {} }),
|
||||
);
|
||||
|
||||
export function mergeReferences(
|
||||
prev: RowReferences,
|
||||
next: RowReferences | undefined,
|
||||
): RowReferences {
|
||||
if (!next) return prev;
|
||||
return {
|
||||
users: { ...prev.users, ...next.users },
|
||||
pages: { ...prev.pages, ...next.pages },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { BaseViewDraft } from "@/ee/base/types/base.types";
|
||||
|
||||
export type ViewDraftKey = {
|
||||
userId: string;
|
||||
pageId: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
export const viewDraftStorageKey = (k: ViewDraftKey) =>
|
||||
`docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`;
|
||||
|
||||
// atomWithStorage handles JSON serialization and cross-tab sync. The custom
|
||||
// comparator ensures the same userId/pageId/viewId triple resolves to the
|
||||
// same atom instance, so Jotai's identity-equality cache hits still work.
|
||||
export const viewDraftAtomFamily = atomFamily(
|
||||
(k: ViewDraftKey) =>
|
||||
atomWithStorage<BaseViewDraft | null>(viewDraftStorageKey(k), null),
|
||||
(a, b) =>
|
||||
a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId,
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import {
|
||||
usePageQuery,
|
||||
useUpdateTitlePageMutation,
|
||||
updatePageData,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
// Editable base name for the inline embed. Follows the TitleEditor convention
|
||||
// (updatePageData + localEmitter + websocket emit) so the sidebar and other
|
||||
// clients stay in sync. Standalone pages use the page TitleEditor instead.
|
||||
export function BaseEmbedTitle({ pageId }: { pageId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId });
|
||||
const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const [value, setValue] = useState("");
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Keep in sync with the persisted title but never clobber active user input.
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) setValue(page?.title ?? "");
|
||||
}, [page?.title]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!page || trimmed === (page.title ?? "")) return;
|
||||
updateTitleAsync({ pageId, title: trimmed }).then((updated) => {
|
||||
if (updated.title !== trimmed) return;
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: updated.spaceId,
|
||||
entity: ["pages"],
|
||||
id: updated.id,
|
||||
payload: {
|
||||
title: updated.title,
|
||||
slugId: updated.slugId,
|
||||
parentPageId: updated.parentPageId,
|
||||
icon: updated.icon,
|
||||
},
|
||||
};
|
||||
updatePageData(updated);
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
});
|
||||
}, [value, page, pageId, updateTitleAsync, emit]);
|
||||
|
||||
const debouncedCommit = useDebouncedCallback(commit, 500);
|
||||
|
||||
// Force-save any pending edit on unmount (e.g. navigating away mid-type).
|
||||
const commitRef = useRef(commit);
|
||||
useEffect(() => {
|
||||
commitRef.current = commit;
|
||||
}, [commit]);
|
||||
useEffect(() => () => commitRef.current(), []);
|
||||
|
||||
return (
|
||||
<input
|
||||
className={classes.embedTitleInput}
|
||||
value={value}
|
||||
placeholder={t("Untitled base")}
|
||||
aria-label={t("Base name")}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
debouncedCommit();
|
||||
}}
|
||||
onFocus={() => {
|
||||
focusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
focusedRef.current = false;
|
||||
commit();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setValue(page?.title ?? "");
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import gridClasses from "@/ee/base/styles/grid.module.css";
|
||||
import classes from "@/ee/base/styles/base-table-skeleton.module.css";
|
||||
|
||||
const ROW_NUMBER_WIDTH = 64;
|
||||
const COLUMN_WIDTH = 180;
|
||||
const DEFAULT_COLUMN_COUNT = 6;
|
||||
const DEFAULT_ROW_COUNT = 10;
|
||||
|
||||
// Deterministic widths prevent flicker between renders.
|
||||
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
|
||||
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
|
||||
|
||||
type BaseTableSkeletonProps = {
|
||||
// Match the eventual content shape to avoid a jarring size jump on swap.
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
};
|
||||
|
||||
export function BaseTableSkeleton({
|
||||
rows = DEFAULT_ROW_COUNT,
|
||||
columns = DEFAULT_COLUMN_COUNT,
|
||||
}: BaseTableSkeletonProps = {}) {
|
||||
const gridTemplateColumns = [
|
||||
`${ROW_NUMBER_WIDTH}px`,
|
||||
...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`),
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.toolbar}>
|
||||
<div className={classes.toolbarTabs}>
|
||||
<Skeleton height={22} width={44} radius="sm" />
|
||||
<Skeleton height={22} width={64} radius="sm" />
|
||||
<Skeleton height={22} width={48} radius="sm" />
|
||||
</div>
|
||||
<div className={classes.toolbarActions}>
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.gridWrapper}>
|
||||
<div className={classes.grid} style={{ gridTemplateColumns }}>
|
||||
<div className={gridClasses.headerCell}>
|
||||
<div className={classes.headerCellInner}>
|
||||
<Skeleton height={14} width={14} circle />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
|
||||
<div className={classes.headerCellInner}>
|
||||
<Skeleton height={14} width={14} circle />
|
||||
<Skeleton
|
||||
height={10}
|
||||
width={`${HEADER_WIDTH_RATIOS[colIndex % HEADER_WIDTH_RATIOS.length] * 100}%`}
|
||||
radius="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
|
||||
<div className={gridClasses.cell}>
|
||||
<div className={classes.cellInner}>
|
||||
<Skeleton height={10} width={18} radius="sm" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className={gridClasses.cell}
|
||||
>
|
||||
<div className={classes.cellInner}>
|
||||
<Skeleton
|
||||
height={10}
|
||||
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
|
||||
radius="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { GridContainer } from "@/ee/base/components/grid/grid-container";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
} from "@/ee/base/types/base.types";
|
||||
|
||||
type BaseTableProps = {
|
||||
base: IBase;
|
||||
rows: IBaseRow[];
|
||||
effectiveView: IBaseView | undefined;
|
||||
table: Table<IBaseRow>;
|
||||
pageId: string;
|
||||
embedded?: boolean;
|
||||
isFiltered: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onFetchNextPage: () => void;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
|
||||
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
onRowReorder: (
|
||||
rowId: string,
|
||||
targetRowId: string,
|
||||
dropPosition: "above" | "below",
|
||||
) => void;
|
||||
persistViewConfig: () => void;
|
||||
scrollportRef: React.RefObject<HTMLDivElement>;
|
||||
aboveBand?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BaseTable({
|
||||
base,
|
||||
rows: _rows,
|
||||
table,
|
||||
pageId,
|
||||
embedded,
|
||||
isFiltered,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
scrollportRef,
|
||||
aboveBand,
|
||||
}: BaseTableProps) {
|
||||
return (
|
||||
<GridContainer
|
||||
table={table}
|
||||
properties={base.properties}
|
||||
onCellUpdate={onCellUpdate}
|
||||
onAddRow={onAddRow}
|
||||
pageId={pageId}
|
||||
isFiltered={isFiltered}
|
||||
onColumnReorder={onColumnReorder}
|
||||
onResizeEnd={onResizeEnd}
|
||||
onRowReorder={onRowReorder}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
scrollElement={embedded ? window : scrollportRef.current}
|
||||
aboveBand={aboveBand ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IconSortAscending,
|
||||
IconFilter,
|
||||
IconEye,
|
||||
IconDownload,
|
||||
IconArrowsDiagonal,
|
||||
IconLayoutColumns,
|
||||
IconAdjustments,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewSortConfig,
|
||||
FilterCondition,
|
||||
FilterGroup,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { exportBaseToCsv } from "@/ee/base/services/base-service";
|
||||
import { getApiErrorMessage } from "@/lib/api-error";
|
||||
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
|
||||
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
|
||||
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
||||
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
|
||||
import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker";
|
||||
import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
|
||||
|
||||
type BaseToolbarProps = {
|
||||
base: IBase;
|
||||
activeView: IBaseView | undefined;
|
||||
views: IBaseView[];
|
||||
table?: Table<IBaseRow>;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
canAddView?: boolean;
|
||||
onPersistViewConfig: () => void;
|
||||
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||
onExpand?: () => void;
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
};
|
||||
|
||||
export function BaseToolbar({
|
||||
base,
|
||||
activeView,
|
||||
views,
|
||||
table,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
canAddView,
|
||||
onPersistViewConfig,
|
||||
onDraftSortsChange,
|
||||
onDraftFiltersChange,
|
||||
onExpand,
|
||||
getViewShareUrl,
|
||||
}: BaseToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortOpened, setSortOpened] = useState(false);
|
||||
const [filterOpened, setFilterOpened] = useState(false);
|
||||
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
||||
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const isKanban = activeView?.type === "kanban";
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportBaseToCsv(base.id);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: getApiErrorMessage(err, t("Failed to export CSV")),
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [base.id, exporting, t]);
|
||||
|
||||
const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => {
|
||||
setSortOpened(panel === "sort" ? (v) => !v : false);
|
||||
setFilterOpened(panel === "filter" ? (v) => !v : false);
|
||||
setPropertiesOpened(panel === "properties" ? (v) => !v : false);
|
||||
}, []);
|
||||
|
||||
const sorts = activeView?.config?.sorts ?? [];
|
||||
const conditions = useMemo<FilterCondition[]>(() => {
|
||||
const filter = activeView?.config?.filter;
|
||||
if (!filter || filter.op !== "and") return [];
|
||||
return filter.children.filter(
|
||||
(c): c is FilterCondition => !("children" in c),
|
||||
);
|
||||
}, [activeView?.config?.filter]);
|
||||
|
||||
const hiddenPropertyCount = useMemo(() => {
|
||||
if (!table) return 0;
|
||||
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
||||
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
|
||||
}, [table, table?.getState().columnVisibility]);
|
||||
|
||||
const handleSortsChange = useCallback(
|
||||
(newSorts: ViewSortConfig[]) => {
|
||||
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
|
||||
},
|
||||
[onDraftSortsChange],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newConditions: FilterCondition[]) => {
|
||||
const filter: FilterGroup | undefined =
|
||||
newConditions.length > 0
|
||||
? { op: "and", children: newConditions }
|
||||
: undefined;
|
||||
onDraftFiltersChange(filter);
|
||||
},
|
||||
[onDraftFiltersChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.toolbar}>
|
||||
<ViewTabs
|
||||
views={views}
|
||||
activeViewId={activeView?.id}
|
||||
pageId={base.id}
|
||||
onViewChange={onViewChange}
|
||||
onAddView={onAddView}
|
||||
base={base}
|
||||
canAddView={canAddView}
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
|
||||
<div className={classes.toolbarRight}>
|
||||
<Tooltip label={t("Export CSV")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
loading={exporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<ViewFilterConfigPopover
|
||||
opened={filterOpened}
|
||||
onClose={() => setFilterOpened(false)}
|
||||
conditions={conditions}
|
||||
properties={base.properties}
|
||||
onChange={handleFiltersChange}
|
||||
>
|
||||
<Tooltip label={t("Filter")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={conditions.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
{conditions.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
className={toolbarClasses.badgeDot}
|
||||
>
|
||||
{conditions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewFilterConfigPopover>
|
||||
|
||||
{isKanban && activeView && (
|
||||
<>
|
||||
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
|
||||
<Tooltip label={t("Group by")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
<IconLayoutColumns size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</KanbanGroupByPicker>
|
||||
|
||||
<KanbanCardProperties
|
||||
opened={cardPropertiesOpened}
|
||||
onClose={() => setCardPropertiesOpened(false)}
|
||||
base={base}
|
||||
view={activeView}
|
||||
pageId={base.id}
|
||||
>
|
||||
<Tooltip label={t("Card properties")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={() => setCardPropertiesOpened((v) => !v)}
|
||||
>
|
||||
<IconAdjustments size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</KanbanCardProperties>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isKanban && (
|
||||
<>
|
||||
<ViewSortConfigPopover
|
||||
opened={sortOpened}
|
||||
onClose={() => setSortOpened(false)}
|
||||
sorts={sorts}
|
||||
properties={base.properties}
|
||||
onChange={handleSortsChange}
|
||||
>
|
||||
<Tooltip label={t("Sort")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={sorts.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("sort")}
|
||||
>
|
||||
<IconSortAscending size={16} />
|
||||
{sorts.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
className={toolbarClasses.badgeDot}
|
||||
>
|
||||
{sorts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewSortConfigPopover>
|
||||
|
||||
{table && (
|
||||
<ViewPropertyVisibility
|
||||
opened={propertiesOpened}
|
||||
onClose={() => setPropertiesOpened(false)}
|
||||
table={table}
|
||||
properties={base.properties}
|
||||
onPersist={onPersistViewConfig}
|
||||
>
|
||||
<Tooltip label={t("Hide properties")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("properties")}
|
||||
>
|
||||
<IconEye size={16} />
|
||||
{hiddenPropertyCount > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
className={toolbarClasses.badgeDot}
|
||||
>
|
||||
{hiddenPropertyCount}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewPropertyVisibility>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onExpand && (
|
||||
<Tooltip label={t("Open as page")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<IconArrowsDiagonal size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Group, Button, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type BaseViewDraftBannerProps = {
|
||||
isDirty: boolean;
|
||||
canSave: boolean;
|
||||
onReset: () => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export function BaseViewDraftBanner({
|
||||
isDirty,
|
||||
canSave,
|
||||
onReset,
|
||||
onSave,
|
||||
saving,
|
||||
}: BaseViewDraftBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!isDirty) return null;
|
||||
return (
|
||||
<Group justify="flex-end" gap="xs" px="md" py={6} wrap="nowrap">
|
||||
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>
|
||||
{t("Reset")}
|
||||
</Button>
|
||||
{canSave && (
|
||||
<Tooltip
|
||||
label={t("Filter and sort changes are visible only to you")}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
>
|
||||
{t("Save for everyone")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Text, Stack } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
|
||||
import {
|
||||
FilterGroup,
|
||||
ViewSortConfig,
|
||||
EditingCell,
|
||||
FocusedCell,
|
||||
IBaseProperty,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import {
|
||||
useBaseRowsQuery,
|
||||
flattenRows,
|
||||
useCreateRowMutation,
|
||||
useUpdateRowMutation,
|
||||
useReorderRowMutation,
|
||||
} from "@/ee/base/queries/base-row-query";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import {
|
||||
activeViewIdAtomFamily,
|
||||
editingCellAtomFamily,
|
||||
focusedCellAtomFamily,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
|
||||
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store";
|
||||
import { useViewDraft } from "@/ee/base/hooks/use-view-draft";
|
||||
import { BaseToolbar } from "@/ee/base/components/base-toolbar";
|
||||
import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner";
|
||||
import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title";
|
||||
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
||||
import { ViewRenderer } from "@/ee/base/components/views/view-renderer";
|
||||
import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal";
|
||||
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
|
||||
import { BaseEditableProvider } from "@/ee/base/context/base-editable";
|
||||
import { RowExpandProvider } from "@/ee/base/context/row-expand";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
import viewClasses from "@/ee/base/styles/base-view.module.css";
|
||||
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type BaseViewProps = {
|
||||
pageId: string;
|
||||
embedded?: boolean;
|
||||
/** False makes the view read-only. Standalone passes page.permissions.canEdit;
|
||||
* embedded ANDs that with the host editor's editability. */
|
||||
editable?: boolean;
|
||||
titleSlot?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
// Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache.
|
||||
useBaseSocket(pageId);
|
||||
const { data: base, isLoading: baseLoading, error: baseError } =
|
||||
useBaseQuery(pageId);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data: page } = usePageQuery({ pageId });
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!page) return;
|
||||
navigate(buildPageUrl(page.space?.slug, page.slugId, page.title));
|
||||
}, [navigate, page]);
|
||||
|
||||
// Share URL for a specific view; always points at the standalone page where ?view= is honored.
|
||||
const getViewShareUrl = useCallback(
|
||||
(viewId: string) =>
|
||||
page
|
||||
? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}`
|
||||
: null,
|
||||
[page],
|
||||
);
|
||||
|
||||
const [activeViewId, setActiveViewId] = useAtom(
|
||||
activeViewIdAtomFamily(pageId),
|
||||
) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
const [, setEditingCell] = useAtom(
|
||||
editingCellAtomFamily(pageId),
|
||||
) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const [, setFocusedCell] = useAtom(
|
||||
focusedCellAtomFamily(pageId),
|
||||
) as unknown as [FocusedCell, (val: FocusedCell) => void];
|
||||
|
||||
const views = useMemo(
|
||||
() =>
|
||||
[...(base?.views ?? [])].sort((a, b) =>
|
||||
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||
),
|
||||
[base?.views],
|
||||
);
|
||||
const activeView = useMemo(() => {
|
||||
if (!views.length) return undefined;
|
||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||
}, [views, activeViewId]);
|
||||
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
useHydrateCurrentUser(pageId);
|
||||
const {
|
||||
effectiveFilter,
|
||||
effectiveSorts,
|
||||
isDirty,
|
||||
setFilter: setDraftFilter,
|
||||
setSorts: setDraftSorts,
|
||||
reset: resetDraft,
|
||||
buildPromotedConfig,
|
||||
} = useViewDraft({
|
||||
userId: currentUser?.user.id,
|
||||
pageId,
|
||||
viewId: activeView?.id,
|
||||
baselineFilter: activeView?.config?.filter,
|
||||
baselineSorts: activeView?.config?.sorts,
|
||||
});
|
||||
|
||||
// Baseline merged with local draft. Used for table state and toolbar badge counts.
|
||||
// The real activeView remains the auto-persist baseline so drafts can't leak into layout writes.
|
||||
const effectiveView = useMemo(
|
||||
() =>
|
||||
activeView
|
||||
? {
|
||||
...activeView,
|
||||
config: {
|
||||
...activeView.config,
|
||||
filter: effectiveFilter,
|
||||
sorts: effectiveSorts,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
[activeView, effectiveFilter, effectiveSorts],
|
||||
);
|
||||
|
||||
const activeFilter = effectiveFilter;
|
||||
const activeSorts = effectiveSorts;
|
||||
|
||||
const canSave = editable;
|
||||
|
||||
// Gate on base to avoid a "bland" list request before the active view's
|
||||
// config resolves, which would double network traffic for sorted/filtered views.
|
||||
const isKanban = activeView?.type === "kanban";
|
||||
|
||||
const {
|
||||
data: rowsData,
|
||||
isLoading: rowsLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView && activeViewId !== activeView.id) {
|
||||
setActiveViewId(activeView.id);
|
||||
}
|
||||
}, [activeView, activeViewId, setActiveViewId]);
|
||||
|
||||
// Deep link: apply ?view=<id> once after views load; skip if the id is
|
||||
// unrecognised so we fall back to the default without fighting a later tab switch.
|
||||
const appliedViewParamRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (appliedViewParamRef.current || views.length === 0) return;
|
||||
const viewParam = new URLSearchParams(window.location.search).get("view");
|
||||
if (viewParam && views.some((v) => v.id === viewParam)) {
|
||||
setActiveViewId(viewParam);
|
||||
}
|
||||
appliedViewParamRef.current = true;
|
||||
}, [views, setActiveViewId]);
|
||||
|
||||
const { clear: clearSelection } = useRowSelection(pageId);
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
}, [pageId, activeView?.id, clearSelection]);
|
||||
|
||||
const scrollportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const flat = flattenRows(rowsData);
|
||||
// With an active sort the server returns rows in sort order via keyset
|
||||
// pagination; re-sorting by position on the client would break it as more
|
||||
// pages load. Position sort only applies when no view sort is active.
|
||||
if (activeSorts && activeSorts.length > 0) {
|
||||
return flat;
|
||||
}
|
||||
return flat.sort((a, b) =>
|
||||
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||
);
|
||||
}, [rowsData, activeSorts]);
|
||||
const rowsRef = useRef(rows);
|
||||
rowsRef.current = rows;
|
||||
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView);
|
||||
|
||||
const guardedPersistViewConfig = useCallback(() => {
|
||||
if (!editable) return;
|
||||
persistViewConfig();
|
||||
}, [editable, persistViewConfig]);
|
||||
|
||||
// Mutation result objects change identity every render; only .mutate is
|
||||
// stable. Rows are memoized on these callbacks' identities, so they must
|
||||
// not churn with unrelated re-renders.
|
||||
const updateRow = updateRowMutation.mutate;
|
||||
const handleCellUpdate = useCallback(
|
||||
(rowId: string, propertyId: string, value: unknown) => {
|
||||
if (!editable) return;
|
||||
updateRow({
|
||||
rowId,
|
||||
pageId,
|
||||
cells: { [propertyId]: value },
|
||||
});
|
||||
},
|
||||
[editable, pageId, updateRow],
|
||||
);
|
||||
|
||||
const handleAddRow = useCallback(
|
||||
(afterRowId?: string, focusPropertyId?: string) => {
|
||||
if (!editable) return;
|
||||
createRowMutation.mutate(
|
||||
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
|
||||
{
|
||||
onSuccess: (newRow) => {
|
||||
let propertyId = focusPropertyId;
|
||||
if (!propertyId) {
|
||||
const firstEditable = table.getVisibleLeafColumns().find((col) => {
|
||||
if (col.id === "__row_number") return false;
|
||||
const prop = col.columnDef.meta?.property as
|
||||
| IBaseProperty
|
||||
| undefined;
|
||||
return (
|
||||
!!prop &&
|
||||
prop.type !== "checkbox" &&
|
||||
!isSystemPropertyType(prop.type)
|
||||
);
|
||||
});
|
||||
propertyId = (
|
||||
firstEditable?.columnDef.meta?.property as
|
||||
| IBaseProperty
|
||||
| undefined
|
||||
)?.id;
|
||||
}
|
||||
if (propertyId) {
|
||||
setEditingCell({ rowId: newRow.id, propertyId });
|
||||
setFocusedCell({ rowId: newRow.id, propertyId });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
|
||||
);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(viewId: string) => {
|
||||
setActiveViewId(viewId);
|
||||
},
|
||||
[setActiveViewId],
|
||||
);
|
||||
|
||||
const handleColumnReorder = useCallback(
|
||||
(columnId: string, finishIndex: number) => {
|
||||
const order = table.getState().columnOrder;
|
||||
const startIndex = order.indexOf(columnId);
|
||||
if (startIndex === -1 || startIndex === finishIndex) return;
|
||||
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
|
||||
guardedPersistViewConfig();
|
||||
},
|
||||
[table, guardedPersistViewConfig],
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
guardedPersistViewConfig();
|
||||
}, [guardedPersistViewConfig]);
|
||||
|
||||
const handleDraftSortsChange = useCallback(
|
||||
(sorts: ViewSortConfig[] | undefined) => {
|
||||
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
|
||||
},
|
||||
[setDraftSorts],
|
||||
);
|
||||
|
||||
const handleDraftFiltersChange = useCallback(
|
||||
(filter: FilterGroup | undefined) => {
|
||||
setDraftFilter(filter);
|
||||
},
|
||||
[setDraftFilter],
|
||||
);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!activeView || !base) return;
|
||||
// Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts.
|
||||
const config = buildPromotedConfig(activeView.config);
|
||||
try {
|
||||
await updateViewMutation.mutateAsync({
|
||||
viewId: activeView.id,
|
||||
pageId: base.id,
|
||||
config,
|
||||
});
|
||||
resetDraft();
|
||||
notifications.show({ message: t("View updated for everyone") });
|
||||
} catch {
|
||||
// useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry.
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
base,
|
||||
buildPromotedConfig,
|
||||
resetDraft,
|
||||
t,
|
||||
updateViewMutation,
|
||||
]);
|
||||
|
||||
const { openRowId, openRow, closeRow } = useRowDetailModal(pageId);
|
||||
// openRow's identity tracks searchParams; rows subscribe to the expand
|
||||
// context, so hand them a stable wrapper instead.
|
||||
const openRowRef = useRef(openRow);
|
||||
openRowRef.current = openRow;
|
||||
const handleExpandRow = useCallback((rowId: string) => {
|
||||
openRowRef.current(rowId);
|
||||
}, []);
|
||||
const handleRowNavigate = useCallback((rowId: string) => {
|
||||
openRowRef.current(rowId, { replace: true });
|
||||
}, []);
|
||||
|
||||
const reorderRow = reorderRowMutation.mutate;
|
||||
const handleRowReorder = useCallback(
|
||||
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
|
||||
if (!editable) return;
|
||||
const remainingRows = rowsRef.current.filter((r) => r.id !== rowId);
|
||||
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
let lowerPos: string | null = null;
|
||||
let upperPos: string | null = null;
|
||||
if (dropPosition === "above") {
|
||||
lowerPos =
|
||||
targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
|
||||
upperPos = remainingRows[targetIndex]?.position ?? null;
|
||||
} else {
|
||||
lowerPos = remainingRows[targetIndex]?.position ?? null;
|
||||
upperPos =
|
||||
targetIndex < remainingRows.length - 1
|
||||
? remainingRows[targetIndex + 1]?.position
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
let newPosition: string;
|
||||
if (lowerPos && upperPos && lowerPos === upperPos) {
|
||||
newPosition = generateJitteredKeyBetween(lowerPos, null);
|
||||
} else {
|
||||
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
|
||||
}
|
||||
reorderRow({ rowId, pageId, position: newPosition });
|
||||
} catch {
|
||||
// Position computation failed; skip silently.
|
||||
}
|
||||
},
|
||||
[editable, pageId, reorderRow],
|
||||
);
|
||||
|
||||
if (baseLoading || (!isKanban && rowsLoading)) {
|
||||
return <BaseTableSkeleton />;
|
||||
}
|
||||
if (baseError) {
|
||||
return (
|
||||
<Stack align="center" gap="sm" p="xl">
|
||||
<IconTable size={40} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">{t("Failed to load base")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (!base) return null;
|
||||
|
||||
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
|
||||
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
|
||||
|
||||
const banner = (
|
||||
<BaseViewDraftBanner
|
||||
isDirty={isDirty}
|
||||
canSave={canSave}
|
||||
onReset={resetDraft}
|
||||
onSave={handleSaveDraft}
|
||||
saving={updateViewMutation.isPending}
|
||||
/>
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<BaseToolbar
|
||||
base={base}
|
||||
activeView={effectiveView}
|
||||
views={views}
|
||||
table={table}
|
||||
onViewChange={handleViewChange}
|
||||
canAddView={editable}
|
||||
onPersistViewConfig={guardedPersistViewConfig}
|
||||
onDraftSortsChange={handleDraftSortsChange}
|
||||
onDraftFiltersChange={handleDraftFiltersChange}
|
||||
onExpand={embedded ? handleExpand : undefined}
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
const kanbanBand = (
|
||||
<div className={kanbanClasses.bandWrap}>
|
||||
{embedded ? null : titleSlot}
|
||||
{banner}
|
||||
{toolbar}
|
||||
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewRenderer = (folded: React.ReactNode) => (
|
||||
<ViewRenderer
|
||||
base={base}
|
||||
rows={rows}
|
||||
effectiveView={effectiveView}
|
||||
table={table}
|
||||
pageId={pageId}
|
||||
embedded={embedded}
|
||||
editable={editable}
|
||||
isFiltered={isFiltered}
|
||||
hasNextPage={!!hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onCellUpdate={handleCellUpdate}
|
||||
onAddRow={handleAddRow}
|
||||
onColumnReorder={editable ? handleColumnReorder : undefined}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onRowReorder={editable ? handleRowReorder : undefined}
|
||||
persistViewConfig={guardedPersistViewConfig}
|
||||
scrollportRef={scrollportRef}
|
||||
kanbanFilter={activeFilter}
|
||||
aboveBand={folded}
|
||||
/>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
if (isKanban) {
|
||||
return (
|
||||
<BaseEditableProvider editable={editable}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
{kanbanBand}
|
||||
{viewRenderer(null)}
|
||||
</RowExpandProvider>
|
||||
<RowDetailModal
|
||||
base={base}
|
||||
rows={rows}
|
||||
openRowId={openRowId}
|
||||
onClose={closeRow}
|
||||
onNavigate={handleRowNavigate}
|
||||
/>
|
||||
</BaseEditableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Banner and toolbar go into aboveBand so they scroll with the host document;
|
||||
// only the column-header row stays pinned (via --sticky-band-top).
|
||||
return (
|
||||
<BaseEditableProvider editable={editable}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
{viewRenderer(
|
||||
<>
|
||||
{banner}
|
||||
{toolbar}
|
||||
<BaseEmbedTitle pageId={pageId} />
|
||||
</>,
|
||||
)}
|
||||
</RowExpandProvider>
|
||||
<RowDetailModal
|
||||
base={base}
|
||||
rows={rows}
|
||||
openRowId={openRowId}
|
||||
onClose={closeRow}
|
||||
onNavigate={handleRowNavigate}
|
||||
/>
|
||||
</BaseEditableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (isKanban) {
|
||||
return (
|
||||
<BaseEditableProvider editable={editable}>
|
||||
<div className={kanbanClasses.standalone}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
{kanbanBand}
|
||||
{viewRenderer(null)}
|
||||
</RowExpandProvider>
|
||||
</div>
|
||||
<RowDetailModal
|
||||
base={base}
|
||||
rows={rows}
|
||||
openRowId={openRowId}
|
||||
onClose={closeRow}
|
||||
onNavigate={handleRowNavigate}
|
||||
/>
|
||||
</BaseEditableProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
|
||||
// container so they scroll away; only the column-header row stays pinned.
|
||||
return (
|
||||
<BaseEditableProvider editable={editable}>
|
||||
<div className={viewClasses.fullHeight}>
|
||||
<div className={classes.tableScrollport} ref={scrollportRef}>
|
||||
<RowExpandProvider value={handleExpandRow}>
|
||||
{viewRenderer(
|
||||
<>
|
||||
{titleSlot}
|
||||
{banner}
|
||||
{toolbar}
|
||||
</>,
|
||||
)}
|
||||
</RowExpandProvider>
|
||||
</div>
|
||||
</div>
|
||||
<RowDetailModal
|
||||
base={base}
|
||||
rows={rows}
|
||||
openRowId={openRowId}
|
||||
onClose={closeRow}
|
||||
onNavigate={handleRowNavigate}
|
||||
/>
|
||||
</BaseEditableProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
export function computeVisibleBadgeCount(
|
||||
itemWidths: number[],
|
||||
gap: number,
|
||||
available: number,
|
||||
badgeWidth: number,
|
||||
): number {
|
||||
const count = itemWidths.length;
|
||||
if (count === 0) return 0;
|
||||
if (available <= 0) return count;
|
||||
|
||||
let lineWidth = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
|
||||
}
|
||||
if (lineWidth <= available) return count;
|
||||
|
||||
let used = 0;
|
||||
let fit = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const advance = itemWidths[i] + (i > 0 ? gap : 0);
|
||||
if (used + advance + gap + badgeWidth <= available) {
|
||||
used += advance;
|
||||
fit = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.max(fit, 1);
|
||||
}
|
||||
|
||||
const BADGE_GAP = 4;
|
||||
|
||||
type BadgeOverflowListProps = {
|
||||
chips: ReactElement[];
|
||||
measureKey: string;
|
||||
tooltipLabel?: string;
|
||||
};
|
||||
|
||||
export function BadgeOverflowList({
|
||||
chips,
|
||||
measureKey,
|
||||
tooltipLabel,
|
||||
}: BadgeOverflowListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(chips.length);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const recompute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
|
||||
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
|
||||
setVisibleCount(
|
||||
computeVisibleBadgeCount(
|
||||
chipWidths,
|
||||
BADGE_GAP,
|
||||
container.clientWidth,
|
||||
badgeWidth,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
recompute();
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [measureKey]);
|
||||
|
||||
const visible = chips.slice(0, visibleCount);
|
||||
const overflow = chips.length - visibleCount;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltipLabel}
|
||||
multiline
|
||||
withinPortal
|
||||
openDelay={400}
|
||||
disabled={!tooltipLabel || overflow <= 0}
|
||||
>
|
||||
<div className={cellClasses.badgeGroup} ref={containerRef}>
|
||||
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
|
||||
{chips}
|
||||
<span className={cellClasses.overflowCount}>+{chips.length}</span>
|
||||
</div>
|
||||
{visible}
|
||||
{overflow > 0 && (
|
||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellCheckboxProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
readOnly?: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
|
||||
const checked = value === true;
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
onCommit(!checked);
|
||||
}, [readOnly, checked, onCommit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cellClasses.checkboxCell}
|
||||
onClick={handleChange}
|
||||
style={readOnly ? { cursor: "default" } : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => {}}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: readOnly ? "default" : "pointer",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellCreatedAtProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellCreatedAt({ value }: CellCreatedAtProps) {
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={cellClasses.dateValue}>{formatted}</span>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user