snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)

This commit is contained in:
upstream-mirror
2026-09-15 15:15:34 +05:00
commit 2653349432
1615 changed files with 202632 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
node_modules
.git
.gitignore
dist

27
apps/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
.env
package-lock.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

27
apps/client/README.md Normal file
View File

@@ -0,0 +1,27 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@@ -0,0 +1,36 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import pluginQuery from "@tanstack/eslint-plugin-query";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"@tanstack/query": pluginQuery,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-unused-expressions": "off",
"no-useless-escape": "off",
},
},
);

26
apps/client/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
<title>Docmost</title>
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Docmost" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!--meta-tags-->
</head>
<body>
<div id="root"></div>
<!--window-config-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

100
apps/client/package.json Normal file
View File

@@ -0,0 +1,100 @@
{
"name": "client",
"private": true,
"version": "0.96.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@fontsource-variable/inter": "5.3.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",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.18.1",
"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.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.18",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"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.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@tanstack/eslint-plugin-query": "5.94.4",
"@testing-library/jest-dom": "6.6.0",
"@testing-library/react": "16.1.0",
"@types/blueimp-load-image": "5.16.6",
"@types/file-saver": "2.0.7",
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.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.25",
"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"
}
}

View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

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

View File

@@ -0,0 +1,30 @@
{
"name": "Docmost",
"short_name": "Docmost",
"start_url": "/",
"display": "standalone",
"background_color": "#222",
"theme_color": "#222",
"icons": [
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

202
apps/client/src/App.tsx Normal file
View File

@@ -0,0 +1,202 @@
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "@/components/layouts/global/layout.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
const Home = lazy(() => import("@/pages/dashboard/home"));
const Page = lazy(() => import("@/pages/page/page"));
const AccountSettings = lazy(
() => import("@/pages/settings/account/account-settings"),
);
const WorkspaceMembers = lazy(
() => import("@/pages/settings/workspace/workspace-members"),
);
const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"),
);
const Groups = lazy(() => import("@/pages/settings/group/groups"));
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx"));
const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx"));
const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx"));
const PasswordReset = lazy(() => import("./pages/auth/password-reset"));
const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx"));
const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx"));
const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx"));
const Security = lazy(() => import("@/ee/security/pages/security.tsx"));
const License = lazy(() => import("@/ee/licence/pages/license.tsx"));
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx"));
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
default: m.MfaChallengePage,
})),
);
const MfaSetupRequiredPage = lazy(() =>
import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({
default: m.MfaSetupRequiredPage,
})),
);
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys"));
const WorkspaceApiKeys = lazy(
() => import("@/ee/api-key/pages/workspace-api-keys"),
);
const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx"));
const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx"));
const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx"));
const VerifiedPages = lazy(
() => import("@/ee/page-verification/pages/verified-pages.tsx"),
);
const TemplateList = lazy(() => import("@/ee/template/pages/template-list"));
const TemplateEditor = lazy(
() => import("@/ee/template/pages/template-editor"),
);
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
export default function App() {
const { t } = useTranslation();
useRedirectToCloudSelect();
useTrackOrigin();
useEffect(() => {
// warm the editor chunk so opening a page doesn't wait on the network
const timer = setTimeout(() => import("@/pages/page/page"), 3000);
return () => clearTimeout(timer);
}, []);
return (
<Suspense fallback={null}>
<Routes>
<Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
)}
{isCloud() && (
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</>
)}
<Route element={<ShareLayout />}>
<Route
path={"/share/:shareId/p/:pageSlug"}
element={<SharedPage />}
/>
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/docs"} element={<PublicSpaceDirectoryPage />} />
<Route element={<PublicSpaceLayout />}>
<Route path={"/docs/:spaceSlug"} element={<PublicSpacePage />} />
<Route
path={"/docs/:spaceSlug/:pageSlug"}
element={<PublicSpacePage />}
/>
</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
path={"/s/:spaceSlug/p/:pageSlug"}
element={<Page />}
/>
<Route path={"/base/:pageId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
path={"account/preferences"}
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"audit/siem"} element={<AuditLogs />} />
<Route
path={"siem"}
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
</Route>
<Route path="*" element={<Error404 />} />
</Routes>
</Suspense>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,183 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
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;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
aria-label={ariaLabel}
aria-haspopup="menu"
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 200,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}

View File

@@ -0,0 +1,33 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";

View File

@@ -0,0 +1,40 @@
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";
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, size, label }: CopyProps) {
const { t } = useTranslation();
const copyLabel = label ?? t("Copy");
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : copyLabel}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
size={size}
aria-label={copied ? t("Copied") : copyLabel}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
}

