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 */}