Compare commits

..
9 Commits
Author SHA1 Message Date
Philipinho 7213cc6050 Merge branch 'main' into personal-spaces 2026-06-20 14:25:58 +01:00
Philip Okugbe 510199cf04 feat(ee): docx word export (#2294)
* vendorize  prosemirror-docx - wip
* feat(ee): docx word export
* sync
2026-06-20 14:23:56 +01:00
Philip Okugbe aa5d52ad3e feat(editor): add /time slash command to insert current time (#2290) 2026-06-20 14:22:54 +01:00
Philipinho 23a2c891da error notification 2026-06-20 14:18:42 +01:00
Philipinho 447d5eef94 feat: on-demand only 2026-06-20 14:14:55 +01:00
Philipinho 94845eac41 pref 2026-06-20 14:03:50 +01:00
Philipinho 9d19a3269f feat(ee): personal spaces 2026-06-20 13:30:58 +01:00
Mayank-2-16andPhilipinho 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
27 changed files with 424 additions and 17 deletions
@@ -398,6 +398,8 @@
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
@@ -1085,5 +1087,11 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Allow personal spaces": "Allow personal spaces",
"Members can create their own personal space.": "Members can create their own personal space.",
"Toggle allow personal spaces": "Toggle allow personal spaces",
"Create personal space": "Create personal space",
"Personal space": "Personal space",
"{{name}}'s space": "{{name}}'s space"
}
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
IconUser,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { getSpaceUrl } from "@/lib/config.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
const [
createOpened,
{ open: openCreate, close: closeCreate },
] = useDisclosure(false);
if (!user || !workspace) {
return <></>;
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
@@ -115,6 +131,26 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
{personalSpace ? (
<Menu.Item
component={Link}
to={getSpaceUrl(personalSpace.slug)}
leftSection={<IconUser size={16} />}
>
{t("Personal space")}
</Menu.Item>
) : (
hasPersonalSpaces &&
settingEnabled && (
<Menu.Item
onClick={openCreate}
leftSection={<IconUser size={16} />}
>
{t("Create personal space")}
</Menu.Item>
)
)}
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
@@ -160,5 +196,8 @@ export default function TopMenu() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}
+1
View File
@@ -19,5 +19,6 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
title={data.title}
content={data.content}
pageId={data.pageId}
printMode
/>
</Container>
);
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
import { getSpaceUrl } from "@/lib/config.ts";
import { notifications } from "@mantine/notifications";
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
});
type FormValues = z.infer<typeof formSchema>;
type Props = {
opened: boolean;
onClose: () => void;
};
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentUser = useAtomValue(currentUserAtom);
const createMutation = useCreatePersonalSpaceMutation();
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
},
});
const handleSubmit = async (values: FormValues) => {
try {
const createdSpace = await createMutation.mutateAsync({
name: values.name,
});
onClose();
navigate(getSpaceUrl(createdSpace.slug));
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Create personal space")}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Divider size="xs" mb="md" />
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
withAsterisk
data-autofocus
label={t("Space name")}
variant="filled"
errorProps={{ role: "alert" }}
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</form>
</Modal>
);
}
@@ -0,0 +1,64 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function PersonalSpacesSetting() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow personal spaces")}</Text>
<Text size="sm" c="dimmed">
{t("Members can create their own personal space.")}
</Text>
</div>
<PersonalSpacesToggle />
</Group>
);
}
function PersonalSpacesToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.spaces?.allowPersonal === true,
);
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
allowPersonalSpaces: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasPersonalSpaces}
aria-label={t("Toggle allow personal spaces")}
/>
</Tooltip>
);
}
@@ -0,0 +1,34 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { ISpace } from "@/features/space/types/space.types";
import {
createPersonalSpace,
getPersonalSpace,
} from "@/ee/personal-space/services/personal-space-service";
export function usePersonalSpaceQuery(
enabled: boolean,
): UseQueryResult<ISpace | null, Error> {
return useQuery({
queryKey: ["personal-space"],
queryFn: () => getPersonalSpace(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useCreatePersonalSpaceMutation() {
const queryClient = useQueryClient();
return useMutation<ISpace, Error, { name?: string }>({
mutationFn: (data) => createPersonalSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
queryClient.invalidateQueries({ queryKey: ["spaces"] });
},
});
}
@@ -0,0 +1,14 @@
import api from "@/lib/api-client";
import { ISpace } from "@/features/space/types/space.types";
export async function getPersonalSpace(): Promise<ISpace | null> {
const req = await api.post<ISpace | null>("/personal-space/info", {});
return req.data;
}
export async function createPersonalSpace(data: {
name?: string;
}): Promise<ISpace> {
const req = await api.post<ISpace>("/personal-space/create", data);
return req.data;
}
@@ -21,6 +21,7 @@ import {
IconMenu4,
IconPageBreak,
IconCalendar,
IconClock,
IconAppWindow,
IconSitemap,
IconColumns3,
@@ -474,6 +475,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Time",
description: "Insert current time",
searchTerms: ["time", "now", "clock"],
icon: IconClock,
command: ({ editor, range }: CommandProps) => {
const currentTime = new Date().toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "numeric",
});
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(currentTime)
.run();
},
},
{
title: "Status",
description: "Insert inline status badge.",
@@ -767,18 +787,34 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
const translatedTitle = i18n.t(item.title);
const translatedDescription = i18n.t(item.description);
return (
fuzzyMatch(search, item.title) ||
fuzzyMatch(search, translatedTitle) ||
item.description.toLowerCase().includes(search) ||
translatedDescription.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
item.searchTerms.some(
(term: string) =>
term.includes(search) ||
i18n.t(term).toLowerCase().includes(search),
))
);
});
if (filteredItems.length) {
filteredGroups[group] = filteredItems.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
const aTitle =
a.title.toLowerCase().includes(search) ||
i18n.t(a.title).toLowerCase().includes(search)
? 0
: 1;
const bTitle =
b.title.toLowerCase().includes(search) ||
i18n.t(b.title).toLowerCase().includes(search)
? 0
: 1;
return aTitle - bTitle;
});
}
@@ -15,6 +15,7 @@ interface PageEditorProps {
title: string;
content: any;
pageId?: string;
printMode?: boolean;
/**
* When rendering inside a public share, pass the share's id (or key). Lookups
* for transclusion content then resolve against the share graph instead of
@@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({
title,
content,
pageId,
printMode = false,
shareId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({
}, []);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
"uniqueID",
...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []),
]);
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
(ext) => !excludedExtensions.has(ext.name),
);
return [
@@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({
updateDocument: false,
}),
];
}, []);
}, [printMode]);
const titleExtensions = [
Document.extend({
@@ -163,8 +163,13 @@
@media print {
.tableWrapper.tableHeaderPinned table tr:first-child {
position: static;
transform: none;
position: static !important;
top: auto !important;
transform: none !important;
}
.tableReadonlySortChevron {
display: none !important;
}
}
@@ -268,4 +273,4 @@
.prosemirror-dropcursor-inline {
display: none;
}
}
}
@@ -17,8 +17,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
"Space slug must start with a letter or number and may contain hyphens and underscores",
),
description: z.string().max(500),
});
@@ -15,8 +15,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
"Space slug must start with a letter or number and may contain hyphens and underscores",
),
});
@@ -24,6 +24,7 @@ export interface ISpace {
description: string;
logo?: string;
slug: string;
isPersonal?: boolean;
hostname: string;
creatorId: string;
createdAt: Date;
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
allowPersonalSpaces?: boolean;
isScimEnabled?: boolean;
}
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
spaces?: IWorkspaceSpaceSettings;
}
export interface IWorkspaceApiSettings {
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
allowMemberTemplates?: boolean;
}
export interface IWorkspaceSpaceSettings {
allowPersonal?: boolean;
}
export interface ICreateInvite {
role: string;
emails: string[];
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
@@ -22,6 +23,9 @@ export default function WorkspaceSettings() {
<Divider my="md" />
<AllowMemberTemplates />
<Divider my="md" />
<PersonalSpacesSetting />
{isCloud() && (
<>
<Divider my="md" />
@@ -25,3 +25,11 @@
.mantine-Input-input[data-variant="default"] {
border-color: var(--mantine-color-gray-6);
}
/* better contrast for disabled inputs */
.mantine-Input-input:disabled,
.mantine-Input-input[data-disabled] {
opacity: 0.7;
color: var(--mantine-color-dimmed);
-webkit-text-fill-color: var(--mantine-color-dimmed);
}
+1
View File
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
@@ -1,7 +1,7 @@
import {
IsAlphanumeric,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
@MinLength(2)
@MaxLength(100)
@IsAlphanumeric()
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
message:
'Space slug must start with a letter or number and may contain hyphens and underscores',
})
slug: string;
}
@@ -48,6 +48,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
let space = null;
@@ -59,6 +60,7 @@ export class SpaceService {
workspaceId,
createSpaceDto,
trx,
options,
);
await this.spaceMemberService.addUserToSpace(
@@ -81,6 +83,7 @@ export class SpaceService {
after: {
name: space.name,
slug: space.slug,
...(space.isPersonal ? { isPersonal: true } : {}),
},
},
});
@@ -93,6 +96,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
options?: { isPersonal?: boolean },
): Promise<Space> {
const slugExists = await this.spaceRepo.slugExists(
createSpaceDto.slug,
@@ -112,6 +116,7 @@ export class SpaceService {
creatorId: userId,
workspaceId: workspaceId,
slug: createSpaceDto.slug,
isPersonal: options?.isPersonal ?? false,
},
trx,
);
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
allowMemberTemplates: boolean;
@IsOptional()
@IsBoolean()
allowPersonalSpaces: boolean;
}
@@ -333,7 +333,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -361,6 +362,18 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.PERSONAL_SPACES,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -500,6 +513,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
before.allowPersonalSpaces = prev;
after.allowPersonalSpaces = updateWorkspaceDto.allowPersonalSpaces;
}
await this.workspaceRepo.updateSpaceSettings(
workspaceId,
'allowPersonal',
updateWorkspaceDto.allowPersonalSpaces,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -507,6 +534,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.allowPersonalSpaces;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('spaces')
.addColumn('is_personal', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
await sql`
CREATE UNIQUE INDEX spaces_personal_creator_unique
ON spaces (creator_id)
WHERE is_personal = true AND deleted_at IS NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('spaces_personal_creator_unique')
.ifExists()
.execute();
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
}
@@ -57,6 +57,22 @@ export class SpaceRepo {
.executeTakeFirst();
}
async findPersonalSpace(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Space | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('spaces')
.selectAll('spaces')
.where('workspaceId', '=', workspaceId)
.where('creatorId', '=', userId)
.where('isPersonal', '=', true)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async slugExists(
slug: string,
workspaceId: string,
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateSpaceSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}
+1
View File
@@ -322,6 +322,7 @@ export interface Spaces {
deletedAt: Timestamp | null;
description: string | null;
id: Generated<string>;
isPersonal: Generated<boolean>;
logo: string | null;
name: string | null;
settings: Json | null;