View File

@@ -0,0 +1,232 @@
import {
Modal,
Button,
Group,
Text,
Select,
Switch,
Divider,
Tooltip,
Badge,
} from "@mantine/core";
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;
type: "space" | "page";
open: boolean;
onClose: () => void;
}
export default function ExportModal({
id,
type,
open,
onClose,
}: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
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") {
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 });
}
notifications.show({
message: t("Export successful"),
});
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
} finally {
setIsExporting(false);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection
format={format}
onChange={handleChange}
includeDocx={type === "page"}
docxEntitled={docxEntitled}
/>
</Group>
{type === "page" && !isDocx && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">{t("Include subpages")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeChildren(event.currentTarget.checked)
}
checked={includeChildren}
/>
</Group>
<Group justify="space-between" wrap="nowrap" mt="md">
<div>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
{type === "space" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
{t("Cancel")}
</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>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
includeDocx?: boolean;
docxEntitled?: boolean;
}
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={data}
defaultValue={format}
onChange={onChange}
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>
)
}
/>
);
}

View File

@@ -0,0 +1,20 @@
import { Table, Text } from "@mantine/core";
import React from "react";
import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
text?: string;
}
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation();
return (
<Table.Tr>
<Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center">
{text || t("No results found...")}
</Text>
</Table.Td>
</Table.Tr>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,44 @@
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
export interface PagePaginationProps {
hasPrevPage: boolean;
hasNextPage: boolean;
onPrev: () => void;
onNext: () => void;
}
export default function Paginate({
hasPrevPage,
hasNextPage,
onPrev,
onNext,
}: PagePaginationProps) {
const { t } = useTranslation();
if (!hasPrevPage && !hasNextPage) {
return null;
}
return (
<Group mt="md" justify="flex-end">
<Button
variant="default"
size="compact-sm"
onClick={onPrev}
disabled={!hasPrevPage}
>
{t("Prev")}
</Button>
<Button
variant="default"
size="compact-sm"
onClick={onNext}
disabled={!hasNextPage}
>
{t("Next")}
</Button>
</Group>
);
}

View File

@@ -0,0 +1,109 @@
import {
Text,
Group,
UnstyledButton,
Badge,
Table,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { 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;
}
export default function RecentChanges({ spaceId }: Props) {
const { t } = useTranslation();
const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId);
const pages = data?.pages.flatMap((p) => p.items) ?? [];
if (isLoading) {
return <PageListSkeleton />;
}
if (isError) {
return <Text>{t("Failed to fetch recent pages")}</Text>;
}
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>
<UnstyledButton
className={rowClasses.link}
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
<PageListIcon icon={page.icon} isBase={page.isBase} />
<Text fw={500} size="md" lineClamp={1}>
{getPageTitle(page.title, page.isBase, t)}
</Text>
</Group>
</UnstyledButton>
</Table.Td>
{!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}
title={t("No pages yet")}
description={t("Pages you create will show up here.")}
/>
);
}

View File

@@ -0,0 +1,40 @@
import React, { useState, useEffect } from "react";
import { TextInput, Group } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
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) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
useEffect(() => {
onSearch(debouncedValue);
}, [debouncedValue, onSearch]);
return (
<Group mb="sm">
<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)}
/>
</Group>
);
}

View File

@@ -0,0 +1,24 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import { IUser } from '@/features/user/types/user.types.ts';
interface UserInfoProps {
user: Partial<IUser>;
size?: string;
}
export function UserInfo({ user, size }: UserInfoProps) {
return (
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user?.avatarUrl} name={user?.name} size={size} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user?.name}
</Text>
<Text fz="xs" c="dimmed">
{user?.email}
</Text>
</div>
</Group>
);
}

View File

@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function AirtableIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 215"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#ffbf00"
d="M114.259 2.701 18.86 42.176c-5.305 2.195-5.25 9.73.089 11.847l95.797 37.989a35.544 35.544 0 0 0 26.208 0l95.799-37.99c5.337-2.115 5.393-9.65.086-11.846L141.442 2.7a35.549 35.549 0 0 0-27.183 0"
/>
<path
fill="#26b5f8"
d="M136.35 112.757v94.902c0 4.514 4.55 7.605 8.746 5.942l106.748-41.435a6.39 6.39 0 0 0 4.035-5.941V71.322c0-4.514-4.551-7.604-8.747-5.941l-106.748 41.434a6.392 6.392 0 0 0-4.035 5.942"
/>
<path
fill="#ed3049"
d="m111.423 117.654-31.68 15.296-3.217 1.555L9.65 166.548C5.411 168.593 0 165.504 0 160.795V71.72c0-1.704.874-3.175 2.046-4.283a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
/>
<path
fillOpacity={0.25}
d="m111.423 117.654-31.68 15.296L2.045 67.438a7.266 7.266 0 0 1 1.618-1.213c1.598-.959 3.878-1.215 5.816-.448l101.41 40.18c5.155 2.045 5.56 9.268.533 11.697"
/>
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function ConfluenceIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
</svg>
);
}

