From 8da4a7b9c231e32e00ebe79009e1f8c5f37fa785 Mon Sep 17 00:00:00 2001 From: Usama Date: Fri, 3 Apr 2026 16:29:02 +0000 Subject: [PATCH] Replace window.confirm/alert/prompt with branded modals and fix file cleanup on avatar/logo removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all window.confirm() calls with ConfirmModal across 12 components: ChatAccordion (delete chat), ConversationDangerZone (delete conversation), ProjectDangerZone (delete project — now 2-step confirmation), ProjectPortalEditor (disable anonymization, delete custom topic), ProjectTagsInput (delete tag), LanguagePicker (change language during chat), ProjectConversationOverview (regenerate summary), ProjectLibrary (generate library), ProjectReportRoute (delete report), TemplatesModal (delete template), AccountSettingsCard (remove avatar), WhitelabelLogoCard (remove logo) - Replace window.prompt() with InputModal in ChatAccordion (rename chat) - Replace window.alert() / native alert() with toast notifications: PermissionErrorModal (microphone denied), VerifyEmail (invalid token), useCopyToRichText (clipboard failure) - Add reusable ConfirmModal and InputModal components to common/ - Align template edit view buttons: Delete as subtle red button + Save as primary, in a right-aligned Group (matching ConfirmModal layout) - Backend: delete orphaned Directus files when removing avatar or whitelabel logo (previously files were left behind) - Backend: wrap blocking Directus calls in run_in_thread_pool for async safety - Fix MarkdownWYSIWYG toolbar position to use CSS variable instead of hardcoded sticky, and set it from report editor context - Minor formatting cleanups (line length, import ordering) - Doc updates for future references --- echo/AGENTS.md | 10 ++ echo/frontend/AGENTS.md | 5 + .../src/components/chat/ChatAccordion.tsx | 135 +++++++++++------- .../src/components/chat/TemplatesModal.tsx | 118 +++++++-------- .../src/components/common/ConfirmModal.tsx | 57 ++++++++ .../src/components/common/InputModal.tsx | 92 ++++++++++++ .../conversation/ConversationDangerZone.tsx | 42 +++--- .../form/MarkdownWYSIWYG/styles.css | 2 +- .../components/language/LanguagePicker.tsx | 87 ++++++----- .../frontend/src/components/layout/Header.tsx | 120 +++++----------- .../participant/PermissionErrorModal.tsx | 3 +- .../components/project/ProjectDangerZone.tsx | 75 +++++----- .../project/ProjectPortalEditor.tsx | 107 +++++--------- .../components/project/ProjectTagsInput.tsx | 80 ++++++----- .../settings/AccountSettingsCard.tsx | 89 ++++++------ .../settings/WhitelabelLogoCard.tsx | 34 ++++- .../src/components/view/CreateViewForm.tsx | 30 +++- echo/frontend/src/hooks/useCopyToRichText.ts | 4 +- echo/frontend/src/routes/auth/VerifyEmail.tsx | 6 +- .../ProjectConversationOverview.tsx | 26 +++- .../routes/project/library/ProjectLibrary.tsx | 34 +++-- .../project/report/ProjectReportRoute.tsx | 45 ++---- echo/server/dembrane/api/user_settings.py | 73 +++++++++- 23 files changed, 761 insertions(+), 513 deletions(-) create mode 100644 echo/frontend/src/components/common/ConfirmModal.tsx create mode 100644 echo/frontend/src/components/common/InputModal.tsx 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")} - > - 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")} + <> + + + - Delete - - - - + + + + + + + } + disabled={deleteChatMutation.isPending} + onClick={openRename} + {...testId("chat-item-menu-rename")} + > + Rename + + } + disabled={deleteChatMutation.isPending} + onClick={openDeleteConfirm} + {...testId("chat-item-menu-delete")} + > + 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. - - - - - - - - + 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 = ({ )} - - {view === "edit" && editingId && ( - setDeletingTemplateId(editingId)} + + {view === "edit" && editingId && ( + + )} + +
@@ -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 */} + + + + +); 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 ( + +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + setValue(e.currentTarget.value)} + autoFocus + data-testid={dataTestId ? `${dataTestId}-input` : undefined} + /> + + + + + +
+
+ ); +}; 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 = ({ - - - + 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. - - - - - - - - + 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. - - - - - - - - + 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 && ( @@ -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. - - - - - - - + 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 */}