diff --git a/echo/AGENTS.md b/echo/AGENTS.md
index 479df4262..5ba18dbe2 100644
--- a/echo/AGENTS.md
+++ b/echo/AGENTS.md
@@ -234,6 +234,16 @@ Production uses webhook mode (`ASSEMBLYAI_WEBHOOK_URL`); polling is only a fallb
The `agent/` directory contains the agentic chat service (LangGraph-based). It runs as a separate FastAPI service on port 8001. Agentic chat streams via `POST /api/agentic/runs/{run_id}/stream` — no Dramatiq dispatch. See `agent/README.md`.
+## Directus File Cleanup
+
+When removing a file reference from a user record (e.g. avatar, whitelabel logo), always delete the orphaned Directus file after clearing the reference. Pattern:
+
+1. Fetch the current file ID from the user record
+2. Update the user record to set the field to `None`
+3. Delete the file via `directus.delete_file(file_id)`
+
+Wrap all blocking Directus SDK calls in `run_in_thread_pool` (from `dembrane.async_helpers`) when used in async endpoints. See `server/dembrane/api/user_settings.py` for reference implementations in `remove_avatar` and `remove_whitelabel_logo`.
+
## Tech Debt / Known Issues
- Some mypy errors in `llm_router.py` and `settings.py` (pre-existing, non-blocking)
diff --git a/echo/frontend/AGENTS.md b/echo/frontend/AGENTS.md
index d0982a21b..fa27e2ece 100644
--- a/echo/frontend/AGENTS.md
+++ b/echo/frontend/AGENTS.md
@@ -33,6 +33,11 @@
- **React Query hook hubs**: Each feature owns a `hooks/index.ts` exposing `useQuery`/`useMutation` wrappers with shared `useQueryClient` invalidation logic (`src/components/{conversation,project,chat,participant,...}/hooks/index.ts`).
- **Lingui macros for copy**: Most routed screens import `t` from `@lingui/core/macro` and `Trans` from `@lingui/react/macro` to localize UI strings (e.g. `src/routes/auth/Login.tsx`, `src/routes/project/conversation/ProjectConversationOverview.tsx`).
- **Mantine + Tailwind blend**: Screens compose Mantine primitives (`Stack`, `Group`, `ActionIcon`, etc.) while layering Tailwind utility classes via `className`, alongside toast feedback via `@/components/common/Toaster` (e.g. `src/components/conversation/ConversationDangerZone.tsx`, `src/components/dropzone/UploadConversationDropzone.tsx`).
+- **ConfirmModal for destructive/irreversible actions**: Never use `window.confirm()`. Always use `ConfirmModal` from `@/components/common/ConfirmModal`. Pass `confirmColor="red"` for destructive actions and always include a `data-testid` prop. Manage open/close state with `useDisclosure` from `@mantine/hooks`. Used in 12+ components (delete chat, delete conversation, delete project, delete template, delete report, delete tag, remove avatar, remove logo, regenerate summary, generate library, disable anonymization, change language during chat).
+- **InputModal for text input prompts**: Never use `window.prompt()`. Always use `InputModal` from `@/components/common/InputModal`. It auto-focuses, validates empty input, and supports form submit via Enter. Used for chat rename and similar single-field prompts.
+- **Toast for status messages**: Never use `window.alert()` or `alert()`. Always use `toast.error()` / `toast.success()` from `@/components/common/Toaster` for transient status feedback (e.g. permission denied, invalid token, clipboard failure).
+- **Confirm dialog button layout**: Right-aligned `Group` with `variant="subtle"` cancel button on the left, primary action button on the right. This is handled automatically by `ConfirmModal` and `InputModal`.
+- **`data-testid` convention for modals**: Use kebab-case like `"chat-delete-modal"`, `"tag-delete-modal"`. `ConfirmModal` and `InputModal` automatically append `-cancel` and `-confirm` suffixes on their buttons.
## Change Hotspots (git history)
- Translation bundles dominate churn: `src/locales/{en-US,de-DE,es-ES,fr-FR,nl-NL}.{po,ts}` appear in 50–60 commits each (`git log` frequency).
diff --git a/echo/frontend/src/components/chat/ChatAccordion.tsx b/echo/frontend/src/components/chat/ChatAccordion.tsx
index 0f1cef0a1..a7dd2e465 100644
--- a/echo/frontend/src/components/chat/ChatAccordion.tsx
+++ b/echo/frontend/src/components/chat/ChatAccordion.tsx
@@ -13,6 +13,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
import {
IconDotsVertical,
IconMessageCircle,
@@ -26,6 +27,8 @@ import { useInView } from "react-intersection-observer";
import { useParams } from "react-router";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { testId } from "@/lib/testUtils";
+import { ConfirmModal } from "../common/ConfirmModal";
+import { InputModal } from "../common/InputModal";
import { NavigationButton } from "../common/NavigationButton";
import { MODE_COLORS } from "./ChatModeSelector";
import { ChatSkeleton } from "./ChatSkeleton";
@@ -104,62 +107,88 @@ export const ChatAccordionItemMenu = ({
const deleteChatMutation = useDeleteChatMutation();
const updateChatMutation = useUpdateChatMutation();
const navigate = useI18nNavigate();
+ const [
+ deleteConfirmOpened,
+ { open: openDeleteConfirm, close: closeDeleteConfirm },
+ ] = useDisclosure(false);
+ const [renameOpened, { open: openRename, close: closeRename }] =
+ useDisclosure(false);
return (
-
-
-
-
-
-
-
-
-
- }
- disabled={deleteChatMutation.isPending}
- onClick={() => {
- const newName = prompt(
- t`Enter new name for the chat:`,
- chat.name ?? "",
- );
- if (newName) {
- updateChatMutation.mutate({
- chatId: chat.id ?? "",
- payload: { name: newName },
- projectId: (chat.project_id as string) ?? "",
- });
- }
- }}
- {...testId("chat-item-menu-rename")}
- >
-
-
- }
- disabled={deleteChatMutation.isPending}
- onClick={() => {
- if (confirm("Are you sure you want to delete this chat?")) {
- deleteChatMutation.mutate({
- chatId: chat.id ?? "",
- projectId: (chat.project_id as string) ?? "",
- });
- navigate(`/projects/${chat.project_id}/overview`);
- }
- }}
- {...testId("chat-item-menu-delete")}
+ <>
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+ }
+ disabled={deleteChatMutation.isPending}
+ onClick={openRename}
+ {...testId("chat-item-menu-rename")}
+ >
+
+
+ }
+ disabled={deleteChatMutation.isPending}
+ onClick={openDeleteConfirm}
+ {...testId("chat-item-menu-delete")}
+ >
+
+
+
+
+
+
+ Chat name}
+ initialValue={chat.name ?? ""}
+ confirmLabel={Save }
+ loading={updateChatMutation.isPending}
+ onConfirm={(newName) => {
+ updateChatMutation.mutate({
+ chatId: chat.id ?? "",
+ payload: { name: newName },
+ projectId: (chat.project_id as string) ?? "",
+ });
+ closeRename();
+ }}
+ data-testid="chat-rename-modal"
+ />
+
+ Delete}
+ confirmColor="red"
+ loading={deleteChatMutation.isPending}
+ onConfirm={() => {
+ deleteChatMutation.mutate({
+ chatId: chat.id ?? "",
+ projectId: (chat.project_id as string) ?? "",
+ });
+ navigate(`/projects/${chat.project_id}/overview`);
+ closeDeleteConfirm();
+ }}
+ data-testid="chat-delete-modal"
+ />
+ >
);
};
diff --git a/echo/frontend/src/components/chat/TemplatesModal.tsx b/echo/frontend/src/components/chat/TemplatesModal.tsx
index 97c927d79..e2a994d0f 100644
--- a/echo/frontend/src/components/chat/TemplatesModal.tsx
+++ b/echo/frontend/src/components/chat/TemplatesModal.tsx
@@ -37,25 +37,25 @@ import {
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
- ArrowLeft,
- Copy,
- DotsSixVertical,
- Globe,
- MagnifyingGlass,
- PencilSimple,
- Plus,
- Trash,
- X,
+ ArrowLeftIcon,
+ CopyIcon,
+ DotsSixVerticalIcon,
+ MagnifyingGlassIcon,
+ PencilSimpleIcon,
+ PlusIcon,
+ TrashIcon,
+ XIcon,
} from "@phosphor-icons/react";
import { IconPin, IconPinFilled } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import {
- type QuickAccessItem,
encodeTemplateKey,
keyToQuickAccess,
+ type QuickAccessItem,
quickAccessToKey,
} from "./templateKey";
-import { type Template, Templates } from "./templates";
+import { Templates } from "./templates";
// ── Types ──
@@ -402,40 +402,23 @@ export const TemplatesModal = ({
// ── Render: Create / Edit view ──
const deleteConfirmationModal = (
- setDeletingTemplateId(null)}
title={t`Delete template`}
- size="sm"
- centered
- >
-
-
-
- Are you sure you want to delete this template? This cannot be
- undone.
-
-
-
- setDeletingTemplateId(null)}>
- Cancel
-
- {
- if (deletingTemplateId) {
- onDeleteUserTemplate?.(deletingTemplateId);
- setDeletingTemplateId(null);
- setView("browse");
- }
- }}
- >
- Delete
-
-
-
-
+ data-testid="template-delete-modal"
+ message={t`Are you sure you want to delete this template? This cannot be undone.`}
+ confirmLabel={Delete }
+ loading={isDeleting}
+ confirmColor="red"
+ onConfirm={() => {
+ if (deletingTemplateId) {
+ onDeleteUserTemplate?.(deletingTemplateId);
+ setDeletingTemplateId(null);
+ setView("browse");
+ }
+ }}
+ />
);
if (view === "create" || view === "edit") {
@@ -445,7 +428,7 @@ export const TemplatesModal = ({
-
+
Back to templates
@@ -476,23 +459,26 @@ export const TemplatesModal = ({
)}
-
- Save template
-
- {view === "edit" && editingId && (
- setDeletingTemplateId(editingId)}
+
+ {view === "edit" && editingId && (
+ setDeletingTemplateId(editingId)}
+ >
+ Delete
+
+ )}
+
-
- Delete template
-
-
- )}
+ Save template
+
+
@@ -538,7 +524,7 @@ export const TemplatesModal = ({
className="flex cursor-grab items-center text-gray-400 hover:text-gray-600 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
-
+
)}
@@ -564,7 +550,7 @@ export const TemplatesModal = ({
handleDuplicate(tmpl.title, tmpl.content);
}}
>
-
+
)}
@@ -580,7 +566,7 @@ export const TemplatesModal = ({
if (ut) handleStartEdit(ut);
}}
>
-
+
@@ -594,7 +580,7 @@ export const TemplatesModal = ({
setDeletingTemplateId(tmpl.id);
}}
>
-
+
>
@@ -687,7 +673,7 @@ export const TemplatesModal = ({
{/* Search */}
}
+ leftSection={ }
className="flex-1"
size="sm"
rightSection={
@@ -698,7 +684,7 @@ export const TemplatesModal = ({
aria-label="Clear search"
onClick={() => setSearchQuery("")}
>
-
+
) : null
}
@@ -710,7 +696,7 @@ export const TemplatesModal = ({
{/* Create template — primary CTA */}
}
+ rightSection={ }
onClick={handleStartCreate}
>
Create template
diff --git a/echo/frontend/src/components/common/ConfirmModal.tsx b/echo/frontend/src/components/common/ConfirmModal.tsx
new file mode 100644
index 000000000..a4cab91b2
--- /dev/null
+++ b/echo/frontend/src/components/common/ConfirmModal.tsx
@@ -0,0 +1,57 @@
+import { Trans } from "@lingui/react/macro";
+import { Button, Group, Modal, Stack, Text } from "@mantine/core";
+import type { ReactNode } from "react";
+
+type ConfirmModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ message: ReactNode;
+ confirmLabel?: ReactNode;
+ cancelLabel?: ReactNode;
+ confirmColor?: string;
+ loading?: boolean;
+ "data-testid"?: string;
+};
+
+export const ConfirmModal = ({
+ opened,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ confirmColor = "primary",
+ loading = false,
+ "data-testid": dataTestId,
+}: ConfirmModalProps) => (
+
+
+ {message}
+
+
+ {cancelLabel ?? Cancel }
+
+
+ {confirmLabel ?? Confirm }
+
+
+
+
+);
diff --git a/echo/frontend/src/components/common/InputModal.tsx b/echo/frontend/src/components/common/InputModal.tsx
new file mode 100644
index 000000000..97c303f47
--- /dev/null
+++ b/echo/frontend/src/components/common/InputModal.tsx
@@ -0,0 +1,92 @@
+import { Trans } from "@lingui/react/macro";
+import { Button, Group, Modal, Stack, TextInput } from "@mantine/core";
+import { type ReactNode, useEffect, useState } from "react";
+
+type InputModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ onConfirm: (value: string) => void;
+ title: string;
+ label?: ReactNode;
+ placeholder?: string;
+ initialValue?: string;
+ confirmLabel?: ReactNode;
+ cancelLabel?: ReactNode;
+ loading?: boolean;
+ "data-testid"?: string;
+};
+
+export const InputModal = ({
+ opened,
+ onClose,
+ onConfirm,
+ title,
+ label,
+ placeholder,
+ initialValue = "",
+ confirmLabel,
+ cancelLabel,
+ loading = false,
+ "data-testid": dataTestId,
+}: InputModalProps) => {
+ const [value, setValue] = useState(initialValue);
+
+ useEffect(() => {
+ if (opened) {
+ setValue(initialValue);
+ }
+ }, [opened, initialValue]);
+
+ const handleSubmit = () => {
+ const trimmed = value.trim();
+ if (trimmed) {
+ onConfirm(trimmed);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx
index a1b642ecb..1e854ff8a 100644
--- a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx
+++ b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx
@@ -1,8 +1,10 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Button, Group, Stack, Tooltip } from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
import { IconDownload, IconTrash } from "@tabler/icons-react";
import { useParams } from "react-router";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { MoveConversationButton } from "@/components/conversation/MoveConversationButton";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { analytics } from "@/lib/analytics";
@@ -21,22 +23,8 @@ export const ConversationDangerZone = ({
const deleteConversationByIdMutation = useDeleteConversationByIdMutation();
const navigate = useI18nNavigate();
const { projectId } = useParams();
-
- const handleDelete = () => {
- if (
- window.confirm(
- t`Are you sure you want to delete this conversation? This action cannot be undone.`,
- )
- ) {
- try {
- analytics.trackEvent(events.DELETE_CONVERSATION);
- } catch (error) {
- console.warn("Analytics tracking failed:", error);
- }
- deleteConversationByIdMutation.mutate(conversation.id);
- navigate(`/projects/${projectId}/overview`);
- }
- };
+ const [confirmOpened, { open: openConfirm, close: closeConfirm }] =
+ useDisclosure(false);
const handleDownloadAudio = () => {
try {
@@ -78,7 +66,7 @@ export const ConversationDangerZone = ({
}
@@ -89,6 +77,26 @@ export const ConversationDangerZone = ({
+
+ Delete}
+ confirmColor="red"
+ onConfirm={() => {
+ try {
+ analytics.trackEvent(events.DELETE_CONVERSATION);
+ } catch (error) {
+ console.warn("Analytics tracking failed:", error);
+ }
+ deleteConversationByIdMutation.mutate(conversation.id);
+ navigate(`/projects/${projectId}/overview`);
+ closeConfirm();
+ }}
+ />
);
};
diff --git a/echo/frontend/src/components/form/MarkdownWYSIWYG/styles.css b/echo/frontend/src/components/form/MarkdownWYSIWYG/styles.css
index 1ad16411a..fb569bb9a 100644
--- a/echo/frontend/src/components/form/MarkdownWYSIWYG/styles.css
+++ b/echo/frontend/src/components/form/MarkdownWYSIWYG/styles.css
@@ -488,7 +488,7 @@
padding: var(--spacing-1_5);
align-items: center;
overflow-x: auto;
- position: sticky;
+ position: var(--mdx-toolbar-position, static);
top: var(--mdx-toolbar-top, 135px);
background-color: var(--baseBg);
width: inherit;
diff --git a/echo/frontend/src/components/language/LanguagePicker.tsx b/echo/frontend/src/components/language/LanguagePicker.tsx
index a45863e0e..a231b0f88 100644
--- a/echo/frontend/src/components/language/LanguagePicker.tsx
+++ b/echo/frontend/src/components/language/LanguagePicker.tsx
@@ -1,7 +1,10 @@
import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
import { NativeSelect } from "@mantine/core";
import type { ChangeEvent } from "react";
+import { useState } from "react";
import { useLocation } from "react-router";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { SUPPORTED_LANGUAGES } from "@/config";
import { useLanguage } from "@/hooks/useLanguage";
import { testId } from "@/lib/testUtils";
@@ -63,28 +66,11 @@ export const languageOptionsByIso639_1 = data.map((d) => ({
export const LanguagePicker = () => {
const { language: currentLanguage } = useLanguage();
const { pathname } = useLocation();
+ const [pendingLanguage, setPendingLanguage] = useState(null);
- const handleChange = (e: ChangeEvent) => {
- const selectedLanguage = e.target.value;
-
- // If the selected language is the same as the current language, do nothing
- if (selectedLanguage === currentLanguage) return;
-
- // Check if we're in a chat context
- const isInChat = pathname.includes("/chats/");
- if (isInChat) {
- // biome-ignore lint/suspicious/noAlert: TODO
- const confirmed = window.confirm(
- t`Changing language during an active chat may lead to unexpected results. It's recommended to start a new chat after changing the language. Are you sure you want to continue?`,
- );
- if (!confirmed) {
- return;
- }
- }
-
+ const applyLanguageChange = (selectedLanguage: string) => {
let newPathname = pathname;
- // Remove existing language from the pathname
SUPPORTED_LANGUAGES.forEach((lang) => {
if (newPathname.startsWith(`/${lang}/`)) {
newPathname = newPathname.replace(`/${lang}`, "");
@@ -93,26 +79,55 @@ export const LanguagePicker = () => {
}
});
- // use browser history to navigate to the new language path
- // otherwise the language change found to be inconsistent!
window.location.href = `/${selectedLanguage}${newPathname}`;
};
+ const handleChange = (e: ChangeEvent) => {
+ const selectedLanguage = e.target.value;
+
+ if (selectedLanguage === currentLanguage) return;
+
+ const isInChat = pathname.includes("/chats/");
+ if (isInChat) {
+ setPendingLanguage(selectedLanguage);
+ return;
+ }
+
+ applyLanguageChange(selectedLanguage);
+ };
+
return (
-
- {languageOptions.map((option) => (
-
- {option.label}
-
- ))}
-
+ <>
+
+ {languageOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ setPendingLanguage(null)}
+ title={t`Change language`}
+ data-testid="language-change-modal"
+ message={t`Changing language during an active chat may lead to unexpected results. It's recommended to start a new chat after changing the language. Are you sure you want to continue?`}
+ confirmLabel={Continue }
+ onConfirm={() => {
+ if (pendingLanguage) {
+ applyLanguageChange(pendingLanguage);
+ setPendingLanguage(null);
+ }
+ }}
+ />
+ >
);
};
diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx
index 6964cf8e5..7769b8b71 100644
--- a/echo/frontend/src/components/layout/Header.tsx
+++ b/echo/frontend/src/components/layout/Header.tsx
@@ -55,11 +55,7 @@ type HeaderViewProps = {
loading: boolean;
};
-function CreateFeedbackButton({
- onFallback,
-}: {
- onFallback: () => void;
-}) {
+function CreateFeedbackButton({ onFallback }: { onFallback: () => void }) {
const handleClick = async () => {
const feedback = Sentry.getFeedback();
if (feedback) {
@@ -74,10 +70,7 @@ function CreateFeedbackButton({
};
return (
- }
- onClick={handleClick}
- >
+ } onClick={handleClick}>
Report an issue
);
@@ -169,20 +162,14 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
>
)}
-
+
@@ -197,9 +184,7 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
size="sm"
fw={500}
truncate
- {...testId(
- "header-user-name",
- )}
+ {...testId("header-user-name")}
>
{user.first_name ?? "User"}
@@ -207,9 +192,7 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
size="xs"
c="dimmed"
truncate
- {...testId(
- "header-user-email",
- )}
+ {...testId("header-user-email")}
>
{user.email ?? ""}
@@ -221,33 +204,22 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
{/* Primary */}
- }
+ leftSection={ }
onClick={handleSettingsClick}
- {...testId(
- "header-settings-menu-item",
- )}
+ {...testId("header-settings-menu-item")}
>
Settings
- }
+ leftSection={ }
component="a"
href={docUrl}
target="_blank"
rightSection={
-
+
}
- {...testId(
- "header-documentation-menu-item",
- )}
+ {...testId("header-documentation-menu-item")}
>
Documentation
@@ -256,49 +228,34 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
{/* Community */}
- }
- onClick={() =>
- setFeedbackPortalOpen(true)
- }
+ leftSection={ }
+ onClick={() => setFeedbackPortalOpen(true)}
>
Feedback portal
- setFeedbackFallbackOpen(true)} />
+ setFeedbackFallbackOpen(true)}
+ />
- }
+ leftSection={ }
component="a"
href={COMMUNITY_SLACK_URL}
target="_blank"
onClick={() => {
try {
- analytics.trackEvent(
- events.JOIN_SLACK_COMMUNITY,
- );
+ analytics.trackEvent(events.JOIN_SLACK_COMMUNITY);
} catch (error) {
- console.warn(
- "Analytics tracking failed:",
- error,
- );
+ console.warn("Analytics tracking failed:", error);
}
}}
rightSection={
-
+
10+
}
- {...testId(
- "header-join-community-menu-item",
- )}
+ {...testId("header-join-community-menu-item")}
>
Slack community
@@ -311,14 +268,10 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
- }
+ leftSection={ }
onClick={handleLogout}
color="red"
- {...testId(
- "header-logout-menu-item",
- )}
+ {...testId("header-logout-menu-item")}
>
Logout
@@ -342,10 +295,9 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
- The built-in issue reporter could not be loaded.
- You can still let us know what went wrong through
- our feedback portal. It helps us fix things
- faster than not submitting a report.
+ The built-in issue reporter could not be loaded. You can still let
+ us know what went wrong through our feedback portal. It helps us
+ fix things faster than not submitting a report.
@@ -376,25 +328,23 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => {
- We'd love to hear from you. Whether you have an
- idea for something new, you've hit a bug, spotted
- a translation that feels off, or just want to
- share how things have been going.
+ We'd love to hear from you. Whether you have an idea for something
+ new, you've hit a bug, spotted a translation that feels off, or
+ just want to share how things have been going.
- To help us act on it, try to include where it
- happened and what you were trying to do. For bugs,
- tell us what went wrong. For ideas, tell us what
- need it would solve for you.
+ To help us act on it, try to include where it happened and what
+ you were trying to do. For bugs, tell us what went wrong. For
+ ideas, tell us what need it would solve for you.
- Just talk or type naturally. Your input goes
- directly to our product team and genuinely helps us
- make dembrane better. We read everything.
+ Just talk or type naturally. Your input goes directly to our
+ product team and genuinely helps us make dembrane better. We read
+ everything.
diff --git a/echo/frontend/src/components/participant/PermissionErrorModal.tsx b/echo/frontend/src/components/participant/PermissionErrorModal.tsx
index 1a3e9154e..404717fcd 100644
--- a/echo/frontend/src/components/participant/PermissionErrorModal.tsx
+++ b/echo/frontend/src/components/participant/PermissionErrorModal.tsx
@@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { Button, Divider, Modal, Stack } from "@mantine/core";
import { IconQuestionMark, IconReload } from "@tabler/icons-react";
import { useState } from "react";
+import { toast } from "@/components/common/Toaster";
import { checkPermissionError } from "@/lib/utils";
type PermissionErrorModalProps = {
@@ -20,7 +21,7 @@ export const PermissionErrorModal = ({
if (["granted", "prompt"].includes(permissionState ?? "")) {
window.location.reload();
} else {
- alert(
+ toast.error(
t`Microphone access is still denied. Please check your settings and try again.`,
);
}
diff --git a/echo/frontend/src/components/project/ProjectDangerZone.tsx b/echo/frontend/src/components/project/ProjectDangerZone.tsx
index 42d084d4a..b55c62dc2 100644
--- a/echo/frontend/src/components/project/ProjectDangerZone.tsx
+++ b/echo/frontend/src/components/project/ProjectDangerZone.tsx
@@ -12,6 +12,7 @@ import {
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconTrash } from "@tabler/icons-react";
import { useState } from "react";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { analytics } from "@/lib/analytics";
import { AnalyticsEvents as events } from "@/lib/analyticsEvents";
@@ -36,6 +37,11 @@ export const ProjectDangerZone = ({ project }: { project: Project }) => {
{ open: openDeleteModal, close: closeDeleteModal },
] = useDisclosure(false);
+ const [
+ isFinalDeleteOpen,
+ { open: openFinalDelete, close: closeFinalDelete },
+ ] = useDisclosure(false);
+
const [cloneName, setCloneName] = useState(project.name ?? "");
const handleClone = async () => {
@@ -63,19 +69,13 @@ export const ProjectDangerZone = ({ project }: { project: Project }) => {
};
const handleDelete = () => {
- if (
- window.confirm(
- t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`,
- )
- ) {
- try {
- analytics.trackEvent(events.DELETE_PROJECT);
- } catch (error) {
- console.warn("Analytics tracking failed:", error);
- }
- deleteProjectByIdMutation.mutate(project.id);
- navigate("/projects");
+ try {
+ analytics.trackEvent(events.DELETE_PROJECT);
+ } catch (error) {
+ console.warn("Analytics tracking failed:", error);
}
+ deleteProjectByIdMutation.mutate(project.id);
+ navigate("/projects");
};
return (
@@ -165,37 +165,30 @@ export const ProjectDangerZone = ({ project }: { project: Project }) => {
- Delete Project}
- {...testId("project-delete-modal")}
- >
-
-
-
- Are you sure you want to delete this project? This action cannot
- be undone.
-
-
-
-
-
- Cancel
-
-
- Delete Project
-
-
-
+ title={t`Delete project`}
+ data-testid="project-delete-modal"
+ message={t`Are you sure you want to delete this project? This action cannot be undone.`}
+ confirmLabel={Delete }
+ confirmColor="red"
+ onConfirm={() => {
+ closeDeleteModal();
+ openFinalDelete();
+ }}
+ />
+ Delete project}
+ confirmColor="red"
+ loading={deleteProjectByIdMutation.isPending}
+ onConfirm={handleDelete}
+ />
);
};
diff --git a/echo/frontend/src/components/project/ProjectPortalEditor.tsx b/echo/frontend/src/components/project/ProjectPortalEditor.tsx
index 60bf5aecf..14f765c26 100644
--- a/echo/frontend/src/components/project/ProjectPortalEditor.tsx
+++ b/echo/frontend/src/components/project/ProjectPortalEditor.tsx
@@ -11,7 +11,6 @@ import {
Divider,
Group,
InputDescription,
- Modal,
NativeSelect,
Paper,
Stack,
@@ -41,6 +40,7 @@ import { Resizable } from "re-resizable";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { useAutoSave } from "@/hooks/useAutoSave";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { useLanguage } from "@/hooks/useLanguage";
@@ -1513,49 +1513,36 @@ const ProjectPortalEditorComponent: React.FC = ({
/>
)}
/>
-
-
-
-
- Turning off anonymization while recordings are
- ongoing may have unintended consequences. Active
- conversations will also be affected mid-recording.
- Please use this with caution.
-
-
-
-
- Cancel
-
- {
- setValue("anonymize_transcripts", false, {
- shouldDirty: true,
- });
- anonymizeModalHandlers.close();
- dispatchAutoSave({
- ...getValues(),
- anonymize_transcripts: false,
- } as ProjectPortalFormValues);
- }}
- >
-
- Turn off
-
-
-
-
-
+ message={
+
+ Turning off anonymization while recordings are ongoing
+ may have unintended consequences. Active conversations
+ will also be affected mid-recording. Please use this
+ with caution.
+
+ }
+ confirmLabel={
+
+ Turn off
+
+ }
+ confirmColor="red"
+ onConfirm={() => {
+ setValue("anonymize_transcripts", false, {
+ shouldDirty: true,
+ });
+ anonymizeModalHandlers.close();
+ dispatchAutoSave({
+ ...getValues(),
+ anonymize_transcripts: false,
+ } as ProjectPortalFormValues);
+ }}
+ data-testid="anonymize-disable-modal"
+ />
@@ -1738,37 +1725,17 @@ const ProjectPortalEditorComponent: React.FC = ({
updateCustomTopicMutation.isPending
}
/>
- setDeleteConfirmKey(null)}
- title={Delete Custom Topic }
- size="sm"
- radius="md"
- padding="xl"
- {...testId("custom-topic-delete-confirm-modal")}
- >
-
-
-
- Are you sure you want to delete this custom topic? This cannot be
- undone.
-
-
-
- setDeleteConfirmKey(null)}>
- Cancel
-
-
- Delete
-
-
-
-
+ title={t`Delete custom topic`}
+ message={t`Are you sure you want to delete this custom topic? This cannot be undone.`}
+ confirmLabel={Delete }
+ confirmColor="red"
+ loading={deleteCustomTopicMutation.isPending}
+ onConfirm={confirmDeleteCustomTopic}
+ data-testid="custom-topic-delete-modal"
+ />
);
};
diff --git a/echo/frontend/src/components/project/ProjectTagsInput.tsx b/echo/frontend/src/components/project/ProjectTagsInput.tsx
index a1a2ed499..734ec78f1 100644
--- a/echo/frontend/src/components/project/ProjectTagsInput.tsx
+++ b/echo/frontend/src/components/project/ProjectTagsInput.tsx
@@ -29,8 +29,10 @@ import {
Text,
TextInput,
} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useState } from "react";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { useProjectById } from "@/components/project/hooks";
import { testId } from "@/lib/testUtils";
import { FormLabel } from "../form/FormLabel";
@@ -42,6 +44,8 @@ import {
export const ProjectTagPill = ({ tag }: { tag: ProjectTag }) => {
const deleteTagMutation = useDeleteTagByIdMutation();
+ const [confirmOpened, { open: openConfirm, close: closeConfirm }] =
+ useDisclosure(false);
const {
attributes,
listeners,
@@ -69,43 +73,53 @@ export const ProjectTagPill = ({ tag }: { tag: ProjectTag }) => {
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
- if (
- !isDragging &&
- window.confirm(
- t`Are you sure you want to delete this tag? This will remove the tag from existing conversations that contain it.`,
- )
- ) {
- deleteTagMutation.mutate(tag.id);
+ if (!isDragging) {
+ openConfirm();
}
};
return (
- handleDelete(e)}
- size="xs"
- variant="transparent"
- c="gray.8"
- onPointerDown={(e) => e.stopPropagation()}
- >
-
-
- }
- {...attributes}
- {...listeners}
- >
- {tag.text}
-
+ <>
+ handleDelete(e)}
+ size="xs"
+ variant="transparent"
+ c="gray.8"
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+
+
+ }
+ {...attributes}
+ {...listeners}
+ >
+ {tag.text}
+
+ Delete}
+ confirmColor="red"
+ onConfirm={() => {
+ deleteTagMutation.mutate(tag.id);
+ closeConfirm();
+ }}
+ />
+ >
);
};
diff --git a/echo/frontend/src/components/settings/AccountSettingsCard.tsx b/echo/frontend/src/components/settings/AccountSettingsCard.tsx
index aef127fdf..57f5a4605 100644
--- a/echo/frontend/src/components/settings/AccountSettingsCard.tsx
+++ b/echo/frontend/src/components/settings/AccountSettingsCard.tsx
@@ -15,6 +15,7 @@ import { IconTrash, IconUpload, IconUser } from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useCurrentUser } from "@/components/auth/hooks";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { ImageCropModal } from "@/components/common/ImageCropModal";
import { UserAvatar } from "@/components/common/UserAvatar";
import { API_BASE_URL } from "@/config";
@@ -37,27 +38,28 @@ export const AccountSettingsCard = () => {
const [cropSrc, setCropSrc] = useState(null);
const [cropOpened, { open: openCrop, close: closeCrop }] =
useDisclosure(false);
+ const [
+ removeConfirmOpened,
+ { open: openRemoveConfirm, close: closeRemoveConfirm },
+ ] = useDisclosure(false);
const updateNameMutation = useMutation({
mutationFn: async (firstName: string) => {
- const response = await fetch(
- `${API_BASE_URL}/user-settings/name`,
- {
- body: JSON.stringify({ first_name: firstName }),
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- method: "PATCH",
- },
- );
+ const response = await fetch(`${API_BASE_URL}/user-settings/name`, {
+ body: JSON.stringify({ first_name: firstName }),
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ method: "PATCH",
+ });
if (!response.ok) throw new Error("Failed to update name");
},
+ onError: () => {
+ toast.error(t`Failed to update name`);
+ },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
toast.success(t`Name updated`);
},
- onError: () => {
- toast.error(t`Failed to update name`);
- },
});
const uploadAvatarMutation = useMutation({
@@ -65,44 +67,38 @@ export const AccountSettingsCard = () => {
const formData = new FormData();
formData.append("file", blob, "avatar.png");
- const response = await fetch(
- `${API_BASE_URL}/user-settings/avatar`,
- {
- body: formData,
- credentials: "include",
- method: "POST",
- },
- );
+ const response = await fetch(`${API_BASE_URL}/user-settings/avatar`, {
+ body: formData,
+ credentials: "include",
+ method: "POST",
+ });
if (!response.ok) throw new Error("Failed to upload avatar");
return response.json();
},
+ onError: () => {
+ toast.error(t`Failed to upload avatar`);
+ },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
toast.success(t`Avatar updated`);
},
- onError: () => {
- toast.error(t`Failed to upload avatar`);
- },
});
const removeAvatarMutation = useMutation({
mutationFn: async () => {
- const response = await fetch(
- `${API_BASE_URL}/user-settings/avatar`,
- {
- credentials: "include",
- method: "DELETE",
- },
- );
+ const response = await fetch(`${API_BASE_URL}/user-settings/avatar`, {
+ credentials: "include",
+ method: "DELETE",
+ });
if (!response.ok) throw new Error("Failed to remove avatar");
},
+ onError: () => {
+ toast.error(t`Failed to remove avatar`);
+ },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
toast.success(t`Avatar removed`);
},
- onError: () => {
- toast.error(t`Failed to remove avatar`);
- },
});
const handleFileSelect = (file: File | null) => {
@@ -159,7 +155,7 @@ export const AccountSettingsCard = () => {
size="compact-sm"
leftSection={ }
loading={removeAvatarMutation.isPending}
- onClick={() => removeAvatarMutation.mutate()}
+ onClick={openRemoveConfirm}
>
Remove
@@ -180,18 +176,12 @@ export const AccountSettingsCard = () => {
/>
{/* Email (read-only) */}
-
+
{hasNameChanged && (
- updateNameMutation.mutate(name.trim())
- }
+ onClick={() => updateNameMutation.mutate(name.trim())}
loading={updateNameMutation.isPending}
disabled={!name.trim()}
>
@@ -202,6 +192,21 @@ export const AccountSettingsCard = () => {
+ Remove}
+ confirmColor="red"
+ loading={removeAvatarMutation.isPending}
+ onConfirm={() => {
+ removeAvatarMutation.mutate();
+ closeRemoveConfirm();
+ }}
+ />
+
{cropSrc && (
{
const [cropSrc, setCropSrc] = useState(null);
const [cropOpened, { open: openCrop, close: closeCrop }] =
useDisclosure(false);
+ const [
+ removeConfirmOpened,
+ { open: openRemoveConfirm, close: closeRemoveConfirm },
+ ] = useDisclosure(false);
const uploadMutation = useMutation({
mutationFn: async (blob: Blob) => {
@@ -53,13 +58,13 @@ export const WhitelabelLogoCard = () => {
const data = await response.json();
return data.file_id;
},
+ onError: () => {
+ toast.error(t`Failed to upload logo`);
+ },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
toast.success(t`Logo updated`);
},
- onError: () => {
- toast.error(t`Failed to upload logo`);
- },
});
const removeMutation = useMutation({
@@ -76,13 +81,13 @@ export const WhitelabelLogoCard = () => {
throw new Error("Failed to remove logo");
}
},
+ onError: () => {
+ toast.error(t`Failed to remove logo`);
+ },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
toast.success(t`Logo removed`);
},
- onError: () => {
- toast.error(t`Failed to remove logo`);
- },
});
const handleFileSelect = (file: File | null) => {
@@ -137,7 +142,7 @@ export const WhitelabelLogoCard = () => {
size="compact-sm"
leftSection={ }
loading={removeMutation.isPending}
- onClick={() => removeMutation.mutate()}
+ onClick={openRemoveConfirm}
>
Remove
@@ -167,6 +172,21 @@ export const WhitelabelLogoCard = () => {
+ Remove}
+ confirmColor="red"
+ loading={removeMutation.isPending}
+ onConfirm={() => {
+ removeMutation.mutate();
+ closeRemoveConfirm();
+ }}
+ />
+
{cropSrc && (
(null);
+
const onSubmit = (data: CreateViewForm) => {
createViewMutation.mutate({
additionalContext: data.additionalContext,
@@ -73,10 +79,8 @@ export const CreateView = ({
query: string;
additionalContext: string;
}) => {
- if (
- (queryValue?.trim() !== "" || additionalContextValue?.trim() !== "") &&
- !window.confirm(t`This will clear your current input. Are you sure?`)
- ) {
+ if (queryValue?.trim() !== "" || additionalContextValue?.trim() !== "") {
+ setPendingTemplate({ additionalContext, query });
return;
}
@@ -139,6 +143,22 @@ export const CreateView = ({
+
+ setPendingTemplate(null)}
+ title={t`Apply template`}
+ data-testid="view-apply-template-modal"
+ message={t`This will clear your current input. Are you sure?`}
+ confirmLabel={Apply }
+ onConfirm={() => {
+ if (pendingTemplate) {
+ setValue("query", pendingTemplate.query);
+ setValue("additionalContext", pendingTemplate.additionalContext);
+ setPendingTemplate(null);
+ }
+ }}
+ />
);
diff --git a/echo/frontend/src/hooks/useCopyToRichText.ts b/echo/frontend/src/hooks/useCopyToRichText.ts
index dd254b4e9..b99e5dc51 100644
--- a/echo/frontend/src/hooks/useCopyToRichText.ts
+++ b/echo/frontend/src/hooks/useCopyToRichText.ts
@@ -1,9 +1,11 @@
+import { t } from "@lingui/core/macro";
import { useCallback, useEffect, useState } from "react";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
+import { toast } from "@/components/common/Toaster";
async function markdownToHtml(markdown: string): Promise {
const result = await unified()
@@ -55,7 +57,7 @@ function useCopyToRichText() {
(_e) => {
navigator.clipboard.write([fallBackData]).catch((e) => {
console.error("Rich text copy failed:", e);
- alert("Failed to copy. Please report this issue to the team.");
+ toast.error(t`Could not copy to clipboard. Please try again.`);
});
},
);
diff --git a/echo/frontend/src/routes/auth/VerifyEmail.tsx b/echo/frontend/src/routes/auth/VerifyEmail.tsx
index 5a8f740b9..0c7df67e9 100644
--- a/echo/frontend/src/routes/auth/VerifyEmail.tsx
+++ b/echo/frontend/src/routes/auth/VerifyEmail.tsx
@@ -5,6 +5,7 @@ import { useDocumentTitle } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router";
import { useVerifyMutation } from "@/components/auth/hooks";
+import { toast } from "@/components/common/Toaster";
export const VerifyEmailRoute = () => {
useDocumentTitle(t`Email Verification | Dembrane`);
@@ -15,10 +16,11 @@ export const VerifyEmailRoute = () => {
const handleVerify = () => {
const token = search.get("token");
if (!token) {
- window.alert(t`Invalid token. Please try again.`);
+ toast.error(t`Invalid token. Please try again.`);
+ return;
}
- verifyMutation.mutate({ token: token ?? "" });
+ verifyMutation.mutate({ token });
};
const runOnlyOnce = useRef(true);
diff --git a/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx b/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx
index 7d5dab895..8a4d09797 100644
--- a/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx
+++ b/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx
@@ -10,7 +10,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
-import { useClipboard } from "@mantine/hooks";
+import { useClipboard, useDisclosure } from "@mantine/hooks";
import { IconRefresh } from "@tabler/icons-react";
import {
useMutation,
@@ -18,6 +18,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { useParams } from "react-router";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { CopyIconButton } from "@/components/common/CopyIconButton";
import { Markdown } from "@/components/common/Markdown";
import { toast } from "@/components/common/Toaster";
@@ -136,6 +137,10 @@ export const ProjectConversationOverviewRoute = () => {
const isMutationPending = pendingMutations.length > 0;
const clipboard = useClipboard();
+ const [
+ regenerateConfirmOpened,
+ { open: openRegenerateConfirm, close: closeRegenerateConfirm },
+ ] = useDisclosure(false);
return (
@@ -164,11 +169,7 @@ export const ProjectConversationOverviewRoute = () => {
- window.confirm(
- t`Are you sure you want to regenerate the summary? You will lose the current summary.`,
- ) && useHandleGenerateSummaryManually.mutate(true)
- }
+ onClick={openRegenerateConfirm}
{...testId(
"conversation-overview-regenerate-summary-button",
)}
@@ -282,6 +283,19 @@ export const ProjectConversationOverviewRoute = () => {
>
)}
+
+ Regenerate}
+ onConfirm={() => {
+ useHandleGenerateSummaryManually.mutate(true);
+ closeRegenerateConfirm();
+ }}
+ />
);
};
diff --git a/echo/frontend/src/routes/project/library/ProjectLibrary.tsx b/echo/frontend/src/routes/project/library/ProjectLibrary.tsx
index efad5112c..3e50d6f7b 100644
--- a/echo/frontend/src/routes/project/library/ProjectLibrary.tsx
+++ b/echo/frontend/src/routes/project/library/ProjectLibrary.tsx
@@ -24,6 +24,7 @@ import { formatRelative } from "date-fns";
import { useParams } from "react-router";
import { Breadcrumbs } from "@/components/common/Breadcrumbs";
import { CloseableAlert } from "@/components/common/ClosableAlert";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { useConversationsByProjectId } from "@/components/conversation/hooks";
import {
useGenerateProjectLibraryMutation,
@@ -68,6 +69,10 @@ export const ProjectLibraryRoute = () => {
const latestRun = latestRunQuery.data ?? null;
const [opened, { toggle, close }] = useDisclosure(false);
+ const [
+ generateConfirmOpened,
+ { open: openGenerateConfirm, close: closeGenerateConfirm },
+ ] = useDisclosure(false);
const isLibraryEnabled =
projectQuery.data?.is_enhanced_audio_processing_enabled ?? false;
@@ -95,17 +100,8 @@ export const ProjectLibraryRoute = () => {
const viewsExist = viewsQuery?.data && viewsQuery.data.length > 0;
- const handleCreateLibrary = async () => {
- if (
- window.confirm(
- t`Are you sure you want to generate the library? This will take a while and overwrite your current views and insights.`,
- )
- ) {
- requestProjectLibraryMutation.mutate({
- language: iso639_1,
- projectId: projectId ?? "",
- });
- }
+ const handleCreateLibrary = () => {
+ openGenerateConfirm();
};
const contactSales = () => {
@@ -304,6 +300,22 @@ export const ProjectLibraryRoute = () => {
/>
))}
+
+ Generate}
+ onConfirm={() => {
+ requestProjectLibraryMutation.mutate({
+ language: iso639_1,
+ projectId: projectId ?? "",
+ });
+ closeGenerateConfirm();
+ }}
+ />
);
};
diff --git a/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx b/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx
index 5dcd73b01..4eda9843a 100644
--- a/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx
+++ b/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx
@@ -41,6 +41,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router";
import { Breadcrumbs } from "@/components/common/Breadcrumbs";
import { CloseableAlert } from "@/components/common/ClosableAlert";
+import { ConfirmModal } from "@/components/common/ConfirmModal";
import { ExponentialProgress } from "@/components/common/ExponentialProgress";
import { CreateReportForm } from "@/components/report/CreateReportForm";
import {
@@ -978,7 +979,7 @@ export const ProjectReportRoute = () => {
backgroundColor: "var(--mantine-color-body)",
position: "sticky",
top: "1rem",
- zIndex: 10,
+ zIndex: 5,
}}
>
@@ -1260,12 +1261,15 @@ export const ProjectReportRoute = () => {
style={
fullscreen
? ({
+ "--mdx-toolbar-position": "sticky",
"--mdx-toolbar-top": "0px",
backgroundColor: "white",
overflow: "auto",
padding: "2rem",
} as React.CSSProperties)
- : undefined
+ : ({
+ "--mdx-toolbar-position": "sticky",
+ } as React.CSSProperties)
}
>
{
{/* Delete confirmation modal */}
-
-
-
- Are you sure you want to delete this report? This action cannot be
- undone.
-
-
-
-
- Cancel
-
-
- Delete
-
-
-
+ title={t`Delete report`}
+ message={t`Are you sure you want to delete this report? This action cannot be undone.`}
+ confirmLabel={Delete }
+ confirmColor="red"
+ loading={isDeletingReport}
+ onConfirm={handleConfirmDelete}
+ data-testid="report-delete-modal"
+ />
{/* Responsive CSS for mobile + pulse animation */}