View File

@@ -0,0 +1,23 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function FigmaIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<g fill="none" fillRule="evenodd" transform="translate(4)">
<circle cx={12} cy={12} r={4} fill="#19bcfe" />
<path fill="#09cf83" d="M4 24a4 4 0 0 0 4-4v-4H4a4 4 0 1 0 0 8z" />
<path fill="#a259ff" d="M4 16h4V8H4a4 4 0 1 0 0 8z" />
<path fill="#f24e1e" d="M4 8h4V0H4a4 4 0 1 0 0 8z" />
<path fill="#ff7262" d="M12 8H8V0h4a4 4 0 1 1 0 8z" />
</g>
</svg>
);
}

View File

@@ -0,0 +1,17 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function FramerIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M4 0h16v8h-8zm0 8h8l8 8H4zm0 8h8v8z" />
</svg>
);
}

View File

@@ -0,0 +1,24 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDriveIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 87.3 78"
style={{ width: rem(size), height: rem(size) }}
>
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da" />
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47" />
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
fill="#ea4335" />
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d" />
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc" />
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
fill="#ffba00" />
</svg>
);
}

View File

@@ -0,0 +1,33 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function GoogleIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 256 262"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
/>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
);
}

View File

@@ -0,0 +1,23 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleSheetsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
style={{ width: rem(size), height: rem(size) }}
>
<path fill="#43a047" d="M37,45H11c-1.657,0-3-1.343-3-3V6c0-1.657,1.343-3,3-3h19l10,10v29C40,43.657,38.657,45,37,45z"/>
<path fill="#c8e6c9" d="M40 13L30 13 30 3z"/>
<path fill="#2e7d32" d="M30 13L40 23 40 13z"/>
<path
fill="#e8f5e9"
d="M31,23H17h-2v2v2v2v2v2v2v2h18v-2v-2v-2v-2v-2v-2v-2H31z M17,25h4v2h-4V25z M17,29h4v2h-4V29z M17,33h4v2h-4V33z M31,35h-8v-2h8V35z M31,31h-8v-2h8V31z M31,27h-8v-2h8V27z"
/>
</svg>
);
}

View File

@@ -0,0 +1,27 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M7.5 3v18" />
<path d="M12 3v18" />
<path d="M16.5 3v18" />
</svg>
);
}

View File

@@ -0,0 +1,28 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M6.6 3v18" />
<path d="M10.2 3v18" />
<path d="M13.8 3v18" />
<path d="M17.4 3v18" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
function IconDrawio({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#F08705"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
</svg>
);
}
export default IconDrawio;

View File

@@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
function IconExcalidraw({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#6965DB"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
</svg>
);
}
export default IconExcalidraw;

View File

@@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
function IconMermaid({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#FF3670"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
</svg>
);
}
export default IconMermaid;

View File

@@ -0,0 +1,11 @@
import { ThemeIcon } from "@mantine/core";
import React from "react";
import { IconUsersGroup } from "@tabler/icons-react";
export function IconGroupCircle() {
return (
<ThemeIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} />
</ThemeIcon>
);
}

View File

@@ -0,0 +1,11 @@
export { AirtableIcon } from "./airtable-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";

View File

