Compare commits

...

34 Commits

Author SHA1 Message Date
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
46d92fbabc v0.4.1 2024-10-11 11:39:12 +01:00
e17b975aaa Revert "Add alignment styles for task list items (#378)" (#390)
This reverts commit 2721ab6a29.
2024-10-11 11:37:39 +01:00
038d21b438 v0.4.0 2024-10-10 22:03:16 +01:00
078361b367 add local editor-ext to client package.json
* update vite
2024-10-10 21:57:36 +01:00
384f11f2b7 make file upload size limit configurable (#386) 2024-10-10 21:28:28 +01:00
e333eee08b fix: base64 encoded drawio image decoded to Latin-1 instead of UTF-8 (#369) 2024-10-10 15:39:04 +01:00
7ec6a36515 fix: removed font overwrite for KaTeX elements (#377) 2024-10-10 15:35:20 +01:00
2721ab6a29 Add alignment styles for task list items (#378) 2024-10-09 18:52:20 +01:00
a2bc374f47 fix: horizontal scrollbar always shown on math block (#353) 2024-09-30 02:39:57 +01:00
eaa80a5546 fix: disconnect Redis health checker (#351) 2024-09-29 10:00:24 +01:00
e9e668bd39 fix: use environment service for refresh token's expiration (#337) 2024-09-21 10:41:26 +01:00
9390b39e35 Implement nodemailer ignore tls property (#299) 2024-09-20 17:57:50 +01:00
2ae3816324 fix: send "invitation accepted" email to inviter (#331)
The email says "${invitedUserName} has accepted your invitation ...", so it makes more sense to send it to the inviter instead of the invitee.
2024-09-19 22:19:04 +01:00
e96330afbf fix: text casing 2024-09-19 15:59:56 +01:00
e56f7933f4 fix: refactor forgot password system (#329)
* refactor forgot password system

* ready
2024-09-19 15:51:51 +01:00
b152c858b4 fix: add user tokens repo to database module 2024-09-18 20:28:39 +01:00
e43ea66442 add forgot-password ui (#273) 2024-09-17 15:53:05 +01:00
f34812653e feat(backend): forgot password (#250)
* feat(backend): forgot password

* feat: apply feedback from code review

* chore(auth): validate the minimum length of 'newPassword'

* chore(auth): make token has an expiry of 1 hour

* chore: rename all occurrences of 'code' to 'token'

* chore(backend): provide value on nanoIdGen method
2024-09-17 15:52:47 +01:00
6a3a7721be features and bug fixes (#322)
* fix page import title bug

* fix youtube embed in markdown export

* add link to rendered file html

* fix: markdown callout import

* update local generateJSON

* feat: switch spaces from sidebar

* remove unused package

* feat: editor date menu command

* fix date description

* update default locale code

* feat: add more code highlight languages
2024-09-17 15:40:49 +01:00
fb27282886 feat: delete space and edit space slug (#307)
* feat: make space slug editable

* feat: delete space

* client
2024-09-16 17:43:40 +01:00
dea9f4c063 remove unnecessary log 2024-09-13 22:37:38 +01:00
0b6730c06f fix page export failure when title contains non-ASCII characters (#309) 2024-09-13 17:40:24 +01:00
144 changed files with 3503 additions and 1499 deletions

View File

@ -21,6 +21,9 @@ AWS_S3_BUCKET=
AWS_S3_ENDPOINT= AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE= AWS_S3_FORCE_PATH_STYLE=
# default: 50mb
FILE_UPLOAD_SIZE_LIMIT=
# options: smtp | postmark # options: smtp | postmark
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
MAIL_FROM_ADDRESS=hello@example.com MAIL_FROM_ADDRESS=hello@example.com
@ -32,6 +35,7 @@ SMTP_PORT=587
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
SMTP_IGNORETLS=false
# Postmark driver config # Postmark driver config
POSTMARK_TOKEN= POSTMARK_TOKEN=

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/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/ COPY --from=builder /app/pnpm*.yaml /app/
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm RUN npm install -g pnpm
RUN chown -R node:node /app RUN chown -R node:node /app

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.3.1", "version": "0.5.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.1",
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6", "@excalidraw/excalidraw": "^0.17.6",
@ -41,7 +42,6 @@
"react-drawio": "^0.2.0", "react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
@ -68,6 +68,6 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.2" "vite": "^5.4.8"
} }
} }

View File

@ -24,6 +24,8 @@ import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx"; import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx"; import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
export default function App() { export default function App() {
const [, setSocket] = useAtom(socketAtom); const [, setSocket] = useAtom(socketAtom);
@ -63,6 +65,8 @@ export default function App() {
<Route path={"/login"} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} /> <Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/setup/register"} element={<SetupWorkspace />} /> <Route path={"/setup/register"} element={<SetupWorkspace />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />

View File

@ -4,24 +4,25 @@ import {
UnstyledButton, UnstyledButton,
Badge, Badge,
Table, Table,
ScrollArea, ActionIcon,
} from "@mantine/core"; } 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 PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from "@/features/page/page.utils.ts"; import {buildPageUrl} from '@/features/page/page.utils.ts';
import { formattedDate } from "@/lib/time.ts"; import {formattedDate} from '@/lib/time.ts';
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; import {useRecentChangesQuery} from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from "@tabler/icons-react"; import {IconFileDescription} from '@tabler/icons-react';
import { getSpaceUrl } from "@/lib/config.ts"; import {getSpaceUrl} from '@/lib/config.ts';
interface Props { interface Props {
spaceId?: string; 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) { if (isLoading) {
return <PageListSkeleton />; return <PageListSkeleton/>;
} }
if (isError) { if (isError) {
@ -29,7 +30,7 @@ export default function RecentChanges({ spaceId }: Props) {
} }
return pages && pages.items.length > 0 ? ( return pages && pages.items.length > 0 ? (
<ScrollArea> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Tbody> <Table.Tbody>
{pages.items.map((page) => ( {pages.items.map((page) => (
@ -40,10 +41,14 @@ export default function RecentChanges({ spaceId }: Props) {
to={buildPageUrl(page?.space.slug, page.slugId, page.title)} to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
{page.icon || <IconFileDescription size={18} />} {page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18}/>
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}> <Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"} {page.title || 'Untitled'}
</Text> </Text>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@ -55,14 +60,14 @@ export default function RecentChanges({ spaceId }: Props) {
variant="light" variant="light"
component={Link} component={Link}
to={getSpaceUrl(page?.space.slug)} to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }} style={{cursor: 'pointer'}}
> >
{page?.space.name} {page?.space.name}
</Badge> </Badge>
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text c="dimmed" size="xs" fw={500}> <Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)} {formattedDate(page.updatedAt)}
</Text> </Text>
</Table.Td> </Table.Td>
@ -70,7 +75,7 @@ export default function RecentChanges({ spaceId }: Props) {
))} ))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</ScrollArea> </Table.ScrollContainer>
) : ( ) : (
<Text size="md" ta="center"> <Text size="md" ta="center">
No pages yet 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 classes from "./app-header.module.css";
import React from "react"; import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx"; 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 APP_ROUTE from "@/lib/app-route.ts";
import { useAtom } from "jotai/index"; import {useAtom} from "jotai/index";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } 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"; 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() { export function AppHeader() {
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
@ -35,28 +35,33 @@ export function AppHeader() {
<Group wrap="nowrap"> <Group wrap="nowrap">
{!isHomeRoute && ( {!isHomeRoute && (
<> <>
<SidebarToggle <Tooltip label="Sidebar toggle">
aria-label="sidebar toggle"
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<SidebarToggle <SidebarToggle
aria-label="sidebar toggle" aria-label="Sidebar toggle"
opened={desktopOpened} opened={mobileOpened}
onClick={toggleDesktop} onClick={toggleMobile}
visibleFrom="sm" hiddenFrom="sm"
size="sm" size="sm"
/> />
</Tooltip>
<Tooltip label="Sidebar toggle">
<SidebarToggle
aria-label="Sidebar toggle"
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
</> </>
)} )}
<Text <Text
size="lg" size="lg"
fw={600} fw={600}
style={{ cursor: "pointer", userSelect: "none" }} style={{cursor: "pointer", userSelect: "none"}}
component={Link} component={Link}
to="/home" to="/home"
> >
@ -69,7 +74,7 @@ export function AppHeader() {
</Group> </Group>
<Group px={"xl"}> <Group px={"xl"}>
<TopMenu /> <TopMenu/>
</Group> </Group>
</Group> </Group>
</> </>

View File

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

View File

@ -1,19 +1,25 @@
import { Title, Text, Button, Container, Group } from "@mantine/core"; import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css"; import classes from "./error-404.module.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
export function Error404() { export function Error404() {
return ( return (
<Container className={classes.root}> <>
<Title className={classes.title}>404 Page Not Found</Title> <Helmet>
<Text c="dimmed" size="lg" ta="center" className={classes.description}> <title>404 page not found - Docmost</title>
Sorry, we can't find the page you are looking for. </Helmet>
</Text> <Container className={classes.root}>
<Group justify="center"> <Title className={classes.title}>404 Page Not Found</Title>
<Button component={Link} to={"/home"} variant="subtle" size="md"> <Text c="dimmed" size="lg" ta="center" className={classes.description}>
Take me back to homepage Sorry, we can't find the page you are looking for.
</Button> </Text>
</Group> <Group justify="center">
</Container> <Button component={Link} to={"/home"} variant="subtle" size="md">
Take me back to homepage
</Button>
</Group>
</Container>
</>
); );
} }

View File

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

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Invalid email address" }),
});
export function ForgotPasswordForm() {
const { forgotPassword, isLoading } = useAuth();
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
const form = useForm<IForgotPassword>({
validate: zodResolver(formSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: IForgotPassword) {
if (await forgotPassword(data)) {
setIsTokenSent(true);
}
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Forgot password
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
{!isTokenSent && (
<TextInput
id="email"
type="email"
label="Email"
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
)}
{isTokenSent && (
<Text>
A password reset link has been sent to your email. Please check
your inbox.
</Text>
)}
{!isTokenSent && (
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Send reset link
</Button>
)}
</form>
</Box>
</Container>
);
}

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"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2), name: z.string().trim().min(1),
password: z.string().min(8), password: z.string().min(8),
}); });

View File

@ -1,4 +1,3 @@
import * as React from "react";
import * as z from "zod"; import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth"; import useAuth from "@/features/auth/hooks/use-auth";
@ -10,9 +9,13 @@ import {
Button, Button,
PasswordInput, PasswordInput,
Box, Box,
Anchor,
} from "@mantine/core"; } from "@mantine/core";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@ -62,10 +65,20 @@ export function LoginForm() {
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In Sign In
</Button> </Button>
</form> </form>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
Forgot your password?
</Anchor>
</Box> </Box>
</Container> </Container>
); );

View File

@ -0,0 +1,67 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { IPasswordReset } from "@/features/auth/types/auth.types";
import {
Box,
Button,
Container,
PasswordInput,
Text,
Title,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
newPassword: z
.string()
.min(8, { message: "Password must contain at least 8 characters" }),
});
interface PasswordResetFormProps {
resetToken?: string;
}
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IPasswordReset>({
validate: zodResolver(formSchema),
initialValues: {
newPassword: "",
},
});
async function onSubmit(data: IPasswordReset) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword
})
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Password reset
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<PasswordInput
label="New password"
placeholder="Your new password"
variant="filled"
mt="md"
{...form.getInputProps("newPassword")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Set password
</Button>
</form>
</Box>
</Container>
);
}

View File

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

View File

@ -1,10 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { login, setupWorkspace } from "@/features/auth/services/auth-service"; import {
forgotPassword,
login,
passwordReset,
setupWorkspace,
verifyUserToken,
} from "@/features/auth/services/auth-service";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types"; import {
IForgotPassword,
ILogin,
IPasswordReset,
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
@ -76,6 +88,28 @@ export default function useAuth() {
} }
}; };
const handlePasswordReset = async (data: IPasswordReset) => {
setIsLoading(true);
try {
const res = await passwordReset(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
notifications.show({
message: "Password reset was successful",
});
} catch (err) {
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
const handleIsAuthenticated = async () => { const handleIsAuthenticated = async () => {
if (!authToken) { if (!authToken) {
return false; return false;
@ -105,11 +139,50 @@ export default function useAuth() {
navigate(APP_ROUTE.AUTH.LOGIN); navigate(APP_ROUTE.AUTH.LOGIN);
}; };
const handleForgotPassword = async (data: IForgotPassword) => {
setIsLoading(true);
try {
await forgotPassword(data);
setIsLoading(false);
return true;
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
return false;
}
};
const handleVerifyUserToken = async (data: IVerifyUserToken) => {
setIsLoading(true);
try {
await verifyUserToken(data);
setIsLoading(false);
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
return { return {
signIn: handleSignIn, signIn: handleSignIn,
invitationSignup: handleInvitationSignUp, invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace, setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated, isAuthenticated: handleIsAuthenticated,
forgotPassword: handleForgotPassword,
passwordReset: handlePasswordReset,
verifyUserToken: handleVerifyUserToken,
logout: handleLogout, logout: handleLogout,
hasTokens, hasTokens,
isLoading, isLoading,

View File

@ -0,0 +1,14 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { verifyUserToken } from "../services/auth-service";
import { IVerifyUserToken } from "../types/auth.types";
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["verify-token", verify],
queryFn: () => verifyUserToken(verify),
enabled: !!verify.token,
staleTime: 0,
});
}

View File

@ -1,10 +1,13 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
IChangePassword, IChangePassword,
IForgotPassword,
ILogin, ILogin,
IPasswordReset,
IRegister, IRegister,
ISetupWorkspace, ISetupWorkspace,
ITokenResponse, ITokenResponse,
IVerifyUserToken,
} from "@/features/auth/types/auth.types"; } from "@/features/auth/types/auth.types";
export async function login(data: ILogin): Promise<ITokenResponse> { export async function login(data: ILogin): Promise<ITokenResponse> {
@ -19,15 +22,30 @@ export async function register(data: IRegister): Promise<ITokenResponse> {
}*/ }*/
export async function changePassword( export async function changePassword(
data: IChangePassword, data: IChangePassword
): Promise<IChangePassword> { ): Promise<IChangePassword> {
const req = await api.post<IChangePassword>("/auth/change-password", data); const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data; return req.data;
} }
export async function setupWorkspace( export async function setupWorkspace(
data: ISetupWorkspace, data: ISetupWorkspace
): Promise<ITokenResponse> { ): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/setup", data); const req = await api.post<ITokenResponse>("/auth/setup", data);
return req.data; return req.data;
} }
export async function forgotPassword(data: IForgotPassword): Promise<void> {
await api.post<any>("/auth/forgot-password", data);
}
export async function passwordReset(
data: IPasswordReset
): Promise<ITokenResponse> {
const req = await api.post<any>("/auth/password-reset", data);
return req.data;
}
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
return api.post<any>("/auth/verify-token", data);
}

View File

@ -29,3 +29,17 @@ export interface IChangePassword {
oldPassword: string; oldPassword: string;
newPassword: string; newPassword: string;
} }
export interface IForgotPassword {
email: string;
}
export interface IPasswordReset {
token?: string;
newPassword: string;
}
export interface IVerifyUserToken {
token: string;
type: string;
}

View File

@ -1,6 +1,8 @@
import { handleAttachmentUpload } from "@docmost/editor-ext"; import { handleAttachmentUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
export const uploadAttachmentAction = handleAttachmentUpload({ export const uploadAttachmentAction = handleAttachmentUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
@ -18,10 +20,10 @@ export const uploadAttachmentAction = handleAttachmentUpload({
if (file.type.includes("image/") || file.type.includes("video/")) { if (file.type.includes("image/") || file.type.includes("video/")) {
return false; return false;
} }
if (file.size / 1024 / 1024 > 50) { if (file.size > getFileUploadSizeLimit()) {
notifications.show({ notifications.show({
color: "red", color: "red",
message: `File exceeds the 50 MB attachment limit`, message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
}); });
return false; return false;
} }

View File

@ -49,7 +49,7 @@ export default function CodeBlockView(props: NodeViewProps) {
<Select <Select
placeholder="auto" placeholder="auto"
checkIconPosition="right" checkIconPosition="right"
data={extension.options.lowlight.listLanguages()} data={extension.options.lowlight.listLanguages().sort()}
value={languageValue} value={languageValue}
onChange={changeLanguage} onChange={changeLanguage}
searchable searchable

View File

@ -1,5 +1,5 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'; 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 { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts'; import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
@ -21,6 +21,7 @@ export default function DrawioView(props: NodeViewProps) {
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>(''); const [initialXML, setInitialXML] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
@ -87,7 +88,7 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
urlParameters={{ urlParameters={{
ui: 'kennedy', ui: computedColorScheme === 'light' ? 'kennedy' : 'dark',
spin: true, spin: true,
libraries: true, libraries: true,
saveAndExit: 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,109 @@
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) => {
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/');
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) => {
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) => {
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(), elements: excalidrawAPI?.getSceneElements(),
appState: { appState: {
exportEmbedScene: true, exportEmbedScene: true,
exportWithDarkMode: computedColorScheme == 'light' ? false : true, exportWithDarkMode: false,
}, },
files: excalidrawAPI?.getFiles(), files: excalidrawAPI?.getFiles(),
}); });
@ -147,6 +147,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
}} }}
theme={computedColorScheme}
/> />
</Suspense> </Suspense>
</div> </div>
@ -202,7 +203,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</ActionIcon> </ActionIcon>
<Text component="span" size="lg" c="dimmed"> <Text component="span" size="lg" c="dimmed">
Double-click to edit excalidraw diagram Double-click to edit Excalidraw diagram
</Text> </Text>
</div> </div>
</Card> </Card>

View File

@ -1,6 +1,8 @@
import { handleImageUpload } from "@docmost/editor-ext"; import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import { formatBytes } from "@/lib";
export const uploadImageAction = handleImageUpload({ export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
@ -18,10 +20,10 @@ export const uploadImageAction = handleImageUpload({
if (!file.type.includes("image/")) { if (!file.type.includes("image/")) {
return false; return false;
} }
if (file.size / 1024 / 1024 > 50) { if (file.size > getFileUploadSizeLimit()) {
notifications.show({ notifications.show({
color: "red", color: "red",
message: `File exceeds the 50 MB attachment limit`, message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
}); });
return false; return false;
} }

View File

@ -17,10 +17,6 @@
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7)); color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8)); background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
} }
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
} }
.mathBlock { .mathBlock {
@ -33,7 +29,7 @@
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s; transition: background-color 0.2s;
margin: 0 0.1rem; margin: 0 0.1rem;
overflow-x: scroll; overflow-x: auto;
.textInput { .textInput {
width: 400px; width: 400px;
@ -52,10 +48,4 @@
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7)); color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8)); background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
} }
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
} }

View File

@ -16,7 +16,8 @@ import {
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4 IconMenu4,
IconCalendar,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@ -28,6 +29,16 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
import IconExcalidraw from "@/components/icons/icon-excalidraw"; import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid"; import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio"; import IconDrawio from "@/components/icons/icon-drawio";
import {
AirtableIcon,
FigmaIcon,
FramerIcon,
GoogleDriveIcon,
LoomIcon,
MiroIcon,
TypeformIcon,
VimeoIcon, YoutubeIcon
} from "@/components/icons";
const CommandGroups: SlashMenuGroupedItemsType = { const CommandGroups: SlashMenuGroupedItemsType = {
basic: [ basic: [
@ -330,6 +341,107 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setExcalidraw().run(), editor.chain().focus().deleteRange(range).setExcalidraw().run(),
}, },
{
title: "Date",
description: "Insert current date",
searchTerms: ["date", "today"],
icon: IconCalendar,
command: ({ editor, range }: CommandProps) => {
const currentDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(currentDate)
.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();
},
},
], ],
}; };
@ -341,7 +453,7 @@ export const getSuggestionItems = ({
const search = query.toLowerCase(); const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {}; const filteredGroups: SlashMenuGroupedItemsType = {};
const fuzzyMatch = (query, target) => { const fuzzyMatch = (query: string, target: string) => {
let queryIndex = 0; let queryIndex = 0;
target = target.toLowerCase(); target = target.toLowerCase();
for (let char of target) { for (let char of target) {

View File

@ -1,6 +1,8 @@
import { handleVideoUpload } from "@docmost/editor-ext"; import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
export const uploadVideoAction = handleVideoUpload({ export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
@ -19,13 +21,14 @@ export const uploadVideoAction = handleVideoUpload({
return false; return false;
} }
if (file.size / 1024 / 1024 > 50) { if (file.size > getFileUploadSizeLimit()) {
notifications.show({ notifications.show({
color: "red", color: "red",
message: `File exceeds the 50 MB attachment limit`, message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
}); });
return false; return false;
} }
return true; return true;
}, },
}); });

View File

@ -35,6 +35,7 @@ import {
CustomCodeBlock, CustomCodeBlock,
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@ -53,10 +54,28 @@ import AttachmentView from "@/features/editor/components/attachment/attachment-v
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view"; import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; 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 plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import elixir from "highlight.js/lib/languages/elixir";
import erlang from "highlight.js/lib/languages/erlang";
import dockerfile from "highlight.js/lib/languages/dockerfile";
import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
lowlight.register("powershell", powershell);
lowlight.register("powershell", powershell);
lowlight.register("erlang", erlang);
lowlight.register("elixir", elixir);
lowlight.register("dockerfile", dockerfile);
lowlight.register("clojure", clojure);
lowlight.register("fortran", fortran);
lowlight.register("haskell", haskell);
lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
@ -132,6 +151,7 @@ export const mainExtensions = [
DetailsSummary, DetailsSummary,
DetailsContent, DetailsContent,
Youtube.configure({ Youtube.configure({
addPasteHandler: false,
controls: true, controls: true,
nocookie: true, nocookie: true,
}), }),
@ -162,6 +182,9 @@ export const mainExtensions = [
Excalidraw.configure({ Excalidraw.configure({
view: ExcalidrawView, view: ExcalidrawView,
}), }),
Embed.configure({
view: EmbedView,
})
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -11,7 +11,6 @@ import {
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { import {
usePageQuery,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query"; } from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@ -21,7 +20,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history"; import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@ -39,14 +38,15 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500);
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const titleEditor = useEditor({ const titleEditor = useEditor({
extensions: [ extensions: [
@ -74,6 +74,7 @@ export function TitleEditor({
onUpdate({ editor }) { onUpdate({ editor }) {
const currentTitle = editor.getText(); const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle); setDebouncedTitleState(currentTitle);
setActivePageId(pageId);
}, },
editable: editable, editable: editable,
content: title, content: title,
@ -85,7 +86,7 @@ export function TitleEditor({
}, [title]); }, [title]);
useEffect(() => { useEffect(() => {
if (debouncedTitle !== null) { if (debouncedTitle !== null && activePageId === pageId) {
updatePageMutation.mutate({ updatePageMutation.mutate({
pageId: pageId, pageId: pageId,
title: debouncedTitle, title: debouncedTitle,

View File

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

View File

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

View File

@ -1,69 +1,72 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import {Table, Group, Text, Anchor} from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import {useGetGroupsQuery} from "@/features/group/queries/group-query";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
export default function GroupList() { export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery(); const {data, isLoading} = useGetGroupsQuery();
return ( return (
<> <>
{data && ( {data && (
<Table highlightOnHover verticalSpacing="sm"> <Table.ScrollContainer minWidth={400}>
<Table.Thead> <Table highlightOnHover verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Group</Table.Th> <Table.Tr>
<Table.Th>Members</Table.Th> <Table.Th>Group</Table.Th>
</Table.Tr> <Table.Th>Members</Table.Th>
</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.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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 { import {
useGroupMembersQuery, useGroupMembersQuery,
useRemoveGroupMemberMutation, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import {useParams} from "react-router-dom";
import React from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import {IconDots} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import {modals} from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
export default function GroupMembersList() { export default function GroupMembersList() {
const { groupId } = useParams(); const {groupId} = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId); const {data, isLoading} = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
const onRemove = async (userId: string) => { const onRemove = async (userId: string) => {
const memberToRemove = { const memberToRemove = {
@ -34,72 +34,74 @@ export default function GroupMembersList() {
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Delete", cancel: "Cancel" }, labels: {confirm: "Delete", cancel: "Cancel"},
confirmProps: { color: "red" }, confirmProps: {color: "red"},
onConfirm: () => onRemove(userId), onConfirm: () => onRemove(userId),
}); });
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>User</Table.Th> <Table.Tr>
<Table.Th>Status</Table.Th> <Table.Th>User</Table.Th>
<Table.Th></Table.Th> <Table.Th>Status</Table.Th>
</Table.Tr> <Table.Th></Table.Th>
</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.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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(() => { useEffect(() => {
if (groups) { if (groups) {
const groupsData = groups?.items.map((group: IGroup) => { const groupsData = groups?.items
return { .filter((group: IGroup) => group.name.toLowerCase() !== 'everyone')
value: group.id, .map((group: IGroup) => {
label: group.name, 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( const filteredGroupData = groupsData.filter(
(user) => (group) =>
!data.find((existingUser) => existingUser.value === user.value), !data.find((existingGroup) => existingGroup.value === group.value),
); );
// Combine existing data with new search data // Combine existing data with new search data
setData((prevData) => [...prevData, ...filteredGroupData]); setData((prevData) => [... prevData, ... filteredGroupData]);
} }
}, [groups]); }, [groups]);

View File

@ -3,8 +3,8 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
UseQueryResult, UseQueryResult,
} from "@tanstack/react-query"; } from '@tanstack/react-query';
import { IGroup } from "@/features/group/types/group.types"; import { IGroup } from '@/features/group/types/group.types';
import { import {
addGroupMember, addGroupMember,
createGroup, createGroup,
@ -14,43 +14,41 @@ import {
getGroups, getGroups,
removeGroupMember, removeGroupMember,
updateGroup, updateGroup,
} from "@/features/group/services/group-service"; } from '@/features/group/services/group-service';
import { notifications } from "@mantine/notifications"; import { notifications } from '@mantine/notifications';
import { QueryParams } from "@/lib/types.ts"; import { QueryParams } from '@/lib/types.ts';
export function useGetGroupsQuery( export function useGetGroupsQuery(
params?: QueryParams, params?: QueryParams
): UseQueryResult<any, Error> { ): UseQueryResult<any, Error> {
return useQuery({ return useQuery({
queryKey: ["groups", params], queryKey: ['groups', params],
queryFn: () => getGroups(params), queryFn: () => getGroups(params),
}); });
} }
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> { export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({ return useQuery({
queryKey: ["groups", groupId], queryKey: ['group', groupId],
queryFn: () => getGroupById(groupId), queryFn: () => getGroupById(groupId),
enabled: !!groupId, enabled: !!groupId,
}); });
} }
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ["groupMembers", groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useCreateGroupMutation() { export function useCreateGroupMutation() {
const queryClient = useQueryClient();
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data), mutationFn: (data) => createGroup(data),
onSuccess: () => { onSuccess: () => {
notifications.show({ message: "Group created successfully" }); queryClient.invalidateQueries({
queryKey: ['groups'],
});
notifications.show({ message: 'Group created successfully' });
}, },
onError: () => { onError: () => {
notifications.show({ message: "Failed to create group", color: "red" }); notifications.show({ message: 'Failed to create group', color: 'red' });
}, },
}); });
} }
@ -61,14 +59,14 @@ export function useUpdateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({ return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data), mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" }); notifications.show({ message: 'Group updated successfully' });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["group", variables.groupId], queryKey: ['group', variables.groupId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
@ -79,36 +77,46 @@ export function useDeleteGroupMutation() {
return useMutation({ return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }), mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" }); notifications.show({ message: 'Group deleted successfully' });
const groups = queryClient.getQueryData(["groups"]) as any; const groups = queryClient.getQueryData(['groups']) as any;
if (groups) { if (groups) {
groups.items?.filter((group: IGroup) => group.id !== variables); groups.items = groups.items?.filter(
queryClient.setQueryData(["groups"], groups); (group: IGroup) => group.id !== variables
);
queryClient.setQueryData(['groups'], groups);
} }
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
}
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({ return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data), mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" }); notifications.show({ message: 'Added successfully' });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ['groupMembers', variables.groupId],
}); });
}, },
onError: () => { onError: () => {
notifications.show({ notifications.show({
message: "Failed to add group members", message: 'Failed to add group members',
color: "red", color: 'red',
}); });
}, },
}); });
@ -127,14 +135,14 @@ export function useRemoveGroupMemberMutation() {
>({ >({
mutationFn: (data) => removeGroupMember(data), mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" }); notifications.show({ message: 'Removed successfully' });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ['groupMembers', variables.groupId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }

View File

@ -64,7 +64,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, fileName); saveAs(req.data, decodeURIComponent(fileName));
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@ -81,15 +81,18 @@ export async function importPage(file: File, spaceId: string) {
return req.data; return req.data;
} }
export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise<IAttachment> { export async function uploadFile(
file: File,
pageId: string,
attachmentId?: string,
): Promise<IAttachment> {
const formData = new FormData(); const formData = new FormData();
if(attachmentId){ if (attachmentId) {
formData.append("attachmentId", attachmentId); formData.append("attachmentId", attachmentId);
} }
formData.append("pageId", pageId); formData.append("pageId", pageId);
formData.append("file", file); formData.append("file", file);
const req = await api.post<IAttachment>("/files/upload", formData, { const req = await api.post<IAttachment>("/files/upload", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",

View File

@ -207,7 +207,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
overscanCount={10} overscanCount={10}
dndRootElement={rootElement.current} dndRootElement={rootElement.current}
onToggle={() => { onToggle={() => {
setOpenTreeNodes(treeApiRef.current.openState); setOpenTreeNodes(treeApiRef.current?.openState);
}} }}
initialOpenState={openTreeNodes} initialOpenState={openTreeNodes}
> >

View File

@ -21,6 +21,7 @@ import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export function useTreeMutation<T>(spaceId: string) { export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom); const [data, setData] = useAtom(treeDataAtom);
@ -31,6 +32,8 @@ export function useTreeMutation<T>(spaceId: string) {
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { pageSlug } = useParams();
const emit = useQueryEmit();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => { const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = { const payload: { spaceId: string; parentPageId?: string } = {
@ -69,6 +72,17 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data }); tree.create({ parentId, index, data });
setData(tree.data); setData(tree.data);
setTimeout(() => {
emit({
operation: "addTreeNode",
payload: {
parentId,
index,
data
}
});
}, 50);
const pageUrl = buildPageUrl( const pageUrl = buildPageUrl(
spaceSlug, spaceSlug,
createdPage.slugId, createdPage.slugId,
@ -100,7 +114,7 @@ export function useTreeMutation<T>(spaceId: string) {
: tree.data; : tree.data;
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array // 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 // this makes it possible to correctly sort children of a parent node that is not the root
const afterPosition = const afterPosition =
@ -147,11 +161,13 @@ export function useTreeMutation<T>(spaceId: string) {
if (childrenCount === 0) { if (childrenCount === 0) {
tree.update({ tree.update({
id: previousParent.id, id: previousParent.id,
changes: { ...previousParent.data, hasChildren: false } as any, changes: { ... previousParent.data, hasChildren: false } as any,
}); });
} }
} }
//console.log()
setData(tree.data); setData(tree.data);
const payload: IMovePage = { const payload: IMovePage = {
@ -162,6 +178,13 @@ export function useTreeMutation<T>(spaceId: string) {
try { try {
movePageMutation.mutateAsync(payload); movePageMutation.mutateAsync(payload);
setTimeout(() => {
emit({
operation: "moveTreeNode",
payload: { id: draggedNodeId, parentId: args.parentId, index: args.index, position: newPosition },
});
}, 50);
} catch (error) { } catch (error) {
console.error("Error moving page:", error); console.error("Error moving page:", error);
} }
@ -182,12 +205,26 @@ export function useTreeMutation<T>(spaceId: string) {
try { try {
await deletePageMutation.mutateAsync(args.ids[0]); await deletePageMutation.mutateAsync(args.ids[0]);
if (tree.find(args.ids[0])) { const node = tree.find(args.ids[0]);
tree.drop({ id: args.ids[0] }); if (!node) {
setData(tree.data); 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",
payload: { node: node.data }
});
}, 50);
} catch (error) { } catch (error) {
console.error("Failed to delete page:", error); console.error("Failed to delete page:", error);
} }

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[] { export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {}; const nodeMap = {};
let result: SpaceTreeNode[] = []; let result: SpaceTreeNode[] = [];

View File

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

View File

@ -0,0 +1,86 @@
import { Button, Divider, Group, Modal, Text, TextInput } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useDeleteSpaceMutation } from '../queries/space-query';
import { useField } from '@mantine/form';
import { ISpace } from '../types/space.types';
import { useNavigate } from 'react-router-dom';
import APP_ROUTE from '@/lib/app-route';
interface DeleteSpaceModalProps {
space: ISpace;
}
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const [opened, { open, close }] = useDisclosure(false);
const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate();
const confirmNameField = useField({
initialValue: '',
validateOnChange: true,
validate: (value) =>
value.trim().toLowerCase() === space.name.trim().toLocaleLowerCase()
? null
: 'Names do not match',
});
const handleDelete = async () => {
if (
confirmNameField.getValue().trim().toLowerCase() !==
space.name.trim().toLowerCase()
) {
confirmNameField.validate();
return;
}
try {
// pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME);
} catch (error) {
console.error('Failed to delete space', error);
}
};
return (
<>
<Button onClick={open} variant="light" color="red">
Delete
</Button>
<Modal
opened={opened}
onClose={close}
title="Are you sure you want to delete this space?"
>
<Divider size="xs" mb="xs" />
<Text>
All pages, comments, attachments and permissions in this space will be
deleted irreversibly.
</Text>
<Text mt="sm">
Type the space name{' '}
<Text span fw={500}>
'{space.name}'
</Text>{' '}
to confirm your action.
</Text>
<TextInput
{...confirmNameField.getInputProps()}
variant="filled"
placeholder="Confirm space name"
py="sm"
data-autofocus
/>
<Group justify="flex-end" mt="md">
<Button onClick={close} variant="default">
Cancel
</Button>
<Button onClick={handleDelete} color="red">
Confirm
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -8,6 +8,14 @@ import { ISpace } from "@/features/space/types/space.types.ts";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
description: z.string().max(250), description: z.string().max(250),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@ -23,12 +31,14 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
initialValues: { initialValues: {
name: space?.name, name: space?.name,
description: space?.description || "", description: space?.description || "",
slug: space.slug,
}, },
}); });
const handleSubmit = async (values: { const handleSubmit = async (values: {
name?: string; name?: string;
description?: string; description?: string;
slug?: string;
}) => { }) => {
const spaceData: Partial<ISpace> = { const spaceData: Partial<ISpace> = {
spaceId: space.id, spaceId: space.id,
@ -40,6 +50,10 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
spaceData.description = values.description; spaceData.description = values.description;
} }
if (form.isDirty("slug")) {
spaceData.slug = values.slug;
}
await updateSpaceMutation.mutateAsync(spaceData); await updateSpaceMutation.mutateAsync(spaceData);
form.resetDirty(); form.resetDirty();
}; };
@ -62,8 +76,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
id="slug" id="slug"
label="Slug" label="Slug"
variant="filled" variant="filled"
readOnly readOnly={readOnly}
value={space.slug} {...form.getInputProps("slug")}
/> />
<Textarea <Textarea

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

View File

@ -1,6 +0,0 @@
.spaceName {
display: block;
width: 100%;
padding: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}

View File

@ -1,19 +0,0 @@
import { UnstyledButton, Group, Text } from "@mantine/core";
import classes from "./space-name.module.css";
interface SpaceNameProps {
spaceName: string;
}
export function SpaceName({ spaceName }: SpaceNameProps) {
return (
<UnstyledButton className={classes.spaceName}>
<Group>
<div style={{ flex: 1 }}>
<Text size="md" fw={500} lineClamp={1}>
{spaceName}
</Text>
</div>
</Group>
</UnstyledButton>
);
}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import { Avatar, Group, Select, SelectProps, Text } from '@mantine/core';
import { useGetSpacesQuery } from '@/features/space/queries/space-query.ts';
import { ISpace } from '../../types/space.types';
interface SpaceSelectProps {
onChange: (value: string) => void;
value?: string;
label?: string;
}
const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
<Group gap="sm">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<div>
<Text size="sm">{option.label}</Text>
</div>
</Group>
);
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const [searchValue, setSearchValue] = useState('');
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: spaces, isLoading } = useGetSpacesQuery({
query: debouncedQuery,
limit: 50,
});
const [data, setData] = useState([]);
useEffect(() => {
if (spaces) {
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
return {
label: space.name,
value: space.slug,
};
});
const filteredSpaceData = spaceData.filter(
(user) =>
!data.find((existingUser) => existingUser.value === user.value)
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
}, [spaces]);
return (
<Select
data={data}
renderOption={renderSelectOption}
maxDropdownHeight={300}
//label={label || 'Select space'}
placeholder="Search for spaces"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No space found"
limit={50}
checkIconPosition="right"
comboboxProps={{ width: 300, withinPortal: false }}
dropdownOpened
/>
);
}

View File

@ -5,8 +5,8 @@ import {
Text, Text,
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from '@mantine/core';
import { spotlight } from "@mantine/spotlight"; import { spotlight } from '@mantine/spotlight';
import { import {
IconArrowDown, IconArrowDown,
IconDots, IconDots,
@ -14,27 +14,27 @@ import {
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import classes from "./space-sidebar.module.css"; import classes from './space-sidebar.module.css';
import React, { useMemo } from "react"; import React, { useMemo } from 'react';
import { useAtom } from "jotai"; import { useAtom } from 'jotai';
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx"; import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from "clsx"; import clsx from 'clsx';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx"; import { getSpaceUrl } from '@/lib/config.ts';
import { getSpaceUrl } from "@/lib/config.ts"; import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts"; } from '@/features/space/permissions/permissions.type.ts';
import PageImportModal from "@/features/page/components/page-import-modal.tsx"; import PageImportModal from '@/features/page/components/page-import-modal.tsx';
import { SwitchSpace } from './switch-space';
export function SpaceSidebar() { export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
@ -52,7 +52,7 @@ export function SpaceSidebar() {
} }
function handleCreatePage() { function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 }); tree?.create({ parentId: null, type: 'internal', index: 0 });
} }
return ( return (
@ -61,11 +61,12 @@ export function SpaceSidebar() {
<div <div
className={classes.section} className={classes.section}
style={{ style={{
border: "none", border: 'none',
marginBottom: "0", marginTop: 2,
marginBottom: 3,
}} }}
> >
<SpaceName spaceName={space?.name} /> <SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
</div> </div>
<div className={classes.section}> <div className={classes.section}>
@ -77,7 +78,7 @@ export function SpaceSidebar() {
classes.menu, classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug) location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton ? classes.activeButton
: "", : ''
)} )}
> >
<div className={classes.menuItemInner}> <div className={classes.menuItemInner}>
@ -114,7 +115,7 @@ export function SpaceSidebar() {
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
) && ( ) && (
<UnstyledButton <UnstyledButton
className={classes.menu} className={classes.menu}
@ -141,7 +142,7 @@ export function SpaceSidebar() {
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
) && ( ) && (
<Group gap="xs"> <Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} /> <SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
@ -165,7 +166,7 @@ export function SpaceSidebar() {
spaceId={space.id} spaceId={space.id}
readOnly={spaceAbility.cannot( readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
)} )}
/> />
</div> </div>

View File

@ -0,0 +1,5 @@
.spaceName {
width: 100%;
padding: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-0));
}

View File

@ -0,0 +1,62 @@
import classes from './switch-space.module.css';
import { useNavigate } from 'react-router-dom';
import { SpaceSelect } from './space-select';
import { getSpaceUrl } from '@/lib/config';
import { Avatar, Button, Popover, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
interface SwitchSpaceProps {
spaceName: string;
spaceSlug: string;
}
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
const [opened, { close, open, toggle }] = useDisclosure(false);
const navigate = useNavigate();
const handleSelect = (value: string) => {
if (value) {
navigate(getSpaceUrl(value));
close();
}
};
return (
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
opened={opened}
>
<Popover.Target>
<Button
variant="subtle"
fullWidth
justify="space-between"
rightSection={<IconChevronDown size={18} />}
color="gray"
onClick={toggle}
>
<Avatar
size={20}
color="initials"
variant="filled"
name={spaceName}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName}
</Text>
</Button>
</Popover.Target>
<Popover.Dropdown>
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={handleSelect}
/>
</Popover.Dropdown>
</Popover>
);
}

View File

@ -1,7 +1,8 @@
import React from "react"; import React from 'react';
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx"; import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Text } from "@mantine/core"; import { Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@ -18,6 +19,23 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
Details Details
</Text> </Text>
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Delete space</Text>
<Text size="sm" c="dimmed">
Delete this space with all its pages and data.
</Text>
</div>
<DeleteSpaceModal space={space} />
</Group>
</>
)}
</div> </div>
)} )}
</> </>

View File

@ -1,13 +1,13 @@
import { Table, Group, Text, Avatar } from "@mantine/core"; import {Table, Group, Text, Avatar} from "@mantine/core";
import React, { useState } from "react"; import React, {useState} from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import {useGetSpacesQuery} from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import {useDisclosure} from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import {formatMemberCount} from "@/lib";
export default function SpaceList() { export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery(); const {data, isLoading} = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false); const [opened, {open, close}] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => { const handleClick = (spaceId: string) => {
@ -18,44 +18,48 @@ export default function SpaceList() {
return ( return (
<> <>
{data && ( {data && (
<Table highlightOnHover verticalSpacing="sm"> <Table.ScrollContainer minWidth={400}>
<Table.Thead> <Table highlightOnHover verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Space</Table.Th> <Table.Tr>
<Table.Th>Members</Table.Th> <Table.Th>Space</Table.Th>
</Table.Tr> <Table.Th>Members</Table.Th>
</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.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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 && ( {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 React from "react";
import { IconDots } from "@tabler/icons-react"; import {IconDots} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import {modals} from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import {CustomAvatar} from "@/components/ui/custom-avatar.tsx";
import { import {
useChangeSpaceMemberRoleMutation, useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation, useRemoveSpaceMemberMutation,
useSpaceMembersQuery, useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts"; } from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import {IconGroupCircle} from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts"; import {IRemoveSpaceMember} from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx"; import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { import {
getSpaceRoleLabel, getSpaceRoleLabel,
spaceRoleData, spaceRoleData,
} from "@/features/space/types/space-role-data.ts"; } from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib"; import {formatMemberCount} from "@/lib";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
interface SpaceMembersProps { interface SpaceMembersProps {
spaceId: string; spaceId: string;
readOnly?: boolean; readOnly?: boolean;
} }
export default function SpaceMembersList({ export default function SpaceMembersList({
spaceId, spaceId,
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId); const {data, isLoading} = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation(); const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation(); const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -85,99 +87,101 @@ export default function SpaceMembersList({
</Text> </Text>
), ),
centered: true, centered: true,
labels: { confirm: "Remove", cancel: "Cancel" }, labels: {confirm: "Remove", cancel: "Cancel"},
confirmProps: { color: "red" }, confirmProps: {color: "red"},
onConfirm: () => onRemove(memberId, type), onConfirm: () => onRemove(memberId, type),
}); });
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing={8}> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing={8}>
<Table.Tr> <Table.Thead>
<Table.Th>Member</Table.Th> <Table.Tr>
<Table.Th>Role</Table.Th> <Table.Th>Member</Table.Th>
<Table.Th></Table.Th> <Table.Th>Role</Table.Th>
</Table.Tr> <Table.Th></Table.Th>
</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.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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

@ -3,14 +3,14 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
UseQueryResult, UseQueryResult,
} from "@tanstack/react-query"; } from '@tanstack/react-query';
import { import {
IAddSpaceMember, IAddSpaceMember,
IChangeSpaceMemberRole, IChangeSpaceMemberRole,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
ISpaceMember, ISpaceMember,
} from "@/features/space/types/space.types"; } from '@/features/space/types/space.types';
import { import {
addSpaceMember, addSpaceMember,
changeMemberRole, changeMemberRole,
@ -20,23 +20,23 @@ import {
removeSpaceMember, removeSpaceMember,
createSpace, createSpace,
updateSpace, updateSpace,
} from "@/features/space/services/space-service.ts"; deleteSpace,
import { notifications } from "@mantine/notifications"; } from '@/features/space/services/space-service.ts';
import { IPagination } from "@/lib/types.ts"; import { notifications } from '@mantine/notifications';
import { IPagination, QueryParams } from '@/lib/types.ts';
export function useGetSpacesQuery(): UseQueryResult< export function useGetSpacesQuery(
IPagination<ISpace>, params?: QueryParams
Error ): UseQueryResult<IPagination<ISpace>, Error> {
> {
return useQuery({ return useQuery({
queryKey: ["spaces"], queryKey: ['spaces', params],
queryFn: () => getSpaces(), queryFn: () => getSpaces(params),
}); });
} }
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> { export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ["spaces", spaceId], queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -50,22 +50,22 @@ export function useCreateSpaceMutation() {
mutationFn: (data) => createSpace(data), mutationFn: (data) => createSpace(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["spaces"], queryKey: ['spaces'],
}); });
notifications.show({ message: "Space created successfully" }); notifications.show({ message: 'Space created successfully' });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
export function useGetSpaceBySlugQuery( export function useGetSpaceBySlugQuery(
spaceId: string, spaceId: string
): UseQueryResult<ISpace, Error> { ): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ["spaces", spaceId], queryKey: ['space', spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -78,34 +78,64 @@ export function useUpdateSpaceMutation() {
return useMutation<ISpace, Error, Partial<ISpace>>({ return useMutation<ISpace, Error, Partial<ISpace>>({
mutationFn: (data) => updateSpace(data), mutationFn: (data) => updateSpace(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Space updated successfully" }); notifications.show({ message: 'Space updated successfully' });
const space = queryClient.getQueryData([ const space = queryClient.getQueryData([
"space", 'space',
variables.spaceId, variables.spaceId,
]) as ISpace; ]) as ISpace;
if (space) { if (space) {
const updatedSpace = { ...space, ...data }; const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace); queryClient.setQueryData(['space', variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace); queryClient.setQueryData(['space', data.slug], updatedSpace);
} }
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["spaces"], queryKey: ['spaces'],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
},
});
}
export function useDeleteSpaceMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
onSuccess: (data, variables) => {
notifications.show({ message: 'Space deleted successfully' });
if (variables.slug) {
queryClient.removeQueries({
queryKey: ['space', variables.slug],
exact: true,
});
}
const spaces = queryClient.getQueryData(['spaces']) as any;
if (spaces) {
spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id
);
queryClient.setQueryData(['spaces'], spaces);
}
},
onError: (error) => {
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
export function useSpaceMembersQuery( export function useSpaceMembersQuery(
spaceId: string, spaceId: string
): UseQueryResult<IPagination<ISpaceMember>, Error> { ): UseQueryResult<IPagination<ISpaceMember>, Error> {
return useQuery({ return useQuery({
queryKey: ["spaceMembers", spaceId], queryKey: ['spaceMembers', spaceId],
queryFn: () => getSpaceMembers(spaceId), queryFn: () => getSpaceMembers(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
}); });
@ -117,14 +147,14 @@ export function useAddSpaceMemberMutation() {
return useMutation<void, Error, IAddSpaceMember>({ return useMutation<void, Error, IAddSpaceMember>({
mutationFn: (data) => addSpaceMember(data), mutationFn: (data) => addSpaceMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Members added successfully" }); notifications.show({ message: 'Members added successfully' });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId], queryKey: ['spaceMembers', variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
@ -135,14 +165,14 @@ export function useRemoveSpaceMemberMutation() {
return useMutation<void, Error, IRemoveSpaceMember>({ return useMutation<void, Error, IRemoveSpaceMember>({
mutationFn: (data) => removeSpaceMember(data), mutationFn: (data) => removeSpaceMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" }); notifications.show({ message: 'Removed successfully' });
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId], queryKey: ['spaceMembers', variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }
@ -153,15 +183,15 @@ export function useChangeSpaceMemberRoleMutation() {
return useMutation<void, Error, IChangeSpaceMemberRole>({ return useMutation<void, Error, IChangeSpaceMemberRole>({
mutationFn: (data) => changeMemberRole(data), mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Member role updated successfully" }); notifications.show({ message: 'Member role updated successfully' });
// due to pagination levels, change in cache instead // due to pagination levels, change in cache instead
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId], queryKey: ['spaceMembers', variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: "red" }); notifications.show({ message: errorMessage, color: 'red' });
}, },
}); });
} }

View File

@ -1,52 +1,56 @@
import api from "@/lib/api-client"; import api from '@/lib/api-client';
import { import {
IAddSpaceMember, IAddSpaceMember,
IChangeSpaceMemberRole, IChangeSpaceMemberRole,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
} from "@/features/space/types/space.types"; } from "@/features/space/types/space.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
export async function getSpaces(): Promise<IPagination<ISpace>> { export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces"); const req = await api.post("/spaces", params);
return req.data; return req.data;
} }
export async function getSpaceById(spaceId: string): Promise<ISpace> { 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; return req.data;
} }
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> { 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; return req.data;
} }
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> { 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; return req.data;
} }
export async function deleteSpace(spaceId: string): Promise<void> {
await api.post<void>('/spaces/delete', { spaceId });
}
export async function getSpaceMembers( export async function getSpaceMembers(
spaceId: string, spaceId: string
): Promise<IPagination<IUser>> { ): Promise<IPagination<IUser>> {
const req = await api.post<any>("/spaces/members", { spaceId }); const req = await api.post<any>('/spaces/members', { spaceId });
return req.data; return req.data;
} }
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> { 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( export async function removeSpaceMember(
data: IRemoveSpaceMember, data: IRemoveSpaceMember
): Promise<void> { ): Promise<void> {
await api.post("/spaces/members/remove", data); await api.post('/spaces/members/remove', data);
} }
export async function changeMemberRole( export async function changeMemberRole(
data: IChangeSpaceMemberRole, data: IChangeSpaceMemberRole
): Promise<void> { ): Promise<void> {
await api.post("/spaces/members/change-role", data); await api.post('/spaces/members/change-role', data);
} }

View File

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

View File

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

View File

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

View File

@ -1,62 +1,64 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core"; import {Group, Table, Avatar, Text, Alert} from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts"; import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts";
import React from "react"; 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 InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react"; import {IconInfoCircle} from "@tabler/icons-react";
import { formattedDate } from "@/lib/time.ts"; import {formattedDate, timeAgo} from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceInvitesTable() { export default function WorkspaceInvitesTable() {
const { data, isLoading } = useWorkspaceInvitationsQuery({ const {data, isLoading} = useWorkspaceInvitationsQuery({
limit: 100, limit: 100,
}); });
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
return ( 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. Invited members who are yet to accept their invitation will appear here.
</Alert> </Alert>
{data && ( {data && (
<> <>
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>Email</Table.Th> <Table.Tr>
<Table.Th>Role</Table.Th> <Table.Th>Email</Table.Th>
<Table.Th>Date</Table.Th> <Table.Th>Role</Table.Th>
</Table.Tr> <Table.Th>Date</Table.Th>
</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.Tr> </Table.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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 { import {
useChangeMemberRoleMutation, useChangeMemberRoleMutation,
useWorkspaceMembersQuery, useWorkspaceMembersQuery,
} from "@/features/workspace/queries/workspace-query.ts"; } 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 React from "react";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx"; import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { import {
@ -11,14 +11,14 @@ import {
userRoleData, userRoleData,
} from "@/features/workspace/types/user-role-data.ts"; } from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts"; import {UserRole} from "@/lib/types.ts";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 }); const {data, isLoading} = useWorkspaceMembersQuery({limit: 100});
const changeMemberRoleMutation = useChangeMemberRoleMutation(); 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 ( const handleRoleChange = async (
userId: string, userId: string,
@ -40,50 +40,52 @@ export default function WorkspaceMembersTable() {
return ( return (
<> <>
{data && ( {data && (
<Table verticalSpacing="sm"> <Table.ScrollContainer minWidth={500}>
<Table.Thead> <Table verticalSpacing="sm">
<Table.Tr> <Table.Thead>
<Table.Th>User</Table.Th> <Table.Tr>
<Table.Th>Status</Table.Th> <Table.Th>User</Table.Th>
<Table.Th>Role</Table.Th> <Table.Th>Status</Table.Th>
</Table.Tr> <Table.Th>Role</Table.Th>
</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.Tr>
))} </Table.Thead>
</Table.Tbody>
</Table> <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

@ -4,6 +4,8 @@ const APP_ROUTE = {
LOGIN: "/login", LOGIN: "/login",
SIGNUP: "/signup", SIGNUP: "/signup",
SETUP: "/setup/register", SETUP: "/setup/register",
FORGOT_PASSWORD: "/forgot-password",
PASSWORD_RESET: "/password-reset",
}, },
SETTINGS: { SETTINGS: {
ACCOUNT: { ACCOUNT: {

View File

@ -1,51 +1,62 @@
import bytes from "bytes";
declare global { declare global {
interface Window { interface Window {
CONFIG?: Record<string, string>; CONFIG?: Record<string, string>;
} }
}
export function getAppName(): string{
return 'Docmost';
} }
export function getAppUrl(): string { export function getAppUrl(): string {
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL; //let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
// if (import.meta.env.DEV) { // if (import.meta.env.DEV) {
// return appUrl || "http://localhost:3000"; // return appUrl || "http://localhost:3000";
//} //}
return `${window.location.protocol}//${window.location.host}`; return `${window.location.protocol}//${window.location.host}`;
} }
export function getBackendUrl(): string { export function getBackendUrl(): string {
return getAppUrl() + '/api'; return getAppUrl() + '/api';
} }
export function getCollaborationUrl(): string { export function getCollaborationUrl(): string {
const COLLAB_PATH = '/collab'; const COLLAB_PATH = '/collab';
let url = getAppUrl(); let url = getAppUrl();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
url = process.env.APP_URL; url = process.env.APP_URL;
} }
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws'; const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`; return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
} }
export function getAvatarUrl(avatarUrl: string) { export function getAvatarUrl(avatarUrl: string) {
if (!avatarUrl) { if (!avatarUrl) {
return null; return null;
} }
if (avatarUrl?.startsWith('http')) { if (avatarUrl?.startsWith('http')) {
return avatarUrl; return avatarUrl;
} }
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl; return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
} }
export function getSpaceUrl(spaceSlug: string) { export function getSpaceUrl(spaceSlug: string) {
return '/s/' + spaceSlug; return '/s/' + spaceSlug;
} }
export function getFileUrl(src: string) { export function getFileUrl(src: string) {
return src?.startsWith('/files/') ? getBackendUrl() + src : src; return src?.startsWith('/files/') ? getBackendUrl() + src : src;
}
export function getFileUploadSizeLimit() {
const limit = window.CONFIG?.FILE_UPLOAD_SIZE_LIMIT || process?.env.FILE_UPLOAD_SIZE_LIMIT || '50mb';
return bytes(limit);
} }

View File

@ -53,11 +53,25 @@ export async function svgStringToFile(
return new File([blob], fileName, { type: "image/svg+xml" }); return new File([blob], fileName, { type: "image/svg+xml" });
} }
// Convert a string holding Base64 encoded UTF-8 data into a proper UTF-8 encoded string
// as a replacement for `atob`.
// based on: https://developer.mozilla.org/en-US/docs/Glossary/Base64
function decodeBase64(base64: string): string {
// convert string to bytes
const bytes = Uint8Array.from(atob(base64), (m) => m.codePointAt(0));
// properly decode bytes to UTF-8 encoded string
return new TextDecoder().decode(bytes);
}
export function decodeBase64ToSvgString(base64Data: string): string { export function decodeBase64ToSvgString(base64Data: string): string {
const base64Prefix = 'data:image/svg+xml;base64,'; const base64Prefix = 'data:image/svg+xml;base64,';
if (base64Data.startsWith(base64Prefix)) { if (base64Data.startsWith(base64Prefix)) {
base64Data = base64Data.replace(base64Prefix, ''); base64Data = base64Data.replace(base64Prefix, '');
} }
return atob(base64Data); return decodeBase64(base64Data);
}
export function capitalizeFirstChar(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
} }

View File

@ -0,0 +1,14 @@
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 - {getAppName()}</title>
</Helmet>
<ForgotPasswordForm />
</>
);
}

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
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();
const { data, isLoading, isError } = useVerifyUserTokenQuery({
token: searchParams.get("token"),
type: "forgot-password",
});
const resetToken = searchParams.get("token");
if (isLoading) {
return <div></div>;
}
if (isError || !resetToken) {
return (
<>
<Helmet>
<title>Password Reset - {getAppName()}</title>
</Helmet>
<Container my={40}>
<Text size="lg" ta="center">
Invalid or expired password reset link
</Text>
<Group justify="center">
<Button
component={Link}
to={APP_ROUTE.AUTH.LOGIN}
variant="subtle"
size="md"
>
Goto login page
</Button>
</Group>
</Container>
</>
);
}
return (
<>
<Helmet>
<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 { Helmet } from "react-helmet-async";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {getAppName} from "@/lib/config.ts";
export default function SetupWorkspace() { export default function SetupWorkspace() {
const { const {
@ -32,7 +33,7 @@ export default function SetupWorkspace() {
return ( return (
<> <>
<Helmet> <Helmet>
<title>Setup workspace</title> <title>Setup Workspace - {getAppName()}</title>
</Helmet> </Helmet>
<SetupWorkspaceForm /> <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 HomeTabs from "@/features/home/components/home-tabs";
import SpaceGrid from "@/features/space/components/space-grid.tsx"; 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() { export default function Home() {
return ( return (
<Container size={"800"} pt="xl"> <>
<SpaceGrid /> <Helmet>
<title>Home - {getAppName()}</title>
</Helmet>
<Container size={"800"} pt="xl">
<SpaceGrid/>
<Space h="xl" /> <Space h="xl"/>
<HomeTabs /> <HomeTabs/>
</Container> </Container>
); </>
);
} }

View File

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

View File

@ -1,13 +1,18 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members"; import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details"; import GroupDetails from "@/features/group/components/group-details";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function GroupInfo() { export default function GroupInfo() {
return ( return (
<> <>
<SettingsTitle title="Manage Group" /> <Helmet>
<GroupDetails /> <title>Manage Group - {getAppName()}</title>
<GroupMembersList /> </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 { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal"; import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
export default function Groups() { export default function Groups() {
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
return ( return (
<> <>
<Helmet>
<title>Groups - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Groups" /> <SettingsTitle title="Groups" />
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">

View File

@ -1,21 +1,26 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx"; import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.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 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() { export default function Spaces() {
const { isAdmin } = useUserRole(); const {isAdmin} = useUserRole();
return ( return (
<> <>
<SettingsTitle title="Spaces" /> <Helmet>
<title>Spaces - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Spaces"/>
<Group my="md" justify="flex-end"> <Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />} {isAdmin && <CreateSpaceModal/>}
</Group> </Group>
<SpaceList /> <SpaceList/>
</> </>
); );
} }

View File

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

View File

@ -1,11 +1,16 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; 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() { export default function WorkspaceSettings() {
return ( return (
<> <>
<SettingsTitle title="General" /> <Helmet>
<WorkspaceNameForm /> <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 SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import { useParams } from "react-router-dom"; import {useParams} from "react-router-dom";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; 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() { export default function SpaceHome() {
const { spaceSlug } = useParams(); const {spaceSlug} = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const {data: space} = useGetSpaceBySlugQuery(spaceSlug);
return ( return (
<Container size={"800"} pt="xl"> <>
{space && <SpaceHomeTabs />} <Helmet>
</Container> <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

@ -1,20 +1,34 @@
import { createTheme, MantineColorsTuple } from "@mantine/core"; import { createTheme, MantineColorsTuple } from '@mantine/core';
const blue: MantineColorsTuple = [ const blue: MantineColorsTuple = [
"#e7f3ff", '#e7f3ff',
"#d0e4ff", '#d0e4ff',
"#a1c6fa", '#a1c6fa',
"#6ea6f6", '#6ea6f6',
"#458bf2", '#458bf2',
"#2b7af1", '#2b7af1',
"#0b60d8", // '#0b60d8',
"#1b72f2", '#1b72f2',
"#0056c1", '#0056c1',
"#004aac", '#004aac',
];
const red: MantineColorsTuple = [
'#ffebeb',
'#fad7d7',
'#eeadad',
'#e3807f',
'#da5a59',
'#d54241',
'#d43535',
'#bc2727',
'#a82022',
'#93151b',
]; ];
export const theme = createTheme({ export const theme = createTheme({
colors: { colors: {
blue, blue,
red,
}, },
}); });

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.3.1", "version": "0.5.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -51,7 +51,6 @@
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.12.12", "bullmq": "^5.12.12",
"bytes": "^3.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"fix-esm": "^1.0.1", "fix-esm": "^1.0.1",
@ -81,7 +80,6 @@
"@nestjs/schematics": "^10.1.4", "@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.1", "@nestjs/testing": "^10.4.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bytes": "^3.1.4",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",

View File

@ -1,15 +1,15 @@
import { StarterKit } from '@tiptap/starter-kit'; import {StarterKit} from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align'; import {TextAlign} from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list'; import {TaskList} from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item'; import {TaskItem} from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline'; import {Underline} from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import {Superscript} from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight'; import {Highlight} from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography'; import {Typography} from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import {TextStyle} from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import {Color} from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import {Youtube} from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table'; import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header'; import TableHeader from '@tiptap/extension-table-header';
import { import {
@ -30,13 +30,14 @@ import {
Attachment, Attachment,
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, JSONContent } from '@tiptap/core'; import {generateText, JSONContent} from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import {generateHTML} from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html'; import {generateJSON} from '@tiptap/html';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@ -72,6 +73,7 @@ export const tiptapExtensions = [
CustomCodeBlock, CustomCodeBlock,
Drawio, Drawio,
Excalidraw, Excalidraw,
Embed
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {

View File

@ -0,0 +1,3 @@
export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated',
}

View File

@ -1,7 +1,9 @@
import { Extensions, getSchema } from '@tiptap/core'; import { Extensions, getSchema } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model'; import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import { Window, DOMParser as HappyDomParser } from 'happy-dom'; import { Window } from 'happy-dom';
// this function does not work as intended
// it has issues with closing tags
export function generateJSON( export function generateJSON(
html: string, html: string,
extensions: Extensions, extensions: Extensions,
@ -10,8 +12,10 @@ export function generateJSON(
const schema = getSchema(extensions); const schema = getSchema(extensions);
const window = new Window(); const window = new Window();
const dom = new HappyDomParser().parseFromString(html, 'text/html').body; const document = window.document;
document.body.innerHTML = html;
// @ts-ignore return DOMParser.fromSchema(schema)
return DOMParser.fromSchema(schema).parse(dom, options).toJSON(); .parse(document as never, options)
.toJSON();
} }

View File

@ -16,4 +16,3 @@ export const inlineFileExtensions = [
'.mp4', '.mp4',
'.mov', '.mov',
]; ];
export const MAX_FILE_SIZE = '50MB';

View File

@ -1,308 +1,310 @@
import { import {
BadRequestException, BadRequestException,
Controller, Controller,
ForbiddenException, ForbiddenException,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger, Logger,
NotFoundException, NotFoundException,
Param, Param,
Post, Post,
Req, Req,
Res, Res,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import {AttachmentService} from './services/attachment.service';
import { FastifyReply } from 'fastify'; import {FastifyReply} from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import {FileInterceptor} from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import {AuthUser} from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; import {User, Workspace} from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import {StorageService} from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
validAttachmentTypes, validAttachmentTypes,
} from './attachment.utils'; } from './attachment.utils';
import { getMimeType } from '../../common/helpers'; import {getMimeType} from '../../common/helpers';
import { import {
AttachmentType, AttachmentType,
inlineFileExtensions, inlineFileExtensions,
MAX_AVATAR_SIZE, MAX_AVATAR_SIZE,
MAX_FILE_SIZE,
} from './attachment.constants'; } from './attachment.constants';
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type'; } from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { import {
WorkspaceCaslAction, WorkspaceCaslAction,
WorkspaceCaslSubject, WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type'; } from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import {PageRepo} from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid'; import {validate as isValidUUID} from 'uuid';
import {EnvironmentService} from "../../integrations/environment/environment.service";
@Controller() @Controller()
export class AttachmentController { export class AttachmentController {
private readonly logger = new Logger(AttachmentController.name); private readonly logger = new Logger(AttachmentController.name);
constructor( constructor(
private readonly attachmentService: AttachmentService, private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory, private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
) {} private readonly environmentService: EnvironmentService,
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_FILE_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${MAX_FILE_SIZE} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) { ) {
throw new NotFoundException();
} }
const spaceAbility = await this.spaceAbility.createForUser( @UseGuards(JwtAuthGuard)
user, @HttpCode(HttpStatus.OK)
attachment.spaceId, @Post('files/upload')
); @UseInterceptors(FileInterceptor)
async uploadFile(
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { @Req() req: any,
throw new ForbiddenException(); @Res() res: FastifyReply,
} @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${attachment.fileName}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) { ) {
throw new BadRequestException('Invalid image attachment type'); const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
} }
if (attachmentType === AttachmentType.WorkspaceLogo) { @UseGuards(JwtAuthGuard)
const ability = this.workspaceAbility.createForUser(user, workspace); @Get('/files/:fileId/:fileName')
if ( async getFile(
ability.cannot( @Res() res: FastifyReply,
WorkspaceCaslAction.Manage, @AuthUser() user: User,
WorkspaceCaslSubject.Settings, @AuthWorkspace() workspace: Workspace,
) @Param('fileId') fileId: string,
) { @Param('fileName') fileName?: string,
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) { ) {
throw new BadRequestException('Invalid image attachment type'); if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
} }
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
try { let file = null;
const fileStream = await this.storageService.read(filePath); try {
res.headers({ file = await req.file({
'Content-Type': getMimeType(filePath), limits: {fileSize: maxFileSize, fields: 3, files: 1},
'Cache-Control': 'public, max-age=86400', });
}); } catch (err: any) {
return res.send(fileStream); if (err?.statusCode === 413) {
} catch (err) { throw new BadRequestException(
this.logger.error(err); `File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
throw new NotFoundException('File not found'); );
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
} }
}
} }

View File

@ -4,10 +4,11 @@ import { AttachmentController } from './attachment.controller';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module'; import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
@Module({ @Module({
imports: [StorageModule, UserModule, WorkspaceModule], imports: [StorageModule, UserModule, WorkspaceModule],
controllers: [AttachmentController], controllers: [AttachmentController],
providers: [AttachmentService], providers: [AttachmentService, AttachmentProcessor],
}) })
export class AttachmentModule {} export class AttachmentModule {}

View File

@ -0,0 +1,47 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types';
@Processor(QueueName.ATTACHEMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) {
super();
}
async process(job: Job<Space, void>): Promise<void> {
try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}

View File

@ -256,4 +256,37 @@ export class AttachmentService {
trx, trx,
); );
} }
async handleDeleteSpaceAttachments(spaceId: string) {
try {
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
if (!attachments || attachments.length === 0) {
return;
}
const failedDeletions = [];
await Promise.all(
attachments.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
failedDeletions.push(attachment.id);
this.logger.log(
`DeleteSpaceAttachments: failed to delete attachment ${attachment.id}:`,
err,
);
}
}),
);
if(failedDeletions.length === attachments.length){
throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`);
}
} catch (err) {
throw err;
}
}
} }

View File

@ -0,0 +1,3 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
}

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