diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 4275b5af..f0410738 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -420,6 +420,8 @@
"{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
+ "{{count}} result found_one": "{{count}} result found",
+ "{{count}} result found_other": "{{count}} results found",
"Equal columns": "Equal columns",
"Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar",
@@ -1127,6 +1129,18 @@
"Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
+ "Label added": "Label added",
+ "Label removed": "Label removed",
+ "Image updated": "Image updated",
+ "Unsupported image type": "Unsupported image type",
+ "Member deactivated": "Member deactivated",
+ "Member activated": "Member activated",
+ "Name is required": "Name is required",
+ "Name must be 40 characters or fewer": "Name must be 40 characters or fewer",
+ "Group name must be at least 2 characters": "Group name must be at least 2 characters",
+ "Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer",
+ "Description must be 500 characters or fewer": "Description must be 500 characters or fewer",
+ "Invalid invitation link": "Invalid invitation link",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"AI chat": "AI chat",
@@ -1135,7 +1149,6 @@
"Thinking…": "Thinking…",
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant",
- "Enabled": "Enabled",
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
"Public assistant model": "Public assistant model",
"Defaults to the chat model": "Defaults to the chat model",
@@ -1264,5 +1277,7 @@
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
"Analytics / tracker": "Analytics / tracker",
- "Injected verbatim into the
of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
+ "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
+ "Go to login page": "Go to login page",
+ "Move to space": "Move to space"
}
diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx
index d7ac5f40..ec98aa02 100644
--- a/apps/client/src/components/common/avatar-uploader.tsx
+++ b/apps/client/src/components/common/avatar-uploader.tsx
@@ -42,6 +42,23 @@ export default function AvatarUploader({
return;
}
+ // Validate file type. The `accept` attribute only filters the dialog;
+ // a user can still select a non-image file, which previously failed
+ // silently. Surface a visible error instead (issue #133). Accept any
+ // image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
+ // what the server accepts; only genuinely non-image files are rejected.
+ if (!file.type.startsWith("image/")) {
+ notifications.show({
+ message: t("Unsupported image type"),
+ color: "red",
+ });
+ // Reset the input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ return;
+ }
+
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
@@ -58,6 +75,8 @@ export default function AvatarUploader({
try {
await onUpload(file);
+ // Notify on success so the upload gives visible feedback (issue #128)
+ notifications.show({ message: t("Image updated") });
} catch (error) {
console.error(error);
notifications.show({
@@ -117,7 +136,7 @@ export default function AvatarUploader({
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
- accept="image/png,image/jpeg,image/jpg"
+ accept="image/*"
aria-label={ariaLabel}
tabIndex={-1}
style={{ display: "none" }}
diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx
index ec531cf4..4e1183d9 100644
--- a/apps/client/src/components/common/recent-changes.tsx
+++ b/apps/client/src/components/common/recent-changes.tsx
@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
(
+ "ai-chat-window-geom",
+ null,
+);
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
index 5fda9f8c..6d543289 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
+++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
@@ -26,6 +26,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
+ aiChatWindowGeomAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
@@ -123,15 +124,13 @@ export default function AiChatWindow() {
minimizedRef.current = minimized;
const winRef = useRef(null);
- // Live window geometry (position + size); initialized lazily on first open so
- // it is anchored to the current viewport (top-right corner). Kept in state so
- // a user resize survives close/reopen and can be re-clamped to the viewport.
- const [geom, setGeom] = useState<{
- left: number;
- top: number;
- width: number;
- height: number;
- } | null>(null);
+ // Live window geometry (position + size); persisted to localStorage so a
+ // drag/resize survives a full page reload (and close/reopen). `null` means
+ // "never placed yet" — the layout effect below then computes an initial
+ // top-right placement anchored to the current viewport, and on restore it is
+ // re-clamped to the viewport (so a placement saved on a larger screen is not
+ // left partly off-screen).
+ const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
// Track whether we are awaiting the id of a just-created (new) chat, so we
// can adopt it once the chat list refreshes after the first turn finishes.
@@ -416,6 +415,10 @@ export default function AiChatWindow() {
useEffect(() => {
if (!windowOpen || minimized) return;
const el = winRef.current;
+ // `geom` is in the deps so this re-runs once geometry is settled and the
+ // window is actually rendered (on the first open `geom` is still null on the
+ // render that flips windowOpen, so winRef.current is null then — without the
+ // geom dep the observer would never attach and resizes would not persist).
if (!el) return;
const ro = new ResizeObserver(() => {
const width = el.offsetWidth;
@@ -427,7 +430,7 @@ export default function AiChatWindow() {
});
ro.observe(el);
return () => ro.disconnect();
- }, [windowOpen, minimized]);
+ }, [windowOpen, minimized, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
// Ignore drags that originate on a button (minimize/close/new chat).
diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx
index f69b9357..dc2d9966 100644
--- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx
+++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx
@@ -10,9 +10,12 @@ import {
PasswordInput,
Box,
Stack,
+ Group,
+ Text,
} from "@mantine/core";
import { zod4Resolver } from "mantine-form-zod-resolver";
-import { useParams, useSearchParams } from "react-router-dom";
+import { Link, useParams, useSearchParams } from "react-router-dom";
+import APP_ROUTE from "@/lib/app-route";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
}
if (isError) {
- return
{t("invalid invitation link")}
;
+ // Styled error with a CTA to login, mirroring the password-reset
+ // error page and the 404 page (issue #133)
+ return (
+
+
+
+ {t("Invalid invitation link")}
+
+
+
+
+
+
+ );
}
if (!invitation) {
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts
new file mode 100644
index 00000000..c19201ec
--- /dev/null
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { getSuggestionItems } from "./menu-items";
+
+// The slash-command `allow` callback (slash-command.ts) keeps the popup active
+// only while at least one item matches the current query:
+// const groups = getSuggestionItems({ query });
+// const hasMatches = Object.values(groups).some((items) => items.length > 0);
+// return hasMatches;
+// With `allowSpaces: true`, a non-empty query that matches nothing must collapse
+// to an empty result so `allow` returns false and the menu closes (instead of
+// leaving literal "/todo abc" text behind). These tests pin that contract at the
+// `getSuggestionItems` boundary, which is the unit-testable half of `allow`.
+
+const KEY = "currentUser";
+
+function hasMatches(query: string): boolean {
+ // Mirror the exact predicate used by slash-command.ts `allow`.
+ const groups = getSuggestionItems({ query });
+ return Object.values(groups).some((items) => items.length > 0);
+}
+
+beforeEach(() => {
+ // Default workspace state: HTML-embed feature OFF (matches production default).
+ localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
+});
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => {
+ it("keeps the menu allowed for a query that matches items", () => {
+ expect(hasMatches("h1")).toBe(true);
+ });
+
+ it("keeps the menu allowed for a multi-word matching query", () => {
+ // "Heading 1" is a multi-word title kept alive by allowSpaces.
+ expect(hasMatches("Heading 1")).toBe(true);
+ });
+
+ it("closes the menu (no matches) for a non-empty query that matches nothing", () => {
+ expect(hasMatches("zzzznomatch")).toBe(false);
+ });
+
+ it("closes the menu for a space-bearing non-matching query", () => {
+ // The exact case the allowSpaces fix targets: "/todo abc" matches nothing.
+ expect(hasMatches("todo abc")).toBe(false);
+ });
+
+ it("returns an empty result object for a no-match query", () => {
+ expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({});
+ });
+
+ it("returns a non-empty result for the 'Heading 1' query", () => {
+ const groups = getSuggestionItems({ query: "Heading 1" });
+ const titles = Object.values(groups)
+ .flat()
+ .map((item) => item.title);
+ expect(titles).toContain("Heading 1");
+ });
+});
diff --git a/apps/client/src/features/editor/extensions/slash-command.ts b/apps/client/src/features/editor/extensions/slash-command.ts
index 339f88ca..947a6d58 100644
--- a/apps/client/src/features/editor/extensions/slash-command.ts
+++ b/apps/client/src/features/editor/extensions/slash-command.ts
@@ -14,6 +14,10 @@ const Command = Extension.create({
return {
suggestion: {
char: '/',
+ // Keep the query alive through spaces so multi-word item labels
+ // (e.g. "Heading 1", "Math block") match instead of terminating the
+ // query and leaving literal "/Heading 1" text in the document.
+ allowSpaces: true,
command: ({ editor, range, props }) => {
props.command({ editor, range, props });
},
@@ -23,7 +27,22 @@ const Command = Extension.create({
if ($from.parent.type.name === 'codeBlock') {
return false;
}
- return true;
+ // With `allowSpaces: true` a query that contains a space no longer
+ // terminates the suggestion on its own, so a space-bearing query that
+ // matches nothing (e.g. "/todo abc") would otherwise keep an empty
+ // popup logically active and leave the literal "/todo abc" text in the
+ // document, only dismissable via Escape. Deactivate the suggestion when
+ // no item matches the current query: returning false here removes the
+ // decoration, fires the popup's `onExit`, and lets subsequent keystrokes
+ // pass through normally — restoring the pre-`allowSpaces` behavior for
+ // non-matching queries while keeping multi-word matches (e.g.
+ // "/Heading 1") working.
+ const query = state.doc.textBetween(range.from + 1, range.to);
+ const groups = getSuggestionItems({ query });
+ const hasMatches = Object.values(groups).some(
+ (items) => items.length > 0,
+ );
+ return hasMatches;
},
} as Partial,
};
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 41b6538e..5aeea3d4 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -113,6 +113,13 @@ export default function PageEditor({
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
+ // Always holds the latest collab token. The provider effect below runs once
+ // per pageId, so a handler created inside it would otherwise close over a
+ // stale `collabQuery`. Reading the ref gives the current token instead.
+ const collabTokenRef = useRef(undefined);
+ useEffect(() => {
+ collabTokenRef.current = collabQuery?.token;
+ }, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
@@ -167,20 +174,33 @@ export default function PageEditor({
}
};
const onAuthenticationFailedHandler = () => {
- const payload = jwtDecode(collabQuery?.token);
- const now = Date.now().valueOf() / 1000;
- const isTokenExpired = now >= payload.exp;
- if (isTokenExpired) {
- refetchCollabToken().then((result) => {
- if (result.data?.token) {
- socket.disconnect();
- setTimeout(() => {
- remote.configuration.token = result.data.token;
- socket.connect();
- }, 100);
- }
- });
+ // Read the latest token via the ref (the closure-captured `collabQuery`
+ // may be stale). Guard the decode: a missing or unparseable token must
+ // not throw "Invalid token specified" and should trigger a refresh so
+ // the editor reconnects even when the initial token fetch failed.
+ const token = collabTokenRef.current;
+ let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
+ if (token) {
+ try {
+ // A token that decodes but lacks a numeric `exp` must be treated as
+ // expired (`Date.now()/1000 >= undefined` is `false`, which would
+ // otherwise skip the reconnect), so refresh on any missing/non-number exp.
+ const exp = jwtDecode<{ exp?: number }>(token).exp;
+ needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
+ } catch {
+ needsRefresh = true;
+ }
}
+ if (!needsRefresh) return;
+ refetchCollabToken().then((result) => {
+ if (result.data?.token) {
+ socket.disconnect();
+ setTimeout(() => {
+ remote.configuration.token = result.data.token;
+ socket.connect();
+ }, 100);
+ }
+ });
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
diff --git a/apps/client/src/features/favorite/queries/favorite-query.ts b/apps/client/src/features/favorite/queries/favorite-query.ts
index 886f2f5b..51fd7856 100644
--- a/apps/client/src/features/favorite/queries/favorite-query.ts
+++ b/apps/client/src/features/favorite/queries/favorite-query.ts
@@ -13,6 +13,8 @@ import {
ToggleFavoriteParams,
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
export function useAddFavoriteMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (data) => addFavorite(data),
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
+ // Notify on success so the action gives visible feedback (issue #128)
+ notifications.show({ message: t("Added to favorites") });
},
});
}
export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (data) => removeFavorite(data),
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
+ // Notify on success so the action gives visible feedback (issue #128)
+ notifications.show({ message: t("Removed from favorites") });
},
});
}
diff --git a/apps/client/src/features/group/components/create-group-form.tsx b/apps/client/src/features/group/components/create-group-form.tsx
index 3d249bf5..2d8d04c7 100644
--- a/apps/client/src/features/group/components/create-group-form.tsx
+++ b/apps/client/src/features/group/components/create-group-form.tsx
@@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t
import { useTranslation } from "react-i18next";
import { zod4Resolver } from 'mantine-form-zod-resolver';
-const formSchema = z.object({
- name: z.string().trim().min(2).max(100),
- description: z.string().max(500),
-});
-
-type FormValues = z.infer;
+type FormValues = {
+ name: string;
+ description: string;
+};
export function CreateGroupForm() {
const { t } = useTranslation();
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
const [userIds, setUserIds] = useState([]);
const navigate = useNavigate();
+ // Build the schema with friendly, translated validation messages (issue #130)
+ const formSchema = z.object({
+ name: z
+ .string()
+ .trim()
+ .min(2, t("Group name must be at least 2 characters"))
+ .max(100, t("Group name must be 100 characters or fewer")),
+ description: z
+ .string()
+ .max(500, t("Description must be 500 characters or fewer")),
+ });
+
const form = useForm({
validate: zod4Resolver(formSchema),
initialValues: {
diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx
index 3bf04b5a..d7139403 100644
--- a/apps/client/src/features/group/components/group-members.tsx
+++ b/apps/client/src/features/group/components/group-members.tsx
@@ -41,7 +41,7 @@ export default function GroupMembersList() {
),
centered: true,
- labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});
diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx
index 65a4273e..c26bde9e 100644
--- a/apps/client/src/features/home/components/created-by-me.tsx
+++ b/apps/client/src/features/home/components/created-by-me.tsx
@@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) {
{
notifications.show({
@@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) {
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
+ // Notify on success so the action gives visible feedback (issue #128)
+ notifications.show({ message: t("Label removed") });
},
onError: () => {
notifications.show({
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index ab7827d6..02cee126 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -102,7 +102,12 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && }
- {!workspaceSharingDisabled && }
+ {/* Hide the Share entry point for readers; the toggle inside is inert
+ without edit permission, so gate it like other edit-only actions
+ (issue #133) */}
+ {!readOnly && !workspaceSharingDisabled && (
+
+ )}
}
onClick={openMovePageModal}
>
- {t("Move")}
+ {t("Move to space")}
)}
diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
index 54d75f3f..e09fcbe3 100644
--- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
+++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
@@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
variant="subtle"
color="gray"
className={classes.actionIcon}
- aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
+ aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
@@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openExportModal();
}}
>
- {t("Export page")}
+ {t("Export")}
{canEdit && (
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openMovePageModal();
}}
>
- {t("Move")}
+ {t("Move to space")}
- {node.name || t("untitled")}
+ {node.name || t("Untitled")}
{node.isTemplate === true && (
@@ -297,7 +297,7 @@ function CreateNode({
variant="subtle"
color="gray"
className={classes.actionIcon}
- aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
+ aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx
index 9a22f280..b2cfb994 100644
--- a/apps/client/src/features/page/tree/components/space-tree.tsx
+++ b/apps/client/src/features/page/tree/components/space-tree.tsx
@@ -282,7 +282,7 @@ const SpaceTree = forwardRef(function SpaceTree(
[],
);
const getDragLabel = useCallback(
- (n: SpaceTreeNode) => n.name || t("untitled"),
+ (n: SpaceTreeNode) => n.name || t("Untitled"),
[t],
);
diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts
index 3ace0650..53d787c6 100644
--- a/apps/client/src/features/page/tree/utils/utils.ts
+++ b/apps/client/src/features/page/tree/utils/utils.ts
@@ -51,7 +51,7 @@ export function findBreadcrumbPath(
): SpaceTreeNode[] | null {
for (const node of tree) {
if (!node.name || node.name.trim() === "") {
- node.name = "untitled";
+ node.name = "Untitled";
}
if (node.id === pageId) {
diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx
index 7ae136c3..ebe8078c 100644
--- a/apps/client/src/features/search/components/search-spotlight-filters.tsx
+++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx
@@ -107,48 +107,55 @@ export function SearchSpotlightFilters({
-
+ {contentType
+ ? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
+ : t("Type")}
+
+
+
+ {contentTypeOptions.map((option) => (
+
+ contentType !== option.value &&
+ handleFilterChange("contentType", option.value)
+ }
+ >
+
+