@@ -0,0 +1,19 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LoomIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#625DF5"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M24 10.665h-7.018l6.078-3.509-1.335-2.312-6.078 3.509 3.508-6.077L16.843.94l-3.508 6.077V0h-2.67v7.018L7.156.94 4.844 2.275l3.509 6.077-6.078-3.508L.94 7.156l6.078 3.509H0v2.67h7.017L.94 16.844l1.335 2.313 6.077-3.508-3.509 6.077 2.312 1.335 3.509-6.078V24h2.67v-7.017l3.508 6.077 2.312-1.335-3.509-6.078 6.078 3.509 1.335-2.313-6.077-3.508h7.017v-2.67H24zm-12 4.966a3.645 3.645 0 1 1 0-7.29 3.645 3.645 0 0 1 0 7.29z" />
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function MiroIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function OpenIdIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function TypeformIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M15.502 13.035c-.5 0-.756-.411-.756-.917 0-.505.252-.894.756-.894.513 0 .756.407.756.894-.004.515-.261.917-.756.917Zm-4.888-1.81c.292 0 .414.17.414.317 0 .357-.365.514-1.126.536 0-.442.253-.854.712-.854Zm-3.241 1.81c-.473 0-.67-.384-.67-.917 0-.527.202-.894.67-.894.477 0 .702.38.702.894 0 .537-.234.917-.702.917Zm-3.997-2.334h-.738l1.224 2.808c-.234.519-.36.648-.522.648-.171 0-.333-.138-.45-.259l-.324.43c.22.232.522.366.832.366.387 0 .685-.224.856-.626l1.413-3.371h-.725l-.738 2.012-.828-2.008Zm19.553.523c.36 0 .432.246.432.823v1.516H24v-1.914c0-.689-.473-.988-.91-.988-.386 0-.742.241-.94.688a.901.901 0 0 0-.891-.688c-.365 0-.73.232-.927.666v-.626h-.64v2.857h.64v-1.22c0-.617.324-1.114.765-1.114.36 0 .427.246.427.823v1.516h.64l-.005-1.225c0-.617.329-1.114.77-1.114Zm-5.1-.523h-.324v2.857h.639v-1.095c0-.693.306-1.163.76-1.163.118 0 .217.005.325.05l.099-.676c-.081-.009-.153-.018-.225-.018-.45 0-.774.309-.964.707V10.7h-.31Zm-2.327-.045c-.846 0-1.418.644-1.418 1.458 0 .845.58 1.475 1.418 1.475.85 0 1.431-.648 1.431-1.475-.004-.818-.594-1.458-1.431-1.458Zm-4.852 2.38c-.333 0-.581-.17-.685-.515.847-.036 1.675-.242 1.675-.988 0-.43-.423-.872-1.03-.872-.82 0-1.374.666-1.374 1.457 0 .828.545 1.476 1.36 1.476.567 0 .927-.228 1.21-.559l-.31-.42c-.329.335-.531.42-.846.42Zm-3.151-2.38c-.324 0-.648.188-.774.483v-.438h-.64v3.98h.64v-1.422c.135.205.445.34.72.34.85 0 1.3-.631 1.3-1.48-.004-.841-.445-1.463-1.246-1.463Zm-4.483-1.1H0v.622h1.18v3.38h.67v-3.38h1.166v-.622Zm9.502 1.145h-.383v.572h.383v2.285h.639v-2.285h.621v-.572h-.621v-.447c0-.286.117-.385.382-.385.1 0 .19.027.311.068l.144-.537c-.117-.067-.351-.094-.504-.094-.612 0-.972.367-.972 1.002v.393Z" />
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function VimeoIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#1AB7EA"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M23.9765 6.4168c-.105 2.338-1.739 5.5429-4.894 9.6088-3.2679 4.247-6.0258 6.3699-8.2898 6.3699-1.409 0-2.578-1.294-3.553-3.881l-1.9179-7.1138c-.719-2.584-1.488-3.878-2.312-3.878-.179 0-.806.378-1.8809 1.132l-1.129-1.457a315.06 315.06 0 003.501-3.1279c1.579-1.368 2.765-2.085 3.5539-2.159 1.867-.18 3.016 1.1 3.447 3.838.465 2.953.789 4.789.971 5.5069.5389 2.45 1.1309 3.674 1.7759 3.674.502 0 1.256-.796 2.265-2.385 1.004-1.589 1.54-2.797 1.612-3.628.144-1.371-.395-2.061-1.614-2.061-.574 0-1.167.121-1.777.391 1.186-3.8679 3.434-5.7568 6.7619-5.6368 2.4729.06 3.6279 1.664 3.4929 4.7969z" />
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function YoutubeIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#FF0000"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
}

View File

@@ -0,0 +1,39 @@
.header {
height: 100%;
margin-bottom: rem(120px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: var(--mantine-spacing-md);
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;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
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));
}
}

View File

@@ -0,0 +1,183 @@
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, 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 {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import {
searchSpotlight,
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" },
];
export function AppHeader() {
const { t } = useTranslation();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
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 isPageRoute = location.pathname.includes("/p/");
const items = links.map((link) => (
<Link key={link.label} to={link.link} className={classes.link}>
{t(link.label)}
</Link>
));
return (
<>
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
<Group wrap="nowrap">
<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>
<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}
</Group>
</Group>
<div>
<Group visibleFrom="sm">
<SearchControl onClick={searchSpotlight.open} />
</Group>
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={searchSpotlight.open} />
</Group>
</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
variant="light"
style={{ cursor: "pointer" }}
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
visibleFrom="xs"
>
{trialDaysLeft === 1
? "1 day left"
: `${trialDaysLeft} days left`}
</Badge>
)}
<TopMenu />
</Group>
</Group>
</>
);
}

