mirror of
https://github.com/docmost/docmost.git
synced 2025-11-25 08:01:08 +10:00
Compare commits
34 Commits
be0d97661a
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 95159625aa | |||
| 9e0fbae1de | |||
| a52c86a180 | |||
| 31feb38def | |||
| ba32e42ece | |||
| a574d13f43 | |||
| ab70cee278 | |||
| 978fadd6b9 | |||
| b57be9c736 | |||
| d4b219d608 | |||
| 36e720920b | |||
| fa3c8a03e1 | |||
| 46d92fbabc | |||
| e17b975aaa | |||
| 038d21b438 | |||
| 078361b367 | |||
| 384f11f2b7 | |||
| e333eee08b | |||
| 7ec6a36515 | |||
| 2721ab6a29 | |||
| a2bc374f47 | |||
| eaa80a5546 | |||
| e9e668bd39 | |||
| 9390b39e35 | |||
| 2ae3816324 | |||
| e96330afbf | |||
| e56f7933f4 | |||
| b152c858b4 | |||
| e43ea66442 | |||
| f34812653e | |||
| 6a3a7721be | |||
| fb27282886 | |||
| dea9f4c063 | |||
| 0b6730c06f |
@ -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=
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />} />
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
32
apps/client/src/components/icons/airtable-icon.tsx
Normal file
32
apps/client/src/components/icons/airtable-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/client/src/components/icons/figma-icon.tsx
Normal file
23
apps/client/src/components/icons/figma-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/client/src/components/icons/framer-icon.tsx
Normal file
17
apps/client/src/components/icons/framer-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal file
24
apps/client/src/components/icons/google-drive-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/client/src/components/icons/index.ts
Normal file
10
apps/client/src/components/icons/index.ts
Normal 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";
|
||||||
|
|
||||||
19
apps/client/src/components/icons/loom-icon.tsx
Normal file
19
apps/client/src/components/icons/loom-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/client/src/components/icons/miro-icon.tsx
Normal file
18
apps/client/src/components/icons/miro-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/client/src/components/icons/typeform-icon.tsx
Normal file
18
apps/client/src/components/icons/typeform-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal file
19
apps/client/src/components/icons/vimeo-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/client/src/components/icons/youtube-icon.tsx
Normal file
19
apps/client/src/components/icons/youtube-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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" })
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
14
apps/client/src/features/auth/queries/auth-query.tsx
Normal file
14
apps/client/src/features/auth/queries/auth-query.tsx
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
111
apps/client/src/features/editor/components/embed/embed-view.tsx
Normal file
111
apps/client/src/features/editor/components/embed/embed-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/client/src/features/editor/components/embed/providers.ts
Normal file
109
apps/client/src/features/editor/components/embed/providers.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
14
apps/client/src/pages/auth/forgot-password.tsx
Normal file
14
apps/client/src/pages/auth/forgot-password.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
54
apps/client/src/pages/auth/password-reset.tsx
Normal file
54
apps/client/src/pages/auth/password-reset.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
3
apps/server/src/common/events/event.contants.ts
Normal file
3
apps/server/src/common/events/event.contants.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export enum EventName {
|
||||||
|
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,4 +16,3 @@ export const inlineFileExtensions = [
|
|||||||
'.mp4',
|
'.mp4',
|
||||||
'.mov',
|
'.mov',
|
||||||
];
|
];
|
||||||
export const MAX_FILE_SIZE = '50MB';
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/server/src/core/auth/auth.constants.ts
Normal file
3
apps/server/src/core/auth/auth.constants.ts
Normal 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
Reference in New Issue
Block a user