Compare commits

...

47 Commits

Author SHA1 Message Date
5506fe5e73 POC 2024-12-11 22:13:42 +00:00
1302b1b602 v0.6.1 2024-12-11 14:55:06 +00:00
89a3f4cfc2 v0.6.1 2024-12-11 14:54:19 +00:00
e48b1c0dae fix: markdown math import (#529)
* fix: markdown math block import

* fix: block and inline math import

* cleanup
2024-12-09 15:08:25 +00:00
4a2a5a7a4d fix: postgres and redis url validation (#548) 2024-12-09 14:56:15 +00:00
532001fd82 chore: fix linting (#544)
* fix: eslint (server)

* fix: eslint (client)

* commit package lock file

* fix linting
2024-12-09 14:51:31 +00:00
e6bf4cdd6c fix: fix markdown import file button (#542) 2024-12-06 12:26:55 +00:00
a9a4a26db5 fix export controller reference in module 2024-11-30 20:42:31 +00:00
ede5633415 fix export fileName 2024-11-30 20:40:53 +00:00
a25cf84671 fix: add spaceId 2024-11-30 20:29:13 +00:00
a37d558bac v0.6.0 2024-11-30 20:06:44 +00:00
ddb0f9225f fix: uuid7 for commentId (#524) 2024-11-30 20:04:50 +00:00
c717847ca8 chore: update packages (#507) 2024-11-30 19:54:04 +00:00
fe83557767 feat: space export (#506)
* wip

* Space export
* option to export pages with children
* include attachments in exports
* unified export UI

* cleanup

* fix: change export icon

* add export button to space settings

* cleanups

* export name
2024-11-30 19:47:22 +00:00
9fa432dba9 feat: support tab key in code block (#523) 2024-11-30 14:40:05 +00:00
c6aaefecbd fix: clear local cache on logout (#519) 2024-11-28 20:35:53 +00:00
311d81bc71 fix wrong tree sync bug (#514) 2024-11-28 19:39:38 +00:00
f178e6654f fix: properly support redis db (#517) 2024-11-28 18:54:28 +00:00
ca186f3c0e fix: return direct embed urls if present (#516) 2024-11-28 18:53:49 +00:00
a16d5d1bf4 feat: websocket rooms (#515) 2024-11-28 18:53:29 +00:00
d97baf5824 add env variable (#513) 2024-11-28 18:48:25 +00:00
8349d8271c fix: allow space in inline math (#508) 2024-11-28 18:48:08 +00:00
2e6d16dbc3 fix: full width bug on smaller screens (#518) 2024-11-28 18:44:42 +00:00
4107793e73 fix: disable user-select 2024-11-28 15:55:10 +00:00
a1b6ac7f3e fix: close space selection popover onClickOutside (#485) 2024-11-27 02:32:12 +00:00
dd0319a14d fix: index imported content (#495) 2024-11-20 13:36:36 +00:00
8194c7d42d fix: focus editor on bottom click (#484) 2024-11-13 20:00:25 +00:00
d01ced078b * Reduce code block font-size
* Make inline code more distinctive
2024-11-13 11:36:55 -08:00
da9c971050 fix breadcrumb clipping (#457) 2024-11-13 19:15:37 +00:00
4e7af507c6 fix tree dnd 2024-11-06 19:29:12 -08:00
f7426a0b45 fix: use clsx 2024-11-01 10:09:52 +00:00
b85b34d6b1 feat: resizable sidebar (#452)
* feat: resizable sidebar

* only expand space sidebar
2024-11-01 10:05:03 +00:00
e064e58f79 Fix sidebar responsivity (#453)
* navbar height fix. has to be cleaned up
* use parent height for tree
* cleanups
2024-11-01 09:41:23 +00:00
4f1a97ceb9 Revert "fix: prevent default browser save behavior (#450)" (#451)
This reverts commit d07338861b.
2024-10-30 12:23:31 +00:00
d07338861b fix: prevent default browser save behavior (#450) 2024-10-30 11:41:23 +00:00
95159625aa v0.5.0 2024-10-29 19:50:44 +00:00
9e0fbae1de fix: save excalidraw diagram in light mode only 2024-10-29 19:39:08 +00:00
a52c86a180 fix: add drawio dark mode support 2024-10-29 19:37:49 +00:00
31feb38def fix: sync color scheme with excalidraw 2024-10-29 19:33:08 +00:00
ba32e42ece fix: filter out redundant group 2024-10-29 19:15:26 +00:00
a574d13f43 fix: email overflow 2024-10-29 18:44:59 +00:00
ab70cee278 feat: third-party embeds (#423)
* wip

* Add more providers

* icons

* unify embed providers (Youtube)

* fix case

* YT music

* remove redundant code
2024-10-29 18:13:20 +00:00
978fadd6b9 fix: improve sidebar page tree syncing (#407)
* sync node deletion

* tree sync improvements

* fix cache bug

* fix debounced page title

* fix
2024-10-26 15:48:40 +01:00
b57be9c736 fix: rename edit -> save 2024-10-14 12:29:11 +01:00
d4b219d608 add COPY patches to Dockerfile (#400) 2024-10-14 09:13:36 +01:00
36e720920b fix: bug fixes (#397)
* Add more html page titles

* Make tables responsive

* fix react query keys

* Add tooltip to sidebar toggle

* fix: trim inputs

* fix inputs
2024-10-13 17:09:45 +01:00
fa3c8a03e1 fix: remove space tree delete shortcut key (#394) 2024-10-12 13:14:29 +01:00
138 changed files with 7335 additions and 3807 deletions

View File

@ -40,3 +40,5 @@ SMTP_IGNORETLS=false
# Postmark driver config
POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=

View File

@ -30,6 +30,9 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm
RUN chown -R node:node /app

View File

@ -1,22 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
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',
},
}

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",
},
},
);

View File

@ -6,6 +6,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docmost</title>
<!--meta-tags-->
</head>
<body>
<div id="root"></div>

View File

@ -1,73 +1,76 @@
{
"name": "client",
"private": true,
"version": "0.4.1",
"version": "0.6.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@docmost/editor-ext": "workspace:*",
"@casl/ability": "^6.7.1",
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6",
"@mantine/core": "^7.12.2",
"@mantine/form": "^7.12.2",
"@mantine/hooks": "^7.12.2",
"@mantine/modals": "^7.12.2",
"@mantine/notifications": "^7.12.2",
"@mantine/spotlight": "^7.12.2",
"@tabler/icons-react": "^3.14.0",
"@tanstack/react-query": "^5.53.2",
"axios": "^1.7.7",
"@mantine/core": "^7.14.2",
"@mantine/form": "^7.14.2",
"@mantine/hooks": "^7.14.2",
"@mantine/modals": "^7.14.2",
"@mantine/notifications": "^7.14.2",
"@mantine/spotlight": "^7.14.2",
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"axios": "^1.7.8",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"date-fns": "^4.1.0",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"jotai": "^2.9.3",
"jotai": "^2.10.3",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "^0.16.11",
"lowlight": "^3.1.0",
"mermaid": "^11.0.2",
"lowlight": "^3.2.0",
"mermaid": "^11.4.0",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-clear-modal": "^2.0.9",
"react-clear-modal": "^2.0.11",
"react-dom": "^18.3.1",
"react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13",
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5",
"react-router-dom": "^7.0.1",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.12",
"tiptap-extension-global-drag-handle": "^0.1.16",
"zod": "^3.23.8"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.53.0",
"@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "22.5.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.11",
"@types/node": "22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.4.43",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.3",
"typescript": "^5.5.4",
"vite": "^5.4.8"
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.0.0"
}
}

View File

@ -62,6 +62,13 @@ export default function App() {
<>
<Routes>
<Route index element={<Navigate to="/home" />} />
<Route path={"/share/:id"} element={
<ErrorBoundary
fallback={<>Failed to load home. An error occurred.</>}
>
<Home />
</ErrorBoundary>
}/>
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/setup/register"} element={<SetupWorkspace />} />

View File

@ -0,0 +1,149 @@
import {
Modal,
Button,
Group,
Text,
Select,
Switch,
Divider,
} from "@mantine/core";
import { exportPage } 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";
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>(true);
const handleExport = async () => {
try {
if (type === "page") {
await exportPage({ pageId: id, format, includeChildren });
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
setIncludeChildren(false);
setIncludeAttachments(true);
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export {type}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
{type === "page" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch
onChange={(event) =>
setIncludeChildren(event.currentTarget.checked)
}
checked={includeChildren}
/>
</Group>
</>
)}
{type === "space" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include attachments</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
/>
);
}

View File

@ -4,25 +4,25 @@ import {
UnstyledButton,
Badge,
Table,
ScrollArea,
ActionIcon,
} from '@mantine/core';
import { Link } from 'react-router-dom';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import {buildPageUrl} from '@/features/page/page.utils.ts';
import {formattedDate} from '@/lib/time.ts';
import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
import {IconFileDescription} from '@tabler/icons-react';
import {getSpaceUrl} from '@/lib/config.ts';
interface Props {
spaceId?: string;
}
export default function RecentChanges({ spaceId }: Props) {
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
export default function RecentChanges({spaceId}: Props) {
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
return <PageListSkeleton />;
return <PageListSkeleton/>;
}
if (isError) {
@ -30,7 +30,7 @@ export default function RecentChanges({ spaceId }: Props) {
}
return pages && pages.items.length > 0 ? (
<ScrollArea>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.items.map((page) => (
@ -43,7 +43,7 @@ export default function RecentChanges({ spaceId }: Props) {
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18} />
<IconFileDescription size={18}/>
</ActionIcon>
)}
@ -60,14 +60,14 @@ export default function RecentChanges({ spaceId }: Props) {
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: 'pointer' }}
style={{cursor: 'pointer'}}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text c="dimmed" size="xs" fw={500}>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
@ -75,7 +75,7 @@ export default function RecentChanges({ spaceId }: Props) {
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
No pages yet

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,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,10 @@
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 { 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,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

@ -1,18 +1,18 @@
import { Group, Text } from "@mantine/core";
import {Group, Text, Tooltip} from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import {Link} from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai/index";
import {useAtom} from "jotai/index";
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 {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
export function AppHeader() {
const [mobileOpened] = useAtom(mobileSidebarAtom);
@ -35,28 +35,33 @@ export function AppHeader() {
<Group wrap="nowrap">
{!isHomeRoute && (
<>
<SidebarToggle
aria-label="sidebar toggle"
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Tooltip label="Sidebar toggle">
<SidebarToggle
aria-label="sidebar toggle"
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
<SidebarToggle
aria-label="Sidebar toggle"
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
<Tooltip label="Sidebar toggle">
<SidebarToggle
aria-label="Sidebar toggle"
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</>
)}
<Text
size="lg"
fw={600}
style={{ cursor: "pointer", userSelect: "none" }}
style={{cursor: "pointer", userSelect: "none"}}
component={Link}
to="/home"
>
@ -69,7 +74,7 @@ export function AppHeader() {
</Group>
<Group px={"xl"}>
<TopMenu />
<TopMenu/>
</Group>
</Group>
</>

View File

@ -14,3 +14,18 @@
}
}
.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

@ -1,12 +1,12 @@
import { AppShell, Container } from "@mantine/core";
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
mobileSidebarAtom, sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
@ -21,6 +21,46 @@ export default function GlobalAppShell({
const [mobileOpened] = useAtom(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = 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");
@ -33,7 +73,7 @@ export default function GlobalAppShell({
header={{ height: 45 }}
navbar={
!isHomeRoute && {
width: 300,
width: isSpaceRoute ? sidebarWidth : 300,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
@ -54,7 +94,8 @@ export default function GlobalAppShell({
<AppHeader />
</AppShell.Header>
{!isHomeRoute && (
<AppShell.Navbar className={classes.navbar} withBorder={false}>
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
</AppShell.Navbar>

View File

@ -19,3 +19,5 @@ export const asideStateAtom = atom<AsideStateType>({
tab: "",
isAsideOpen: false,
});
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@ -73,11 +73,11 @@ export default function TopMenu() {
name={user.name}
/>
<div>
<div style={{width: 190}}>
<Text size="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" truncate="end">
{user.email}
</Text>
</div>

View File

@ -1,15 +1,9 @@
import React from "react";
import {
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconLayoutSidebarRightExpand
} from "@tabler/icons-react";
import {
ActionIcon,
BoxProps,
ElementProps,
MantineColor,
MantineSize,
} from "@mantine/core";
import React from "react";
import { ActionIcon, BoxProps, ElementProps, MantineColor, MantineSize } from "@mantine/core";
export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
size?: MantineSize | `compact-${MantineSize}` | (string & {});
@ -17,18 +11,18 @@ export interface SidebarToggleProps extends BoxProps, ElementProps<"button"> {
opened?: boolean;
}
export default function SidebarToggle({
opened,
size = "sm",
...others
}: SidebarToggleProps) {
return (
<ActionIcon size={size} {...others} variant="subtle" color="gray">
{opened ? (
<IconLayoutSidebarRightExpand />
) : (
<IconLayoutSidebarRightCollapse />
)}
</ActionIcon>
);
}
const SidebarToggle = React.forwardRef<HTMLButtonElement, SidebarToggleProps>(
({ opened, size = "sm", ...others }, ref) => {
return (
<ActionIcon size={size} {...others} variant="subtle" color="gray" ref={ref}>
{opened ? (
<IconLayoutSidebarRightExpand />
) : (
<IconLayoutSidebarRightCollapse />
)}
</ActionIcon>
);
}
);
export default SidebarToggle;

View File

@ -19,7 +19,7 @@ import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-qu
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
name: z.string().min(2),
name: z.string().trim().min(1),
password: z.string().min(8),
});

View File

@ -15,8 +15,8 @@ import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
const formSchema = z.object({
workspaceName: z.string().min(2).max(60),
name: z.string().min(2).max(60),
workspaceName: z.string().trim().min(3).max(50),
name: z.string().min(1).max(50),
email: z
.string()
.min(1, { message: "email is required" })

View File

@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import APP_ROUTE from "@/lib/app-route.ts";
import { useQueryClient } from "@tanstack/react-query";
export default function useAuth() {
const [isLoading, setIsLoading] = useState(false);
@ -30,6 +31,7 @@ export default function useAuth() {
const [, setCurrentUser] = useAtom(currentUserAtom);
const [authToken, setAuthToken] = useAtom(authTokensAtom);
const queryClient = useQueryClient();
const handleSignIn = async (data: ILogin) => {
setIsLoading(true);
@ -136,7 +138,8 @@ export default function useAuth() {
setAuthToken(null);
setCurrentUser(null);
Cookies.remove("authTokens");
navigate(APP_ROUTE.AUTH.LOGIN);
queryClient.clear();
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
};
const handleForgotPassword = async (data: IForgotPassword) => {

View File

@ -25,7 +25,6 @@ export function useCommentsQuery(
params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: RQ_KEY(params.pageId),
queryFn: () => getPageComments(params),
enabled: !!params.pageId,

View File

@ -23,7 +23,7 @@ import {
showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { v4 as uuidv4 } from "uuid";
import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
@ -84,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
name: "comment",
isActive: () => props.editor.isActive("comment"),
command: () => {
const commentId = uuidv4();
const commentId = uuid7();
props.editor.chain().focus().setCommentDecoration().run();
setDraftCommentId(commentId);

View File

@ -1,9 +1,9 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
import { ActionIcon, Card, Image, Modal, Text, useComputedColorScheme } from '@mantine/core';
import { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import { getDrawioUrl, getFileUrl } from '@/lib/config.ts';
import {
DrawIoEmbed,
DrawIoEmbedRef,
@ -21,6 +21,7 @@ export default function DrawioView(props: NodeViewProps) {
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const handleOpen = async () => {
if (!editor.isEditable) {
@ -39,7 +40,7 @@ export default function DrawioView(props: NodeViewProps) {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
let base64data = (reader.result || '') as string;
const base64data = (reader.result || '') as string;
setInitialXML(base64data);
};
}
@ -86,8 +87,9 @@ export default function DrawioView(props: NodeViewProps) {
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{
ui: 'kennedy',
ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
spin: true,
libraries: true,
saveAndExit: true,

View File

@ -0,0 +1,111 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import clsx from "clsx";
import { ActionIcon, AspectRatio, Button, Card, FocusTrap, Group, Popover, Text, TextInput } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
getEmbedProviderById,
getEmbedUrlAndProvider
} from "@/features/editor/components/embed/providers.ts";
import { notifications } from '@mantine/notifications';
const schema = z.object({
url: z
.string().trim().url({ message: 'please enter a valid url' }),
});
export default function EmbedView(props: NodeViewProps) {
const { node, selected, updateAttributes } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
if (src) {
return getEmbedUrlAndProvider(src).embedUrl;
}
return null;
}, [src]);
const embedForm = useForm<{ url: string }>({
initialValues: {
url: "",
},
validate: zodResolver(schema),
});
async function onSubmit(data: { url: string }) {
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.regex.test(data.url)) {
updateAttributes({ src: data.url });
} else {
notifications.show({
message: `Invalid ${provider} embed link`,
position: 'top-right',
color: 'red'
});
}
}
}
return (
<NodeViewWrapper>
{embedUrl ? (
<>
<AspectRatio ratio={16 / 9}>
<iframe
src={embedUrl}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
frameBorder="0"
></iframe>
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover.Target>
<Card
radius="md"
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Embed {getEmbedProviderById(provider).name}
</Text>
</div>
</Card>
</Popover.Target>
<Popover.Dropdown bg="var(--mantine-color-body)">
<form onSubmit={embedForm.onSubmit(onSubmit)}>
<FocusTrap active={true}>
<TextInput placeholder={`Enter ${getEmbedProviderById(provider).name} link to embed`}
key={embedForm.key('url')}
{... embedForm.getInputProps('url')}
data-autofocus
/>
</FocusTrap>
<Group justify="center" mt="xs">
<Button type="submit">Embed link</Button>
</Group>
</form>
</Popover.Dropdown>
</Popover>
)}
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,121 @@
export interface IEmbedProvider {
id: string;
name: string;
regex: RegExp;
getEmbedUrl: (match: RegExpMatchArray, url?: string) => string;
}
export const embedProviders: IEmbedProvider[] = [
{
id: 'loom',
name: 'Loom',
regex: /^https?:\/\/(?:www\.)?loom\.com\/(?:share|embed)\/([\da-zA-Z]+)\/?/,
getEmbedUrl: (match, url) => {
if(url.includes("/embed/")){
return url;
}
return `https://loom.com/embed/${match[1]}`;
}
},
{
id: 'airtable',
name: 'Airtable',
regex: /^https:\/\/(www.)?airtable.com\/([a-zA-Z0-9]{2,})\/.*/,
getEmbedUrl: (match, url: string) => {
const path = url.split('airtable.com/');
if(url.includes("/embed/")){
return url;
}
return `https://airtable.com/embed/${path[1]}`;
}
},
{
id: 'figma',
name: 'Figma',
regex: /^https:\/\/[\w\.-]+\.?figma.com\/(file|proto|board|design|slides|deck)\/([0-9a-zA-Z]{22,128})/,
getEmbedUrl: (match, url: string) => {
return `https://www.figma.com/embed?url=${url}&embed_host=docmost`;
}
},
{
'id': 'typeform',
name: 'Typeform',
regex: /^(https?:)?(\/\/)?[\w\.]+\.typeform\.com\/to\/.+/,
getEmbedUrl: (match, url: string) => {
return url;
}
},
{
id: 'miro',
name: 'Miro',
regex: /^https:\/\/(www\.)?miro\.com\/app\/board\/([\w-]+=)/,
getEmbedUrl: (match, url) => {
if(url.includes("/live-embed/")){
return url;
}
return `https://miro.com/app/live-embed/${match[2]}?embedMode=view_only_without_ui&autoplay=true&embedSource=docmost`;
}
},
{
id: 'youtube',
name: 'YouTube',
regex: /^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
getEmbedUrl: (match, url) => {
if (url.includes("/embed/")){
return url;
}
return `https://www.youtube-nocookie.com/embed/${match[5]}`;
}
},
{
id: 'vimeo',
name: 'Vimeo',
regex: /^(https:)?\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)/,
getEmbedUrl: (match) => {
return `https://player.vimeo.com/video/${match[4]}`;
}
},
{
id: 'framer',
name: 'Framer',
regex: /^https:\/\/(www\.)?framer\.com\/embed\/([\w-]+)/,
getEmbedUrl: (match, url: string) => {
return url;
}
},
{
id: 'gdrive',
name: 'Google Drive',
regex: /^((?:https?:)?\/\/)?((?:www|m)\.)?(drive\.google\.com)\/file\/d\/([a-zA-Z0-9_-]+)\/.*$/,
getEmbedUrl: (match) => {
return `https://drive.google.com/file/d/${match[4]}/preview`;
}
},
];
export function getEmbedProviderById(id: string) {
return embedProviders.find(provider => provider.id.toLowerCase() === id.toLowerCase());
}
export interface IEmbedResult {
embedUrl: string;
provider: string;
}
export function getEmbedUrlAndProvider(url: string): IEmbedResult {
for (const provider of embedProviders) {
const match = url.match(provider.regex);
if (match) {
return {
embedUrl: provider.getEmbedUrl(match, url),
provider: provider.name.toLowerCase()
};
}
}
return {
embedUrl: url,
provider: 'iframe',
};
}

View File

@ -73,7 +73,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: computedColorScheme == 'light' ? false : true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
@ -147,6 +147,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
...excalidrawData,
scrollToContent: true,
}}
theme={computedColorScheme}
/>
</Suspense>
</div>
@ -202,7 +203,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit excalidraw diagram
Double-click to edit Excalidraw diagram
</Text>
</div>
</Card>

View File

@ -38,7 +38,7 @@ export default function MathInlineView(props: NodeViewProps) {
renderMath(preview || "", mathPreviewContainer.current);
} else if (preview !== null) {
queueMicrotask(() => {
updateAttributes({ text: preview });
updateAttributes({ text: preview.trim() });
});
}
}, [preview, isEditing]);
@ -97,7 +97,7 @@ export default function MathInlineView(props: NodeViewProps) {
ref={textAreaRef}
draggable={false}
classNames={{ input: classes.textInput }}
value={preview?.trim() ?? ""}
value={preview ?? ""}
placeholder={"E = mc^2"}
onKeyDown={(e) => {
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {

View File

@ -7,6 +7,7 @@
transition: background-color 0.2s;
padding: 0 0.25rem;
margin: 0 0.1rem;
user-select: none;
&.empty {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
@ -30,6 +31,7 @@
transition: background-color 0.2s;
margin: 0 0.1rem;
overflow-x: auto;
user-select: none;
.textInput {
width: 400px;

View File

@ -29,6 +29,16 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
import {
AirtableIcon,
FigmaIcon,
FramerIcon,
GoogleDriveIcon,
LoomIcon,
MiroIcon,
TypeformIcon,
VimeoIcon, YoutubeIcon
} from "@/components/icons";
const CommandGroups: SlashMenuGroupedItemsType = {
basic: [
@ -343,7 +353,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
day: "numeric",
});
return editor
editor
.chain()
.focus()
.deleteRange(range)
@ -351,6 +361,87 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Airtable",
description: "Embed Airtable",
searchTerms: ["airtable"],
icon: AirtableIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'airtable' }).run();
},
},
{
title: "Loom",
description: "Embed Loom video",
searchTerms: ["loom"],
icon: LoomIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'loom' }).run();
},
},
{
title: "Figma",
description: "Embed Figma files",
searchTerms: ["figma"],
icon: FigmaIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'figma' }).run();
},
},
{
title: "Typeform",
description: "Embed Typeform",
searchTerms: ["typeform"],
icon: TypeformIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'typeform' }).run();
},
},
{
title: "Miro",
description: "Embed Miro board",
searchTerms: ["miro"],
icon: MiroIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'miro' }).run();
},
},
{
title: "YouTube",
description: "Embed YouTube video",
searchTerms: ["youtube", "yt"],
icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'youtube' }).run();
},
},
{
title: "Vimeo",
description: "Embed Vimeo video",
searchTerms: ["vimeo"],
icon: VimeoIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'vimeo' }).run();
},
},
{
title: "Framer",
description: "Embed Framer prototype",
searchTerms: ["framer"],
icon: FramerIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'framer' }).run();
},
},
{
title: "Google Drive",
description: "Embed Google Drive content",
searchTerms: ["google drive", "gdrive"],
icon: GoogleDriveIcon,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setEmbed({ provider: 'gdrive' }).run();
},
},
],
};
@ -362,10 +453,10 @@ export const getSuggestionItems = ({
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
const fuzzyMatch = (query, target) => {
const fuzzyMatch = (query: string, target: string) => {
let queryIndex = 0;
target = target.toLowerCase();
for (let char of target) {
for (const char of target) {
if (query[queryIndex] === char) queryIndex++;
if (queryIndex === query.length) return true;
}

View File

@ -35,6 +35,7 @@ import {
CustomCodeBlock,
Drawio,
Excalidraw,
Embed
} from "@docmost/editor-ext";
import {
randomElement,
@ -53,6 +54,7 @@ import AttachmentView from "@/features/editor/components/attachment/attachment-v
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import elixir from "highlight.js/lib/languages/elixir";
@ -149,6 +151,7 @@ export const mainExtensions = [
DetailsSummary,
DetailsContent,
Youtube.configure({
addPasteHandler: false,
controls: true,
nocookie: true,
}),
@ -179,6 +182,9 @@ export const mainExtensions = [
Excalidraw.configure({
view: ExcalidrawView,
}),
Embed.configure({
view: EmbedView,
})
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -30,8 +30,7 @@ export function FullEditor({
return (
<Container
fluid={fullPageWidth}
{...(fullPageWidth && { mx: 80 })}
size={850}
size={!fullPageWidth && 850}
className={classes.editor}
>
<MemoizedTitleEditor

View File

@ -97,8 +97,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
}, [remoteProvider, localProvider]);
const extensions = [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser.user),
... mainExtensions,
... collabExtensions(remoteProvider, currentUser.user),
];
const editor = useEditor(
@ -184,6 +184,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
)}
</div>
)}
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
</div>
) : (
<EditorSkeleton />

View File

@ -25,7 +25,7 @@
color: inherit;
padding: 0;
background: none;
font-size: inherit;
font-size: var(--mantine-font-size-sm);
}
/* Code styling */
@ -103,12 +103,12 @@
@mixin where-light {
background-color: var(--code-bg, var(--mantine-color-gray-1));
color: var(--mantine-color-black);
color: var(--mantine-color-pink-7);
}
@mixin where-dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-gray-4);
color: var(--mantine-color-pink-7);
}
}
}

View File

@ -10,10 +10,7 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
usePageQuery,
useUpdatePageMutation,
} from "@/features/page/queries/page-query";
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
@ -21,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
export interface TitleEditorProps {
pageId: string;
@ -39,14 +36,18 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
const updatePageMutation = useUpdatePageMutation();
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const {
data: updatedPageData,
mutate: updatePageMutation,
status,
} = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const titleEditor = useEditor({
extensions: [
@ -74,6 +75,7 @@ export function TitleEditor({
onUpdate({ editor }) {
const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle);
setActivePageId(pageId);
},
editable: editable,
content: title,
@ -85,25 +87,30 @@ export function TitleEditor({
}, [title]);
useEffect(() => {
if (debouncedTitle !== null) {
updatePageMutation.mutate({
if (debouncedTitle !== null && activePageId === pageId) {
updatePageMutation({
pageId: pageId,
title: debouncedTitle,
});
}
}, [debouncedTitle]);
useEffect(() => {
if (status === "success" && updatedPageData) {
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: updatedPageData.spaceId,
entity: ["pages"],
id: pageId,
payload: { title: debouncedTitle, slugId: slugId },
});
}, 50);
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
}
}, [debouncedTitle]);
}, [updatedPageData, status]);
useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) {

View File

@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
const formSchema = z.object({
name: z.string().min(2).max(50),
name: z.string().trim().min(2).max(50),
description: z.string().max(500),
});

View File

@ -79,7 +79,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Edit</Button>
<Button type="submit">Save</Button>
</Group>
</form>
</Box>

View File

@ -1,69 +1,72 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import {Table, Group, Text, Anchor} from "@mantine/core";
import {useGetGroupsQuery} from "@/features/group/queries/group-query";
import React from "react";
import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import {Link} from "react-router-dom";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery();
const {data, isLoading} = useGetGroupsQuery();
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm">
<IconGroupCircle />
<div>
<Text fz="sm" fw={500}>
{group.name}
</Text>
<Text fz="xs" c="dimmed">
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
<Table.ScrollContainer minWidth={400}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Group</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group, index) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap"
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{group.memberCount} members
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</>
);

View File

@ -1,20 +1,20 @@
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
import {Group, Table, Text, Badge, Menu, ActionIcon} from "@mantine/core";
import {
useGroupMembersQuery,
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import {useParams} from "react-router-dom";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function GroupMembersList() {
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const {groupId} = useParams();
const {data, isLoading} = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
const {isAdmin} = useUserRole();
const onRemove = async (userId: string) => {
const memberToRemove = {
@ -34,72 +34,74 @@ export default function GroupMembersList() {
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
labels: {confirm: "Delete", cancel: "Cancel"},
confirmProps: {color: "red"},
onConfirm: () => onRemove(userId),
});
return (
<>
{data && (
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</>
);

View File

@ -39,21 +39,23 @@ export function MultiGroupSelect({
useEffect(() => {
if (groups) {
const groupsData = groups?.items.map((group: IGroup) => {
return {
value: group.id,
label: group.name,
};
});
const groupsData = groups?.items
.filter((group: IGroup) => group.name.toLowerCase() !== 'everyone')
.map((group: IGroup) => {
return {
value: group.id,
label: group.name,
};
});
// Filter out existing users by their ids
// Filter out existing groups by their ids
const filteredGroupData = groupsData.filter(
(user) =>
!data.find((existingUser) => existingUser.value === user.value),
(group) =>
!data.find((existingGroup) => existingGroup.value === group.value),
);
// Combine existing data with new search data
setData((prevData) => [...prevData, ...filteredGroupData]);
setData((prevData) => [... prevData, ... filteredGroupData]);
}
}, [groups]);

View File

@ -29,24 +29,22 @@ export function useGetGroupsQuery(
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({
queryKey: ['groups', groupId],
queryKey: ['group', groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});
}
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useCreateGroupMutation() {
const queryClient = useQueryClient();
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['groups'],
});
notifications.show({ message: 'Group created successfully' });
},
onError: () => {
@ -96,6 +94,14 @@ export function useDeleteGroupMutation() {
});
}
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();

View File

@ -1,11 +1,11 @@
.breadcrumbs {
flex: 1 1 auto;
display: flex;
align-items: center;
overflow: hidden;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {

View File

@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
import {
IconArrowsHorizontal,
IconDots,
IconDownload,
IconFileExport,
IconHistory,
IconLink,
IconMessage,
@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface PageHeaderMenuProps {
readOnly?: boolean;
@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<Menu.Divider />
<Menu.Item
leftSection={<IconDownload size={16} />}
leftSection={<IconFileExport size={16} />}
onClick={openExportModal}
>
Export
@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Dropdown>
</Menu>
<PageExportModal
pageId={page.id}
<ExportModal
type="page"
id={page.id}
open={exportOpened}
onClose={closeExportModal}
/>

View File

@ -1,4 +1,4 @@
import { Modal, Button, Group, Text, Select } from "@mantine/core";
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import * as React from "react";
@ -57,8 +57,18 @@ export default function PageExportModal({
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel

View File

@ -119,7 +119,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return (
<>
<SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
<FileButton onChange={handleFileUpload} accept=".md" multiple>
{(props) => (
<Button
justify="start"

View File

@ -15,7 +15,7 @@ import {
IconChevronDown,
IconChevronRight,
IconDotsVertical,
IconFileDescription,
IconFileDescription, IconFileExport,
IconLink,
IconPlus,
IconPointFilled,
@ -39,7 +39,12 @@ import {
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
import {
useClipboard,
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -47,6 +52,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface SpaceTreeProps {
spaceId: string;
@ -133,13 +139,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
flatTreeItems = [
...flatTreeItems,
...children.filter(
(child) => !flatTreeItems.some((item) => item.id === child.id),
(child) => !flatTreeItems.some((item) => item.id === child.id)
),
];
};
const fetchPromises = ancestors.map((ancestor) =>
fetchAndUpdateChildren(ancestor),
fetchAndUpdateChildren(ancestor)
);
// Wait for all fetch operations to complete
@ -153,7 +159,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const updatedTree = appendNodeChildren(
data,
rootChild.id,
rootChild.children,
rootChild.children
);
setData(updatedTree);
@ -191,13 +197,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
<div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && (
<Tree
data={data}
data={data.filter((node) => node?.spaceId === spaceId)}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
{...controllers}
width={width}
height={height}
height={rootElement.current.clientHeight}
ref={treeApiRef}
openByDefault={false}
disableMultiSelection={true}
@ -207,7 +213,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
overscanCount={10}
dndRootElement={rootElement.current}
onToggle={() => {
setOpenTreeNodes(treeApiRef.current.openState);
setOpenTreeNodes(treeApiRef.current?.openState);
}}
initialOpenState={openTreeNodes}
>
@ -248,7 +254,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const updatedTreeData = appendNodeChildren(
treeData,
node.data.id,
childrenTree,
childrenTree
);
setTreeData(updatedTreeData);
@ -279,6 +285,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native },
@ -293,6 +300,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: null },
@ -400,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -409,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
};
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
Copy link
</Menu.Item>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
Copy link
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
Export page
</Menu.Item>
<Menu.Item
c="red"
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={
<IconTrash size={16} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}

View File

@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom);
@ -31,6 +32,8 @@ export function useTreeMutation<T>(spaceId: string) {
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
const { pageSlug } = useParams();
const emit = useQueryEmit();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = {
@ -69,10 +72,22 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data });
setData(tree.data);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: spaceId,
payload: {
parentId,
index,
data,
},
});
}, 50);
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
createdPage.title,
createdPage.title
);
navigate(pageUrl);
return data;
@ -100,7 +115,7 @@ export function useTreeMutation<T>(spaceId: string) {
: tree.data;
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
// we have to access the node differently viq currentTreeData[args.index]?.data?.position
// we have to access the node differently via currentTreeData[args.index]?.data?.position
// this makes it possible to correctly sort children of a parent node that is not the root
const afterPosition =
@ -142,7 +157,7 @@ export function useTreeMutation<T>(spaceId: string) {
// check if the previous still has children
// if no children left, change 'hasChildren' to false, to make the page toggle arrows work properly
const childrenCount = previousParent.children.filter(
(child) => child.id !== draggedNodeId,
(child) => child.id !== draggedNodeId
).length;
if (childrenCount === 0) {
tree.update({
@ -162,6 +177,19 @@ export function useTreeMutation<T>(spaceId: string) {
try {
movePageMutation.mutateAsync(payload);
setTimeout(() => {
emit({
operation: "moveTreeNode",
spaceId: spaceId,
payload: {
id: draggedNodeId,
parentId: args.parentId,
index: args.index,
position: newPosition,
},
});
}, 50);
} catch (error) {
console.error("Error moving page:", error);
}
@ -182,12 +210,26 @@ export function useTreeMutation<T>(spaceId: string) {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
if (tree.find(args.ids[0])) {
tree.drop({ id: args.ids[0] });
setData(tree.data);
const node = tree.find(args.ids[0]);
if (!node) {
return;
}
navigate(getSpaceUrl(spaceSlug));
tree.drop({ id: args.ids[0] });
setData(tree.data);
// navigate only if the current url is same as the deleted page
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
navigate(getSpaceUrl(spaceSlug));
}
setTimeout(() => {
emit({
operation: "deleteTreeNode",
spaceId: spaceId,
payload: { node: node.data },
});
}, 50);
} catch (error) {
console.error("Failed to delete page:", error);
}

View File

@ -3,10 +3,12 @@
}
.treeContainer {
display: flex;
height: 68vh;
flex: 1;
height: 100%;
min-width: 0;
> div, > div > .tree {
height: 100% !important;
}
}
.node {

View File

@ -100,6 +100,28 @@ export const updateTreeNodeIcon = (
});
};
export const deleteTreeNode = (
nodes: SpaceTreeNode[],
nodeId: string,
): SpaceTreeNode[] => {
return nodes
.map((node) => {
if (node.id === nodeId) {
return null;
}
if (node.children && node.children.length > 0) {
return {
...node,
children: deleteTreeNode(node.children, nodeId),
};
}
return node;
})
.filter((node) => node !== null);
};
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {};
let result: SpaceTreeNode[] = [];

View File

@ -48,6 +48,7 @@ export interface IPageInput {
export interface IExportPageParams {
pageId: string;
format: ExportFormat;
includeChildren?: boolean;
}
export enum ExportFormat {

View File

@ -8,9 +8,10 @@ import { computeSpaceSlug } from "@/lib";
import { getSpaceUrl } from "@/lib/config.ts";
const formSchema = z.object({
name: z.string().min(2).max(50),
name: z.string().trim().min(2).max(50),
slug: z
.string()
.trim()
.min(2)
.max(50)
.regex(

View File

@ -1,10 +1,10 @@
import { Modal, Tabs, rem, Group, ScrollArea } from "@mantine/core";
import {Modal, Tabs, rem, Group, ScrollArea, Text} from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React, { useMemo } from "react";
import React, {useMemo} from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {useSpaceQuery} from "@/features/space/queries/space-query.ts";
import {useSpaceAbility} from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
@ -17,14 +17,14 @@ interface SpaceSettingsModalProps {
}
export default function SpaceSettingsModal({
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const {data: space, isLoading} = useSpaceQuery(spaceId);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
const spaceAbility = useSpaceAbility(spaceRules);
return (
<>
@ -37,14 +37,16 @@ export default function SpaceSettingsModal({
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Overlay/>
<Modal.Content style={{overflow: "hidden"}}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{space?.name}</Modal.Title>
<Modal.CloseButton />
<Modal.Title>
<Text fw={500} lineClamp={1}>{space?.name}</Text>
</Modal.Title>
<Modal.CloseButton/>
</Modal.Header>
<Modal.Body>
<div style={{ height: rem("600px") }}>
<div style={{height: rem(600)}}>
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
@ -55,34 +57,32 @@ export default function SpaceSettingsModal({
</Tabs.Tab>
</Tabs.List>
<ScrollArea h="600" w="100%" scrollbarSize={5}>
<Tabs.Panel value="general">
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</Tabs.Panel>
<Tabs.Panel value="general">
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</Tabs.Panel>
<Tabs.Panel value="members">
<Group my="md" justify="flex-end">
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id} />}
</Group>
<Tabs.Panel value="members">
<Group my="md" justify="flex-end">
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id}/>}
</Group>
<SpaceMembersList
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
)}
/>
</Tabs.Panel>
</ScrollArea>
<SpaceMembersList
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
)}
/>
</Tabs.Panel>
</Tabs>
</div>
</Modal.Body>

View File

@ -6,6 +6,7 @@
padding-top: 0;
display: flex;
flex-direction: column;
user-select: none;
}
.section {
@ -18,6 +19,16 @@
}
}
.sectionPages {
margin-bottom: 0;
overflow-y: hidden;
.pages {
height: 100%;
padding-bottom: 26px;
}
}
.menuItems {
padding-left: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));
padding-right: calc(var(--mantine-spacing-md) - var(--mantine-spacing-xs));

View File

@ -5,36 +5,38 @@ import {
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconArrowDown,
IconDots,
IconFileExport,
IconHome,
IconPlus,
IconSearch,
IconSettings,
} from '@tabler/icons-react';
} from "@tabler/icons-react";
import classes from './space-sidebar.module.css';
import React, { useMemo } from 'react';
import { useAtom } from 'jotai';
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from 'clsx';
import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
import { getSpaceUrl } from '@/lib/config.ts';
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '@/features/space/permissions/permissions.type.ts';
import PageImportModal from '@/features/page/components/page-import-modal.tsx';
import { SwitchSpace } from './switch-space';
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom);
@ -45,14 +47,14 @@ export function SpaceSidebar() {
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
const spaceAbility = useSpaceAbility(spaceRules);
if (!space) {
return <></>;
}
function handleCreatePage() {
tree?.create({ parentId: null, type: 'internal', index: 0 });
tree?.create({ parentId: null, type: "internal", index: 0 });
}
return (
@ -61,7 +63,7 @@ export function SpaceSidebar() {
<div
className={classes.section}
style={{
border: 'none',
border: "none",
marginTop: 2,
marginBottom: 3,
}}
@ -78,7 +80,7 @@ export function SpaceSidebar() {
classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton
: ''
: ""
)}
>
<div className={classes.menuItemInner}>
@ -134,7 +136,7 @@ export function SpaceSidebar() {
</div>
</div>
<div className={classes.section}>
<div className={clsx(classes.section, classes.sectionPages)}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
Pages
@ -191,6 +193,8 @@ interface SpaceMenuProps {
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return (
<>
@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
Import pages
</Menu.Item>
<Menu.Item
onClick={openExportModal}
leftSection={<IconFileExport size={16} />}
>
Export space
</Menu.Item>
<Menu.Divider />
<Menu.Item
@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
open={importOpened}
onClose={closeImportModal}
/>
<ExportModal
type="space"
id={spaceId}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}

View File

@ -12,7 +12,6 @@ interface SwitchSpaceProps {
}
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
const [opened, { close, open, toggle }] = useDisclosure(false);
const navigate = useNavigate();
const handleSelect = (value: string) => {
@ -28,7 +27,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
position="bottom"
withArrow
shadow="md"
opened={opened}
>
<Popover.Target>
<Button
@ -37,7 +35,6 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
justify="space-between"
rightSection={<IconChevronDown size={18} />}
color="gray"
onClick={toggle}
>
<Avatar
size={20}

View File

@ -1,8 +1,10 @@
import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Divider, Group, Text } from '@mantine/core';
import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
interface SpaceDetailsProps {
spaceId: string;
@ -10,6 +12,8 @@ interface SpaceDetailsProps {
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return (
<>
@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Export space</Text>
<Text size="sm" c="dimmed">
Export all pages and attachments in this space
</Text>
</div>
<Button onClick={openExportModal}>
Export
</Button>
</Group>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<DeleteSpaceModal space={space} />
</Group>
<ExportModal
type="space"
id={space.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
)}
</div>

View File

@ -1,13 +1,13 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import {Table, Group, Text, Avatar} from "@mantine/core";
import React, {useState} from "react";
import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
import {useDisclosure} from "@mantine/hooks";
import {formatMemberCount} from "@/lib";
export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const {data, isLoading} = useGetSpacesQuery();
const [opened, {open, close}] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => {
@ -18,44 +18,48 @@ export default function SpaceList() {
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm">
<Avatar
color="initials"
variant="filled"
name={space.name}
/>
<div>
<Text fz="sm" fw={500}>
{space.name}
</Text>
<Text fz="xs" c="dimmed">
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{formatMemberCount(space.memberCount)}</Table.Td>
<Table.ScrollContainer minWidth={400}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{cursor: "pointer"}}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Avatar
color="initials"
variant="filled"
name={space.name}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" style={{whiteSpace: 'nowrap'}}>{formatMemberCount(space.memberCount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{selectedSpaceId && (

View File

@ -1,32 +1,34 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import {Group, Table, Text, Menu, ActionIcon} from "@mantine/core";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {IconDots} from "@tabler/icons-react";
import {modals} from "@mantine/modals";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import {IRemoveSpaceMember} from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getSpaceRoleLabel,
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib";
import {formatMemberCount} from "@/lib";
type MemberType = "user" | "group";
interface SpaceMembersProps {
spaceId: string;
readOnly?: boolean;
}
export default function SpaceMembersList({
spaceId,
readOnly,
}: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId);
spaceId,
readOnly,
}: SpaceMembersProps) {
const {data, isLoading} = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -85,99 +87,101 @@ export default function SpaceMembersList({
</Text>
),
centered: true,
labels: { confirm: "Remove", cancel: "Cancel" },
confirmProps: { color: "red" },
labels: {confirm: "Remove", cancel: "Cancel"},
confirmProps: {color: "red"},
onConfirm: () => onRemove(memberId, type),
});
return (
<>
{data && (
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() =>
openRemoveModal(member.id, member.type)
}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<CustomAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle/>}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${formatMemberCount(member?.memberCount)}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
{!readOnly && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2}/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() =>
openRemoveModal(member.id, member.type)
}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</>
);

View File

@ -36,7 +36,7 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ['spaces', spaceId],
queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
@ -65,7 +65,7 @@ export function useGetSpaceBySlugQuery(
spaceId: string
): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ['spaces', spaceId],
queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
@ -111,7 +111,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) {
queryClient.removeQueries({
queryKey: ['spaces', variables.slug],
queryKey: ['space', variables.slug],
exact: true,
});
}

View File

@ -1,56 +1,72 @@
import api from '@/lib/api-client';
import api from "@/lib/api-client";
import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IExportSpaceParams,
IRemoveSpaceMember,
ISpace,
} from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver";
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
export async function getSpaces(
params?: QueryParams
): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params);
return req.data;
}
export async function getSpaceById(spaceId: string): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/info', { spaceId });
const req = await api.post<ISpace>("/spaces/info", { spaceId });
return req.data;
}
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/create', data);
const req = await api.post<ISpace>("/spaces/create", data);
return req.data;
}
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/update', data);
const req = await api.post<ISpace>("/spaces/update", data);
return req.data;
}
export async function deleteSpace(spaceId: string): Promise<void> {
await api.post<void>('/spaces/delete', { spaceId });
await api.post<void>("/spaces/delete", { spaceId });
}
export async function getSpaceMembers(
spaceId: string
): Promise<IPagination<IUser>> {
const req = await api.post<any>('/spaces/members', { spaceId });
const req = await api.post<any>("/spaces/members", { spaceId });
return req.data;
}
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
await api.post('/spaces/members/add', data);
await api.post("/spaces/members/add", data);
}
export async function removeSpaceMember(
data: IRemoveSpaceMember
): Promise<void> {
await api.post('/spaces/members/remove', data);
await api.post("/spaces/members/remove", data);
}
export async function changeMemberRole(
data: IChangeSpaceMemberRole
): Promise<void> {
await api.post('/spaces/members/change-role', data);
await api.post("/spaces/members/change-role", data);
}
export async function exportSpace(data: IExportSpaceParams): Promise<void> {
const req = await api.post("/spaces/export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName));
}

View File

@ -3,6 +3,7 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpace {
id: string;
@ -68,3 +69,9 @@ export interface SpaceGroupInfo {
}
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
export interface IExportSpaceParams {
spaceId: string;
format: ExportFormat;
includeAttachments?: boolean;
}

View File

@ -1,14 +1,55 @@
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
export type InvalidateEvent = {
operation: "invalidate";
spaceId: string;
entity: Array<string>;
id?: string;
};
export type UpdateEvent = {
operation: "updateOne";
spaceId: string;
entity: Array<string>;
id: string;
payload: Partial<any>;
};
export type WebSocketEvent = InvalidateEvent | UpdateEvent;
export type DeleteEvent = {
operation: "deleteOne";
spaceId: string;
entity: Array<string>;
id: string;
payload?: Partial<any>;
};
export type AddTreeNodeEvent = {
operation: "addTreeNode";
spaceId: string;
payload: {
parentId: string;
index: number;
data: SpaceTreeNode;
};
};
export type MoveTreeNodeEvent = {
operation: "moveTreeNode";
spaceId: string;
payload: {
id: string;
parentId: string;
index: number;
position: string;
}
};
export type DeleteTreeNodeEvent = {
operation: "deleteTreeNode";
spaceId: string;
payload: {
node: SpaceTreeNode
}
};
export type WebSocketEvent = InvalidateEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent;

View File

@ -30,10 +30,13 @@ export const useQuerySubscription = () => {
queryKeyId = data.id;
}
queryClient.setQueryData([...data.entity, queryKeyId], {
...queryClient.getQueryData([...data.entity, queryKeyId]),
...data.payload,
});
// only update if data was already in cache
if(queryClient.getQueryData([...data.entity, queryKeyId])){
queryClient.setQueryData([...data.entity, queryKeyId], {
...queryClient.getQueryData([...data.entity, queryKeyId]),
...data.payload,
});
}
/*
queryClient.setQueriesData(

View File

@ -2,17 +2,15 @@ import { useEffect, useRef } from "react";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import {
updateTreeNodeIcon,
updateTreeNodeName,
} from "@/features/page/tree/utils";
import { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query";
import { SimpleTree } from "react-arborist";
export const useTreeSocket = () => {
const [socket] = useAtom(socketAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom);
const queryClient = useQueryClient();
const initialTreeData = useRef(treeData);
useEffect(() => {
@ -20,42 +18,63 @@ export const useTreeSocket = () => {
}, [treeData]);
useEffect(() => {
socket?.on("message", (event) => {
const data: WebSocketEvent = event;
socket?.on("message", (event: WebSocketEvent) => {
const initialData = initialTreeData.current;
switch (data.operation) {
case "invalidate":
// nothing to do here
break;
const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
switch (event.operation) {
case "updateOne":
// Get the initial value of treeData
if (initialData && initialData.length > 0) {
let newTreeData: SpaceTreeNode[];
if (data.entity[0] === "pages") {
if (data.payload?.title !== undefined) {
newTreeData = updateTreeNodeName(
initialData,
data.id,
data.payload.title,
);
if (event.entity[0] === "pages") {
if (treeApi.find(event.id)) {
if (event.payload?.title) {
treeApi.update({ id: event.id, changes: { name: event.payload.title } });
}
if (data.payload?.icon !== undefined) {
newTreeData = updateTreeNodeIcon(
initialData,
data.id,
data.payload.icon,
);
}
if (newTreeData && newTreeData.length > 0) {
setTreeData(newTreeData);
if (event.payload?.icon) {
treeApi.update({ id: event.id, changes: { icon: event.payload.icon } });
}
setTreeData(treeApi.data);
}
}
break;
case 'addTreeNode':
if (treeApi.find(event.payload.data.id)) return;
treeApi.create({ parentId: event.payload.parentId, index: event.payload.index, data: event.payload.data });
setTreeData(treeApi.data);
break;
case 'moveTreeNode':
// move node
if (treeApi.find(event.payload.id)) {
treeApi.move({
id: event.payload.id,
parentId: event.payload.parentId,
index: event.payload.index
});
// update node position
treeApi.update({
id: event.payload.id,
changes: {
position: event.payload.position,
}
});
setTreeData(treeApi.data);
}
break;
case "deleteTreeNode":
if (treeApi.find(event.payload.node.id)){
treeApi.drop({ id: event.payload.node.id });
setTreeData(treeApi.data);
queryClient.invalidateQueries({
queryKey: ['pages', event.payload.node.slugId].filter(Boolean),
});
}
break;
}
});
}, [socket]);

View File

@ -1,62 +1,64 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import {Group, Table, Avatar, Text, Alert} from "@mantine/core";
import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts";
import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import {getUserRoleLabel} from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react";
import { formattedDate } from "@/lib/time.ts";
import {IconInfoCircle} from "@tabler/icons-react";
import {formattedDate, timeAgo} from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceInvitesTable() {
const { data, isLoading } = useWorkspaceInvitationsQuery({
const {data, isLoading} = useWorkspaceInvitationsQuery({
limit: 100,
});
const { isAdmin } = useUserRole();
const {isAdmin} = useUserRole();
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
<Alert variant="light" color="blue" icon={<IconInfoCircle/>}>
Invited members who are yet to accept their invitation will appear here.
</Alert>
{data && (
<>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar name={invitation.email} color="initials" />
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{formattedDate(invitation.createdAt)}</Table.Td>
<Table.Td>
{isAdmin && (
<InviteActionMenu invitationId={invitation.id} />
)}
</Table.Td>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar name={invitation.email} color="initials"/>
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
<Table.Td>
{isAdmin && (
<InviteActionMenu invitationId={invitation.id}/>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
</>

View File

@ -1,9 +1,9 @@
import { Group, Table, Text, Badge } from "@mantine/core";
import {Group, Table, Text, Badge} from "@mantine/core";
import {
useChangeMemberRoleMutation,
useWorkspaceMembersQuery,
} from "@/features/workspace/queries/workspace-query.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import React from "react";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
@ -11,14 +11,14 @@ import {
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts";
import {UserRole} from "@/lib/types.ts";
export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const { isAdmin, isOwner } = useUserRole();
const {isAdmin, isOwner} = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async (
userId: string,
@ -40,50 +40,52 @@ export default function WorkspaceMembersTable() {
return (
<>
{data && (
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Role</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
disabled={!isAdmin}
/>
</Table.Td>
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Role</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/>
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">Active</Badge>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
disabled={!isAdmin}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</>
);

View File

@ -141,7 +141,6 @@ export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<any, Error> {
return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),
enabled: !!invitationId,

View File

@ -26,14 +26,18 @@ api.interceptors.request.use(
},
(error) => {
return Promise.reject(error);
},
}
);
api.interceptors.response.use(
(response) => {
// we need the response headers
if (response.request.responseURL.includes("/api/pages/export")) {
return response;
// we need the response headers for these endpoints
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
return response;
}
}
return response.data;
@ -72,7 +76,7 @@ api.interceptors.response.use(
}
}
return Promise.reject(error);
},
}
);
function redirectToLogin() {

View File

@ -6,6 +6,10 @@ declare global {
}
}
export function getAppName(): string{
return 'Docmost';
}
export function getAppUrl(): string {
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
@ -53,6 +57,14 @@ export function getFileUrl(src: string) {
}
export function getFileUploadSizeLimit() {
const limit = window.CONFIG?.FILE_UPLOAD_SIZE_LIMIT || process?.env.FILE_UPLOAD_SIZE_LIMIT || '50mb';
const limit =getConfigValue("FILE_UPLOAD_SIZE_LIMIT", "50mb");
return bytes(limit);
}
export function getDrawioUrl() {
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
}
function getConfigValue(key: string, defaultValue: string = undefined) {
return window.CONFIG?.[key] || process?.env?.[key] || defaultValue;
}

View File

@ -2,9 +2,9 @@ import { atom } from "jotai";
export function atomWithWebStorage<Value>(key: string, initialValue: Value, storage = localStorage) {
const storedValue = localStorage.getItem(key);
const isString = typeof initialValue === "string";
const isStringOrInt = typeof initialValue === "string" || typeof initialValue === "number";
const storageValue = storedValue ? isString ? storedValue : storedValue === "true" : undefined;
const storageValue = storedValue ? isStringOrInt ? storedValue : storedValue === "true" : undefined;
const baseAtom = atom(storageValue ?? initialValue);
return atom(

View File

@ -71,3 +71,7 @@ export function decodeBase64ToSvgString(base64Data: string): string {
return decodeBase64(base64Data);
}
export function capitalizeFirstChar(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@ -1,11 +1,12 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
export default function ForgotPassword() {
return (
<>
<Helmet>
<title>Forgot Password - Docmost</title>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<ForgotPasswordForm />
</>

View File

@ -1,12 +1,13 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
export default function InviteSignup() {
return (
<>
<Helmet>
<title>Invitation Signup - Docmost</title>
</Helmet>
<Helmet>
<title>Invitation Signuo - {getAppName()}</title>
</Helmet>
<InviteSignUpForm />
</>
);

View File

@ -1,11 +1,12 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import {getAppName} from "@/lib/config.ts";
export default function LoginPage() {
return (
<>
<Helmet>
<title>Login - Docmost</title>
<title>Login - {getAppName()}</title>
</Helmet>
<LoginForm />
</>

View File

@ -4,6 +4,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
import {getAppName} from "@/lib/config.ts";
export default function PasswordReset() {
const [searchParams] = useSearchParams();
@ -21,7 +22,7 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>Password Reset - Docmost</title>
<title>Password Reset - {getAppName()}</title>
</Helmet>
<Container my={40}>
<Text size="lg" ta="center">
@ -45,7 +46,7 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>Password Reset - Docmost</title>
<title>Password Reset - {getAppName()}</title>
</Helmet>
<PasswordResetForm resetToken={resetToken} />
</>

View File

@ -3,6 +3,7 @@ import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-f
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {getAppName} from "@/lib/config.ts";
export default function SetupWorkspace() {
const {
@ -32,7 +33,7 @@ export default function SetupWorkspace() {
return (
<>
<Helmet>
<title>Setup Workspace - Docmost</title>
<title>Setup Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm />
</>

View File

@ -1,15 +1,22 @@
import { Container, Space } from "@mantine/core";
import {Container, Space} from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import SpaceGrid from "@/features/space/components/space-grid.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Home() {
return (
<Container size={"800"} pt="xl">
<SpaceGrid />
return (
<>
<Helmet>
<title>Home - {getAppName()}</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid/>
<Space h="xl" />
<Space h="xl"/>
<HomeTabs />
</Container>
);
<HomeTabs/>
</Container>
</>
);
}

View File

@ -23,7 +23,7 @@ export default function Page() {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
const spaceAbility = useSpaceAbility(spaceRules);
if (isLoading) {
return <></>;

View File

@ -1,15 +1,20 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import { Divider } from "@mantine/core";
import {Divider} from "@mantine/core";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function AccountPreferences() {
return (
<>
<SettingsTitle title="Preferences" />
<AccountTheme />
<Divider my={"md"} />
<PageWidthPref />
</>
);
return (
<>
<Helmet>
<title>Preferences - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Preferences"/>
<AccountTheme/>
<Divider my={"md"}/>
<PageWidthPref/>
</>
);
}

View File

@ -4,10 +4,15 @@ import ChangePassword from "@/features/user/components/change-password";
import { Divider } from "@mantine/core";
import AccountAvatar from "@/features/user/components/account-avatar";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function AccountSettings() {
return (
<>
<Helmet>
<title>My Profile - {getAppName()}</title>
</Helmet>
<SettingsTitle title="My Profile" />
<AccountAvatar />

View File

@ -1,13 +1,18 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function GroupInfo() {
return (
<>
<SettingsTitle title="Manage Group" />
<GroupDetails />
<GroupMembersList />
</>
);
return (
<>
<Helmet>
<title>Manage Group - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Manage Group"/>
<GroupDetails/>
<GroupMembersList/>
</>
);
}

View File

@ -3,12 +3,17 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Groups() {
const { isAdmin } = useUserRole();
return (
<>
<Helmet>
<title>Groups - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Groups" />
<Group my="md" justify="flex-end">

View File

@ -1,21 +1,26 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import {Group} from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Spaces() {
const { isAdmin } = useUserRole();
const {isAdmin} = useUserRole();
return (
<>
<SettingsTitle title="Spaces" />
return (
<>
<Helmet>
<title>Spaces - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Spaces"/>
<Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />}
</Group>
<Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal/>}
</Group>
<SpaceList />
</>
);
<SpaceList/>
</>
);
}

View File

@ -1,62 +1,67 @@
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import { Group, SegmentedControl, Space, Text } from "@mantine/core";
import {Group, SegmentedControl, Space, Text} from "@mantine/core";
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {useEffect, useState} from "react";
import {useNavigate, useSearchParams} from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function WorkspaceMembers() {
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
const { isAdmin } = useUserRole();
const navigate = useNavigate();
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
const {isAdmin} = useUserRole();
const navigate = useNavigate();
useEffect(() => {
const currentTab = searchParams.get("tab");
if (currentTab === "invites") {
setSegmentValue(currentTab);
}
}, [searchParams.get("tab")]);
useEffect(() => {
const currentTab = searchParams.get("tab");
if (currentTab === "invites") {
setSegmentValue(currentTab);
}
}, [searchParams.get("tab")]);
const handleSegmentChange = (value: string) => {
setSegmentValue(value);
if (value === "invites") {
navigate(`?tab=${value}`);
} else {
navigate("");
}
};
const handleSegmentChange = (value: string) => {
setSegmentValue(value);
if (value === "invites") {
navigate(`?tab=${value}`);
} else {
navigate("");
}
};
return (
<>
<SettingsTitle title="Members" />
return (
<>
<Helmet>
<title>Members - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Members"/>
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
<Group justify="space-between">
<SegmentedControl
value={segmentValue}
onChange={handleSegmentChange}
data={[
{ label: "Members", value: "members" },
{ label: "Pending", value: "invites" },
]}
withItemsBorders={false}
/>
<Group justify="space-between">
<SegmentedControl
value={segmentValue}
onChange={handleSegmentChange}
data={[
{label: "Members", value: "members"},
{label: "Pending", value: "invites"},
]}
withItemsBorders={false}
/>
{isAdmin && <WorkspaceInviteModal />}
</Group>
{isAdmin && <WorkspaceInviteModal/>}
</Group>
<Space h="lg" />
<Space h="lg"/>
{segmentValue === "invites" ? (
<WorkspaceInvitesTable />
) : (
<WorkspaceMembersTable />
)}
</>
);
{segmentValue === "invites" ? (
<WorkspaceInvitesTable/>
) : (
<WorkspaceMembersTable/>
)}
</>
);
}

View File

@ -1,11 +1,16 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function WorkspaceSettings() {
return (
<>
<SettingsTitle title="General" />
<WorkspaceNameForm />
</>
);
return (
<>
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title="General"/>
<WorkspaceNameForm/>
</>
);
}

View File

@ -1,15 +1,22 @@
import { Container } from "@mantine/core";
import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import { useParams } from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function SpaceHome() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const {spaceSlug} = useParams();
const {data: space} = useGetSpaceBySlugQuery(spaceSlug);
return (
<Container size={"800"} pt="xl">
{space && <SpaceHomeTabs />}
</Container>
);
return (
<>
<Helmet>
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<Container size={"800"} pt="xl">
{space && <SpaceHomeTabs/>}
</Container>
</>
);
}

View File

@ -1,23 +0,0 @@
import { Title, Text, Stack } from '@mantine/core';
import { ThemeToggle } from '@/components/theme-toggle';
export function Welcome() {
return (
<Stack>
<Title ta="center" mt={100}>
<Text
inherit
variant="gradient"
component="span"
gradient={{ from: 'pink', to: 'yellow' }}
>
Welcome
</Text>
</Title>
<Text ta="center" size="lg" maw={580} mx="auto" mt="xl">
Welcome to something new and interesting.
</Text>
<ThemeToggle />
</Stack>
);
}

View File

@ -5,13 +5,14 @@ import * as path from "path";
export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => {
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT } = loadEnv(mode, envPath, "");
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, DRAWIO_URL } = loadEnv(mode, envPath, "");
return {
define: {
"process.env": {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT
FILE_UPLOAD_SIZE_LIMIT,
DRAWIO_URL
},
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
},

View File

@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

View File

@ -0,0 +1,34 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: ['eslint.config.mjs'],
},
{
languageOptions: {
globals: { ...globals.node, ...globals.jest },
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'prefer-rest-params': 'off',
'no-useless-catch': 'off',
'no-useless-escape': 'off',
},
},
];

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.4.1",
"version": "0.6.1",
"description": "",
"author": "",
"private": true,
@ -28,43 +28,43 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.637.0",
"@aws-sdk/s3-request-presigner": "^3.637.0",
"@casl/ability": "^6.7.1",
"@aws-sdk/client-s3": "^3.701.0",
"@aws-sdk/s3-request-presigner": "^3.701.0",
"@casl/ability": "^6.7.2",
"@fastify/cookie": "^9.4.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@nestjs/bullmq": "^10.2.1",
"@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.1",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/bullmq": "^10.2.2",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/mapped-types": "^2.0.6",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.4.1",
"@nestjs/platform-socket.io": "^10.4.1",
"@nestjs/platform-fastify": "^10.4.9",
"@nestjs/platform-socket.io": "^10.4.9",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^10.4.1",
"@react-email/components": "0.0.24",
"@react-email/render": "^1.0.1",
"@nestjs/websockets": "^10.4.9",
"@react-email/components": "0.0.28",
"@react-email/render": "^1.0.2",
"@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.12.12",
"bullmq": "^5.29.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0",
"happy-dom": "^15.7.3",
"happy-dom": "^15.11.6",
"kysely": "^0.27.4",
"kysely-migration-cli": "^0.4.2",
"marked": "^13.0.3",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nanoid": "^5.0.9",
"nestjs-kysely": "^1.0.0",
"nodemailer": "^6.9.14",
"nodemailer": "^6.9.16",
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"pg": "^8.13.1",
"pg-tsquery": "^8.4.2",
"postmark": "^4.0.5",
"react": "^18.3.1",
@ -72,40 +72,40 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.7.5",
"socket.io": "^4.8.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.1",
"@eslint/js": "^9.16.0",
"@nestjs/cli": "^10.4.8",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.9",
"@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.5.2",
"@types/nodemailer": "^6.4.15",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.8",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"eslint": "^9.9.1",
"@types/ws": "^8.5.13",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.13.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.16.3",
"prettier": "^3.3.3",
"react-email": "^3.0.1",
"kysely-codegen": "^0.17.0",
"prettier": "^3.4.1",
"react-email": "^3.0.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0"
},
"jest": {
"moduleFileExtensions": [

View File

@ -1,5 +1,8 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param, Res } from '@nestjs/common';
import { AppService } from './app.service';
import { FastifyReply } from "fastify";
import { join } from 'path';
import * as fs from 'node:fs';
@Controller()
export class AppController {
@ -9,4 +12,27 @@ export class AppController {
getHello(): string {
return this.appService.getHello();
}
@Get('/share/:id')
getShare(@Res({ passthrough: false}) res: FastifyReply, @Param() params: any): string {
const clientDistPath = join(
__dirname,
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
console.log('exists')
const indexFilePath = join(clientDistPath, 'index.html');
const stream = fs.createReadStream(indexFilePath);
console.log(params.id)
res.type('text/html').send(stream);
console.log('found');
return;
}
console.log('end')
return this.appService.getHello();
}
}

View File

@ -36,6 +36,7 @@ export class CollaborationGateway {
port: this.redisConfig.port,
options: {
password: this.redisConfig.password,
db: this.redisConfig.db,
retryStrategy: createRetryStrategy(),
},
}),

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