View File

@@ -0,0 +1,29 @@
.header, .navbar, .aside {
background-color: light-dark(#f6f7f9, var(--mantine-color-dark-8));
}
.navbar, .aside {
@media (max-width: $mantine-breakpoint-sm) {
width: 350px;
}
}
.aside {
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 45px;
}
}
.resizeHandle {
width: 3px;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
bottom: 0;
&:hover, &:active {
width: 5px;
background: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-5))
}
}

View File

@@ -0,0 +1,101 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { lazy, ReactNode, Suspense, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
const CommentListWithTabs = lazy(
() => import("@/features/comment/components/comment-list-with-tabs.tsx"),
);
const TableOfContents = lazy(() =>
import(
"@/features/editor/components/table-of-contents/table-of-contents.tsx"
).then((m) => ({ default: m.TableOfContents })),
);
const AsideChatPanel = lazy(
() => import("@/ee/ai-chat/components/aside-chat-panel"),
);
const PageDetailsAside = lazy(() =>
import("@/features/page-details/components/page-details-aside.tsx").then(
(m) => ({ default: m.PageDetailsAside }),
),
);
export default function Aside() {
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 />;
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;
}
return (
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
{tab !== "chat" && (
<Group justify="space-between" wrap="nowrap" mb="md">
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
<Tooltip label={t("Close")} withArrow>
<ActionIcon
variant="subtle"
color="gray"
onClick={closeAside}
aria-label={t("Close")}
>
<IconX size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
<Suspense fallback={null}>
{tab === "comments" || tab === "chat" ? (
component
) : (
<ScrollArea
style={{ height: "85vh" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea>
)}
</Suspense>
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,174 @@
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 {
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
const AiChatSidebar = React.lazy(
() => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"),
);
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import 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, tab: asideTab }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null);
const startResizing = React.useCallback((mouseDownEvent) => {
mouseDownEvent.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = React.useCallback(() => {
setIsResizing(false);
}, []);
const resize = React.useCallback(
(mouseMoveEvent) => {
if (isResizing) {
const newWidth =
mouseMoveEvent.clientX -
sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
}
if (newWidth > 600) {
setSidebarWidth(600);
return;
}
setSidebarWidth(newWidth);
}
},
[isResizing],
);
useEffect(() => {
//https://codesandbox.io/p/sandbox/kz9de
window.addEventListener("mousemove", resize);
window.addEventListener("mouseup", stopResizing);
return () => {
window.removeEventListener("mousemove", resize);
window.removeEventListener("mouseup", stopResizing);
};
}, [resize, stopResizing]);
const location = useLocation();
const isSettingsRoute = location.pathname.startsWith("/settings");
const isSpaceRoute = location.pathname.startsWith("/s/");
const isAiRoute = location.pathname.startsWith("/ai");
const isPageRoute = location.pathname.includes("/p/");
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return (
<>
<SkipToMain />
<AppShell
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
},
}}
aside={
isPageRoute && {
width: 350,
breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}
}
padding="md"
>
<AppShell.Header px="md" className={classes.header}>
<AppHeader />
</AppShell.Header>
<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 />}
{isAiRoute && (
<React.Suspense fallback={null}>
<AiChatSidebar />
</React.Suspense>
)}
{showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar>
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
{isSettingsRoute ? (
<Container size={900} pb={80}>
{children}
</Container>
) : (
children
)}
</AppShell.Main>
{isPageRoute && (
<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>
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,24 @@
import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
import { atom } from "jotai";
export const mobileSidebarAtom = atom<boolean>(false);
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
"showSidebar",
true,
);
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
};
export const asideStateAtom = atom<AsideStateType>({
tab: "",
isAsideOpen: false,
});
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@@ -0,0 +1,8 @@
import { useAtom } from "jotai";
export function useToggleSidebar(sidebarAtom: any) {
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
return () => {
setSidebarState(!sidebarState);
}
}

View File

@@ -0,0 +1,23 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet, useParams } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import React from "react";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
export default function Layout() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
return (
<UserProvider>
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
<SearchSpotlight spaceId={space?.id} />
</UserProvider>
);
}

View File

@@ -0,0 +1,203 @@
import {
Group,
Menu,
Text,
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrightnessFilled,
IconBrush,
IconCheck,
IconChevronDown,
IconDeviceDesktop,
IconLogout,
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";
import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
const { colorScheme, setColorScheme } = useMantineColorScheme();
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>
<Group gap={7} wrap={"nowrap"}>
<CustomAvatar
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}
</Text>
<IconChevronDown size={16} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Workspace")}</Menu.Label>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
leftSection={<IconSettings size={16} />}
>
{t("Workspace settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
leftSection={<IconUsers size={16} />}
>
{t("Manage members")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("Account")}</Menu.Label>
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
<Group wrap={"nowrap"}>
<CustomAvatar
size={"sm"}
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div style={{ width: 190 }}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text size="xs" c="dimmed" truncate="end">
{user.email}
</Text>
</div>
</Group>
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
leftSection={<IconUserCircle size={16} />}
>
{t("My profile")}
</Menu.Item>
<Menu.Item
component={Link}
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
leftSection={<IconBrush size={16} />}
>
{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} />}>
{t("Theme")}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<Menu.Item
onClick={() => setColorScheme("light")}
leftSection={<IconSun size={16} />}
rightSection={
colorScheme === "light" ? <IconCheck size={16} /> : null
}
>
{t("Light")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("dark")}
leftSection={<IconMoon size={16} />}
rightSection={
colorScheme === "dark" ? <IconCheck size={16} /> : null
}
>
{t("Dark")}
</Menu.Item>
<Menu.Item
onClick={() => setColorScheme("auto")}
leftSection={<IconDeviceDesktop size={16} />}
rightSection={
colorScheme === "auto" ? <IconCheck size={16} /> : null
}
>
{t("System settings")}
</Menu.Item>
</Menu.Sub.Dropdown>
</Menu.Sub>
<Menu.Divider />
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
{t("Logout")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}

View File

@@ -0,0 +1,59 @@
import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts";
import { isCloud } from "@/lib/config.ts";
import classes from "@/components/settings/settings.module.css";
import { Indicator, Text, Tooltip } from "@mantine/core";
import React from "react";
import semverGt from "semver/functions/gt";
import { useTranslation } from "react-i18next";
export default function AppVersion() {
const { t } = useTranslation();
const { data: appVersion } = useAppVersion(!isCloud());
let hasUpdate = false;
try {
hasUpdate =
appVersion &&
parseFloat(appVersion.latestVersion) > 0 &&
semverGt(appVersion.latestVersion, appVersion.currentVersion);
} catch (err) {
console.error(err);
}
return (
<div className={classes.text}>
<Tooltip
label={t("{{latestVersion}} is available", {
latestVersion: `v${appVersion?.latestVersion}`,
})}
disabled={!hasUpdate}
>
<Indicator
label={t("New update")}
color="gray"
inline
size={16}
position="middle-end"
style={{ cursor: "pointer" }}
disabled={!hasUpdate}
onClick={() => {
window.open(
"https://github.com/docmost/docmost/releases",
"_blank",
);
}}
>
<Text
size="sm"
c="dimmed"
component="a"
mr={45}
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
</Text>
</Indicator>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { atom, WritableAtom } from "jotai";
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
null,
(get, set, newValue) => {
if (get(settingsOriginAtom) !== newValue) {
set(settingsOriginAtom, newValue);
}
}
);

View File

@@ -0,0 +1,108 @@
import { queryClient } from "@/main.tsx";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroups } from "@/features/group/services/group-service.ts";
import { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
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: "" };
queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
});
};
export const prefetchSpaces = () => {
queryClient.prefetchQuery({
queryKey: ["spaces", {}],
queryFn: () => getSpaces({}),
});
};
export const prefetchGroups = () => {
queryClient.prefetchQuery({
queryKey: ["groups", {}],
queryFn: () => getGroups({}),
});
};
export const prefetchBilling = () => {
queryClient.prefetchQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
queryClient.prefetchQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
};
export const prefetchLicense = () => {
queryClient.prefetchQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
});
};
export const prefetchSsoProviders = () => {
queryClient.prefetchQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
});
};
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", {}],
queryFn: () => getShares({}),
});
};
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", {}],
queryFn: () => getApiKeys({}),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { adminView: true }],
queryFn: () => getApiKeys({ adminView: true }),
});
};
export const prefetchAuditLogs = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["audit-logs", params],
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({}),
});
};

View File

@@ -0,0 +1,319 @@
import React, { useEffect, useState } from "react";
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
import {
IconUser,
IconSettings,
IconUsers,
IconArrowLeft,
IconUsersGroup,
IconSpaces,
IconBrush,
IconCoin,
IconLock,
IconKey,
IconWorld,
IconSparkles,
IconHistory,
IconShieldCheck,
} 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 useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import {
prefetchApiKeyManagement,
prefetchApiKeys,
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";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
type DataItem = {
label: string;
icon: React.ElementType;
path: string;
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
};
type DataGroup = {
heading: string;
items: DataItem[];
};
const groupedData: DataGroup[] = [
{
heading: "Account",
items: [
{ label: "Profile", icon: IconUser, path: "/settings/account/profile" },
{
label: "Preferences",
icon: IconBrush,
path: "/settings/account/preferences",
},
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
},
],
},
{
heading: "Workspace",
items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
{ label: "Members", icon: IconUsers, path: "/settings/members" },
{
label: "Billing",
icon: IconCoin,
path: "/settings/billing",
role: "admin",
env: "cloud",
},
{
label: "Security & SSO",
icon: IconLock,
path: "/settings/security",
feature: Feature.SECURITY_SETTINGS,
role: "admin",
},
{ 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,
path: "/settings/api-keys",
feature: Feature.API_KEYS,
role: "admin",
},
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
role: "admin",
},
{
label: "Audit logs & SIEM",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
role: "owner",
env: "selfhosted",
},
],
},
{
heading: "System",
items: [
{
label: "License & Edition",
icon: IconKey,
path: "/settings/license",
},
],
},
];
export default function SettingsSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole();
const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const hasFeature = (f: string) =>
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
return true;
};
const isItemDisabled = (item: DataItem) => {
if (!item.feature) return false;
return !hasFeature(item.feature);
};
const menuItems = groupedData.map((group) => {
if (group.heading === "System" && (!isAdmin || isCloud())) {
return null;
}
return (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{t(group.heading)}
</Text>
{group.items.map((item) => {
if (!canShowItem(item)) {
return null;
}
let prefetchHandler: any;
switch (item.label) {
case "Members":
prefetchHandler = prefetchWorkspaceMembers;
break;
case "Spaces":
prefetchHandler = prefetchSpaces;
break;
case "Groups":
prefetchHandler = prefetchGroups;
break;
case "Billing":
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (entitlements?.tier !== "free") {
prefetchHandler = prefetchLicense;
}
break;
case "Security & SSO":
prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
break;
case "Public sharing":
prefetchHandler = prefetchShares;
break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit logs & SIEM":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
prefetchHandler = prefetchVerifiedPages;
break;
default:
break;
}
const isDisabled = isItemDisabled(item);
if (isDisabled) {
return (
<Tooltip
key={item.label}
label={upgradeLabel}
position="right"
withArrow
>
<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 (
<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>
);
});
return (
<div className={classes.navbar}>
<Group className={classes.title} justify="flex-start">
<ActionIcon
onClick={() => {
goBack();
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
variant="transparent"
c="gray"
aria-label={t("Back")}
>
<IconArrowLeft stroke={2} />
</ActionIcon>
<Text fw={500}>{t("Settings")}</Text>
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>
{!isCloud() && <AppVersion />}
{isCloud() && (
<div className={classes.text}>
<Text
size="sm"
c="dimmed"
component="a"
href="mailto:help@docmost.com"
>
help@docmost.com
</Text>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Divider, Title } from '@mantine/core';
export default function SettingsTitle({ title }: { title: string }) {
return (
<>
<Title order={1} size="h3">
{title}
</Title>
<Divider my="md" />
</>
);
}

View File

@@ -0,0 +1,64 @@
.navbar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
}
.title {
padding-bottom: var(--mantine-spacing-sm);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.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));
}
&[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));
}
}
}
.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);
}
.linkHeader {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
}
.text {
padding-left: var(--mantine-spacing-xs) ;
padding-top: 10px;
}

View File

@@ -0,0 +1,19 @@
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,28 @@
import {
ActionIcon,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import classes from "./theme-toggle.module.css";
export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme();
return (
<Tooltip label="Toggle Color Scheme">
<ActionIcon
variant="default"
onClick={() => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
}}
aria-label="Toggle color scheme"
>
<IconSun className={classes.light} size={18} stroke={1.5} />
<IconMoon className={classes.dark} size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,50 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
withinPortal={false}
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
export const CheckboxMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";

View File

@@ -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);
}

View File

@@ -0,0 +1,84 @@
import React from "react";
import { Avatar, MantineColor } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl?: string;
name: string;
color?: string;
size?: string | number;
radius?: string | number;
variant?: string;
style?: any;
component?: any;
type?: AvatarIconType;
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, 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={initialsSource}
alt={name}
color={resolvedColor}
variant={variant}
styles={placeholderStyles}
{...props}
/>
);
});

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View File

@@ -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;
};

View File

@@ -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>
)}
</>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}

View File

@@ -0,0 +1,162 @@
import React, { ReactNode, useEffect, useState } from "react";
import {
ActionIcon,
Popover,
Button,
useMantineColorScheme,
} from "@mantine/core";
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
import { Suspense } from "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;
removeEmojiAction: () => void;
readOnly: boolean;
actionIconProps?: {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
function EmojiPicker({
onEmojiSelect,
icon,
removeEmojiAction,
readOnly,
actionIconProps,
}: EmojiPickerInterface) {
const { t } = useTranslation();
const [opened, handlers] = useDisclosure(false);
const { colorScheme } = useMantineColorScheme();
const [target, setTarget] = useState<HTMLElement | null>(null);
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null);
useClickOutside(
() => handlers.close(),
["mousedown", "touchstart"],
[dropdown, target],
);
// We need this because the default Mantine popover closeOnEscape does not work
useWindowEvent("keydown", (event) => {
if (opened && event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
handlers.close();
}
});
// 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();
};
const handleRemoveEmoji = () => {
removeEmojiAction();
handlers.close();
};
return (
<Popover
opened={opened}
onClose={handlers.close}
width={332}
position="bottom"
disabled={readOnly}
closeOnEscape={true}
>
<Popover.Target ref={setTarget}>
<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>
</Popover.Target>
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
theme={colorScheme}
/>
<Button
variant="default"
c="gray"
size="xs"
style={{
position: "absolute",
zIndex: 2,
bottom: "1rem",
right: "1rem",
}}
onClick={handleRemoveEmoji}
>
{t("Remove")}
</Button>
</Popover.Dropdown>
</Suspense>
</Popover>
);
}
export default EmojiPicker;

View File

@@ -0,0 +1,8 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}

View File

@@ -0,0 +1,30 @@
import { Stack, Text } from "@mantine/core";
import { type TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import classes from "./empty-state.module.css";
type EmptyStateProps = {
icon: TablerIcon;
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className={classes.root}>
<Stack align="center" gap="xs">
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500}>
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={350}>
{description}
</Text>
)}
{action}
</Stack>
</div>
);
}

View File

@@ -0,0 +1,24 @@
.root {
padding-top: 80px;
padding-bottom: 80px;
}
.description {
max-width: 500px;
margin: auto;
margin-top: var(--mantine-spacing-xl);
margin-bottom: calc(1.5 * var(--mantine-spacing-xl));
}
.title {
font-family:
Greycliff CF,
var(--mantine-font-family);
text-align: center;
font-weight: 600;
font-size: 30px;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 25px;
}
}

View File

@@ -0,0 +1,26 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<DocumentTitle title={t("404 page not found")} />
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
{t("Sorry, we can't find the page you are looking for.")}
</Text>
<Group justify="center">
<Button component={Link} to={"/home"} variant="subtle" size="md">
{t("Take me back to homepage")}
</Button>
</Group>
</Container>
</>
);
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from '@mantine/core';
export default function PageListSkeleton() {
return (
<>
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
<Skeleton height={25} my="xs" radius="xs" />
</>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,47 @@
import { Box } from "@mantine/core";
import React from "react";
interface ResponsiveSettingsRowProps {
children: React.ReactNode;
}
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
return (
<Box
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
{children}
</Box>
);
}
interface ResponsiveSettingsContentProps {
children: React.ReactNode;
}
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
return (
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
{children}
</Box>
);
}
interface ResponsiveSettingsControlProps {
children: React.ReactNode;
}
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
return (
<Box style={{ flex: "0 0 auto" }}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,68 @@
import React, { forwardRef } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Group, Text, Menu, Button } from "@mantine/core";
import { IRoleData } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
name: string;
}
const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
({ name, ...others }: RoleButtonProps, ref) => (
<Button
variant="default"
ref={ref}
style={{
border: "none",
}}
rightSection={<IconChevronDown size="1rem" />}
{...others}
>
{name}
</Button>
),
);
interface RoleMenuProps {
roles: IRoleData[];
roleName: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
export default function RoleSelectMenu({
roles,
roleName,
onChange,
disabled,
}: RoleMenuProps) {
const { t } = useTranslation();
return (
<Menu withArrow>
<Menu.Target>
<RoleButton name={t(roleName)} disabled={disabled} />
</Menu.Target>
<Menu.Dropdown>
{roles?.map((item) => (
<Menu.Item
onClick={() => onChange && onChange(item.value)}
key={item.value}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{t(item.label)}</Text>
<Text size="xs" opacity={0.65}>
{t(item.description)}
</Text>
</div>
{item.label === roleName && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

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