feat(web): introduce accessible ConfirmDialog and migrate confirmations; add Markdown-safe descriptions

Why
- window.confirm is not supported on Brave Mobile for iOS, which blocked destructive actions like deleting memos. Replacing it with an accessible, app-native dialog restores mobile functionality and improves UX.

What changed
- New ConfirmDialog component
  - Replaces window.confirm usage across the app.
  - Props: open/onOpenChange, title, description or descriptionMarkdown, confirm/cancel labels, onConfirm, confirmVariant.
  - Prevents double-submit and accidental dismiss while confirming (loading state).
- Markdown support for dialog descriptions
  - descriptionMarkdown renders via marked and is sanitized with DOMPurify before injection.
  - Keeps translations readable (Markdown) and safe (sanitized HTML).
- Member management flows
  - Updated archive/delete dialogs to use ConfirmDialog.
  - Added toast notifications for archive, restore, and delete actions.
- i18n: added/updated relevant translation keys (en).

Accessibility and mobile
- Dialog buttons are touch-friendly.
- Escape and outside-click behavior matches expectations.

Manual Tests - Verified in Brave desktop (v1.82.166) and Brave for iOS (v1.81 (134))
- Memos:
  - Archive → confirm archival and shows success toast.
  - Restore (only when archived) → confirm restoration and shows success toast.
  - Delete → destructive dialog → confirm deletion and shows success toast.
- Shortcuts: create → menu → Delete → dialog appears; cancel keeps; confirm deletes and list refreshes.
- Access tokens: Settings → Access Tokens → Delete → dialog title shows masked token; confirm deletes.
- Members: Settings → Members → non-current user:
  - Archive → warning dialog → confirm archives.
  - Delete (only when archived) → destructive dialog → confirm deletes.
- Sessions: Settings → Sessions → Revoke non-current session → dialog appears; confirm revokes; current session remains disabled.
- Webhooks: Settings → Webhooks → Delete → dialog appears; confirm deletes and list refreshes.
- Mobile/accessibility: focus trap, inert background, tappable buttons, Escape/outside-click behavior verified.

Notes / follow-ups
- Deleting a member currently removes the account but does not cascade-delete the member’s content. Not sure if this is intended or not, so I left the warning description more general for now.
pull/5111/head
Nic Luckie 3 weeks ago
parent 7ab57f8ed2
commit 30795d1d9c

@ -40,6 +40,7 @@
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.486.0",
"marked": "^16.3.0",
"mermaid": "^11.11.0",
"mime": "^4.1.0",
"mobx": "^6.13.7",

@ -0,0 +1,72 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DOMPurify from "dompurify";
import { marked } from "marked";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
descriptionMarkdown?: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void | Promise<void>;
confirmVariant?: "default" | "destructive";
}
export default function ConfirmDialog({
open,
onOpenChange,
title,
description,
descriptionMarkdown,
confirmLabel,
cancelLabel,
onConfirm,
confirmVariant = "default",
}: ConfirmDialogProps) {
const [loading, setLoading] = React.useState(false);
const handleConfirm = async () => {
try {
setLoading(true);
await onConfirm();
onOpenChange(false);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
// prepare sanitized HTML if Markdown was provided
const descriptionHtml =
typeof descriptionMarkdown === "string"
? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown)))
: null;
return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{descriptionHtml ? (
<DialogDescription dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

@ -2,6 +2,7 @@ import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import ConfirmDialog from "@/components/ConfirmDialog";
import { shortcutServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { cn } from "@/lib/utils";
@ -11,6 +12,7 @@ import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { useTranslate } from "@/utils/i18n";
import CreateShortcutDialog from "../CreateShortcutDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import toast from "react-hot-toast";
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
@ -25,6 +27,7 @@ const ShortcutsSection = observer(() => {
const t = useTranslate();
const shortcuts = userStore.state.shortcuts;
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => {
@ -32,11 +35,15 @@ const ShortcutsSection = observer(() => {
}, []);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
if (confirmed) {
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
setDeleteTarget(shortcut);
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings();
}
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
const handleCreateShortcut = () => {
@ -113,6 +120,15 @@ const ShortcutsSection = observer(() => {
shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
</div>
);
});

@ -2,6 +2,7 @@ import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "luci
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb";
@ -25,6 +26,7 @@ const TagsSection = observer((props: Props) => {
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>("");
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
@ -52,14 +54,17 @@ const TagsSection = observer((props: Props) => {
};
const handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm"));
if (confirmed) {
setDeleteTagName(tag);
};
const confirmDeleteTag = async () => {
if (!deleteTagName) return;
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: tag,
tag: deleteTagName,
});
toast.success(t("message.deleted-successfully"));
}
setDeleteTagName(undefined);
};
return (
@ -139,6 +144,15 @@ const TagsSection = observer((props: Props) => {
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
<ConfirmDialog
open={!!deleteTagName}
onOpenChange={(open) => !open && setDeleteTagName(undefined)}
title={t("tag.delete-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteTag}
confirmVariant="destructive"
/>
</div>
);
});

@ -1,4 +1,5 @@
import copy from "copy-to-clipboard";
import { useState } from "react";
import {
ArchiveIcon,
ArchiveRestoreIcon,
@ -21,6 +22,7 @@ import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "./ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
@ -49,6 +51,8 @@ const MemoActionMenu = observer((props: Props) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const isComment = Boolean(memo.parent);
@ -101,7 +105,7 @@ const MemoActionMenu = observer((props: Props) => {
},
["state"],
);
toast(message);
toast.success(message);
} catch (error: any) {
toast.error(error.details);
console.error(error);
@ -123,21 +127,24 @@ const MemoActionMenu = observer((props: Props) => {
toast.success(t("message.succeed-copy-link"));
};
const handleDeleteMemoClick = async () => {
const confirmed = window.confirm(t("memo.delete-confirm"));
if (confirmed) {
const handleDeleteMemoClick = () => {
setDeleteDialogOpen(true);
};
const confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}
};
const handleRemoveCompletedTaskListItemsClick = async () => {
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm"));
if (confirmed) {
const handleRemoveCompletedTaskListItemsClick = () => {
setRemoveTasksDialogOpen(true);
};
const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
@ -164,7 +171,6 @@ const MemoActionMenu = observer((props: Props) => {
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
}
};
return (
@ -216,6 +222,27 @@ const MemoActionMenu = observer((props: Props) => {
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
descriptionMarkdown={t("memo.delete-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"
/>
{/* Remove completed tasks confirmation */}
<ConfirmDialog
open={removeTasksDialogOpen}
onOpenChange={setRemoveTasksDialogOpen}
title={t("memo.remove-completed-task-list-items-confirm")}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
onConfirm={confirmRemoveCompletedTaskListItems}
confirmVariant="destructive"
/>
</DropdownMenu>
);
});

@ -3,6 +3,7 @@ import { ClipboardIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
@ -20,6 +21,7 @@ const AccessTokenSection = () => {
const currentUser = useCurrentUser();
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
const createTokenDialog = useDialog();
const [deleteTarget, setDeleteTarget] = useState<UserAccessToken | undefined>(undefined);
useEffect(() => {
listAccessTokens(currentUser.name).then((accessTokens) => {
@ -42,12 +44,15 @@ const AccessTokenSection = () => {
};
const handleDeleteAccessToken = async (userAccessToken: UserAccessToken) => {
const formatedAccessToken = getFormatedAccessToken(userAccessToken.accessToken);
const confirmed = window.confirm(t("setting.access-token-section.access-token-deletion", { accessToken: formatedAccessToken }));
if (confirmed) {
await userServiceClient.deleteUserAccessToken({ name: userAccessToken.name });
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== userAccessToken.accessToken));
}
setDeleteTarget(userAccessToken);
};
const confirmDeleteAccessToken = async () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserAccessToken({ name: deleteTarget.name });
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== deleteTarget.accessToken));
setDeleteTarget(undefined);
toast.success(t("setting.access-token-section.access-token-deleted", { description: deleteTarget.description }));
};
const getFormatedAccessToken = (accessToken: string) => {
@ -134,6 +139,20 @@ const AccessTokenSection = () => {
onOpenChange={createTokenDialog.setOpen}
onSuccess={handleCreateAccessTokenDialogConfirm}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={
deleteTarget
? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description })
: ""
}
descriptionMarkdown={t("setting.access-token-section.access-token-deletion-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteAccessToken}
confirmVariant="destructive"
/>
</div>
);
};

@ -12,6 +12,8 @@ import { User, User_Role } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import CreateUserDialog from "../CreateUserDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import ConfirmDialog from "@/components/ConfirmDialog";
import toast from "react-hot-toast";
const MemberSection = observer(() => {
const t = useTranslate();
@ -21,6 +23,8 @@ const MemberSection = observer(() => {
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>();
const sortedUsers = sortBy(users, "id");
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
useEffect(() => {
fetchUsers();
@ -52,17 +56,21 @@ const MemberSection = observer(() => {
};
const handleArchiveUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.archive-warning", { username: user.displayName }));
if (confirmed) {
setArchiveTarget(user);
};
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
await userServiceClient.updateUser({
user: {
name: user.name,
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: ["state"],
});
setArchiveTarget(undefined);
toast.success(t("setting.member-section.archive-success", { username: archiveTarget.username }));
fetchUsers();
}
};
const handleRestoreUserClick = async (user: User) => {
@ -73,15 +81,20 @@ const MemberSection = observer(() => {
},
updateMask: ["state"],
});
toast.success(t("setting.member-section.restore-success", { username: user.username }));
fetchUsers();
};
const handleDeleteUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.delete-warning", { username: user.displayName }));
if (confirmed) {
await userStore.deleteUser(user.name);
setDeleteTarget(user);
};
const confirmDeleteUser = async () => {
if (!deleteTarget) return;
await userStore.deleteUser(deleteTarget.name);
setDeleteTarget(undefined);
toast.success(t("setting.member-section.delete-success", { username: deleteTarget.username }));
fetchUsers();
}
};
return (
@ -169,6 +182,28 @@ const MemberSection = observer(() => {
{/* Edit User Dialog */}
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => !open && setArchiveTarget(undefined)}
title={archiveTarget ? t("setting.member-section.archive-warning", { username: archiveTarget.username }) : ""}
description={archiveTarget ? t("setting.member-section.archive-warning-description") : ""}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
onConfirm={confirmArchiveUser}
confirmVariant="default"
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.member-section.delete-warning", { username: deleteTarget.username }) : ""}
descriptionMarkdown={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser}
confirmVariant="destructive"
/>
</div>
);
});

@ -2,6 +2,7 @@ import { MoreVerticalIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/grpcweb";
@ -15,6 +16,7 @@ const SSOSection = () => {
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
useEffect(() => {
fetchIdentityProviderList();
@ -26,16 +28,19 @@ const SSOSection = () => {
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
const confirmed = window.confirm(t("setting.sso-section.confirm-delete", { name: identityProvider.title }));
if (confirmed) {
setDeleteTarget(identityProvider);
};
const confirmDeleteIdentityProvider = async () => {
if (!deleteTarget) return;
try {
await identityProviderServiceClient.deleteIdentityProvider({ name: identityProvider.name });
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
await fetchIdentityProviderList();
}
setDeleteTarget(undefined);
};
const handleCreateIdentityProvider = () => {
@ -112,6 +117,16 @@ const SSOSection = () => {
identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.sso-section.confirm-delete", { name: deleteTarget.title }) : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteIdentityProvider}
confirmVariant="destructive"
/>
</div>
);
};

@ -2,6 +2,7 @@ import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { UserSession } from "@/types/proto/api/v1/user_service";
@ -16,6 +17,7 @@ const UserSessionsSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [userSessions, setUserSessions] = useState<UserSession[]>([]);
const [revokeTarget, setRevokeTarget] = useState<UserSession | undefined>(undefined);
useEffect(() => {
listUserSessions(currentUser.name).then((sessions) => {
@ -24,13 +26,15 @@ const UserSessionsSection = () => {
}, []);
const handleRevokeSession = async (userSession: UserSession) => {
const formattedSessionId = getFormattedSessionId(userSession.sessionId);
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId }));
if (confirmed) {
await userServiceClient.revokeUserSession({ name: userSession.name });
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId));
setRevokeTarget(userSession);
};
const confirmRevokeSession = async () => {
if (!revokeTarget) return;
await userServiceClient.revokeUserSession({ name: revokeTarget.name });
setUserSessions(userSessions.filter((session) => session.sessionId !== revokeTarget.sessionId));
toast.success(t("setting.user-sessions-section.session-revoked"));
}
setRevokeTarget(undefined);
};
const getFormattedSessionId = (sessionId: string) => {
@ -148,6 +152,26 @@ const UserSessionsSection = () => {
</div>
</div>
</div>
<ConfirmDialog
open={!!revokeTarget}
onOpenChange={(open) => !open && setRevokeTarget(undefined)}
title={
revokeTarget
? t("setting.user-sessions-section.session-revocation", {
sessionId: getFormattedSessionId(revokeTarget.sessionId),
})
: ""
}
descriptionMarkdown={
revokeTarget
? t("setting.user-sessions-section.session-revocation-description")
: ""
}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmRevokeSession}
confirmVariant="destructive"
/>
</div>
</div>
);

@ -2,17 +2,20 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { UserWebhook } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import CreateWebhookDialog from "../CreateWebhookDialog";
import toast from "react-hot-toast";
const WebhookSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState<UserWebhook[]>([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
const listWebhooks = async () => {
if (!currentUser) return [];
@ -35,11 +38,15 @@ const WebhookSection = () => {
};
const handleDeleteWebhook = async (webhook: UserWebhook) => {
const confirmed = window.confirm(`Are you sure to delete webhook \`${webhook.displayName}\`? You cannot undo this action.`);
if (confirmed) {
await userServiceClient.deleteUserWebhook({ name: webhook.name });
setWebhooks(webhooks.filter((item) => item.name !== webhook.name));
}
setDeleteTarget(webhook);
};
const confirmDeleteWebhook = async () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget?.displayName || "" }));
setDeleteTarget(undefined);
};
return (
@ -79,12 +86,7 @@ const WebhookSection = () => {
{webhook.url}
</td>
<td className="relative whitespace-nowrap px-3 py-2 text-right text-sm">
<Button
variant="ghost"
onClick={() => {
handleDeleteWebhook(webhook);
}}
>
<Button variant="ghost" onClick={() => handleDeleteWebhook(webhook)}>
<TrashIcon className="text-destructive w-4 h-auto" />
</Button>
</td>
@ -118,6 +120,16 @@ const WebhookSection = () => {
onOpenChange={setIsCreateWebhookDialogOpen}
onSuccess={handleCreateWebhookDialogConfirm}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
descriptionMarkdown={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteWebhook}
confirmVariant="destructive"
/>
</div>
);
};

@ -143,7 +143,8 @@
},
"copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo? THIS ACTION IS IRREVERSIBLE",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "**THIS ACTION IS IRREVERSIBLE**\n\nAttachments, links, and references will also be removed.",
"direction": "Direction",
"direction-asc": "Ascending",
"direction-desc": "Descending",
@ -174,7 +175,7 @@
"archived-successfully": "Archived successfully",
"change-memo-created-time": "Change memo created time",
"copied": "Copied",
"deleted-successfully": "Deleted successfully",
"deleted-successfully": "Memo deleted successfully",
"description-is-required": "Description is required",
"failed-to-embed-memo": "Failed to embed memo",
"fill-all": "Please fill in all fields.",
@ -219,6 +220,8 @@
},
"delete-resource": "Delete Resource",
"delete-selected-resources": "Delete Selected Resources",
"delete-all-unused": "Delete all unused",
"delete-all-unused-confirm": "Are you sure you want to delete all unused resources? THIS ACTION IS IRREVERSIBLE",
"fetching-data": "Fetching data…",
"file-drag-drop-prompt": "Drag and drop your file here to upload file",
"linked-amount": "Linked amount",
@ -226,7 +229,7 @@
"no-resources": "No resources.",
"no-unused-resources": "No unused resources",
"reset-link": "Reset Link",
"reset-link-prompt": "Are you sure to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
"reset-link-prompt": "Are you sure you want to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
"reset-resource-link": "Reset Resource Link",
"unused-resources": "Unused resources"
},
@ -237,7 +240,9 @@
"setting": {
"access-token-section": {
"access-token-copied-to-clipboard": "Access token copied to clipboard",
"access-token-deletion": "Are you sure to delete access token {{accessToken}}? THIS ACTION IS IRREVERSIBLE.",
"access-token-deletion": "Are you sure you want to delete access token \"{{description}}\"?",
"access-token-deletion-description": "**THIS ACTION IS IRREVERSIBLE**\n\nYou will need to update any services using this token to use a new token.",
"access-token-deleted": "Access token \"{{description}}\" deleted successfully",
"create-dialog": {
"create-access-token": "Create Access Token",
"created-at": "Created At",
@ -262,7 +267,8 @@
"expires": "Expires",
"current": "Current",
"never": "Never",
"session-revocation": "Are you sure to revoke session {{sessionId}}? You will need to sign in again on that device.",
"session-revocation": "Are you sure you want to revoke session `{{sessionId}}`?",
"session-revocation-description": "You will need to sign in again on that device.",
"session-revoked": "Session revoked successfully",
"revoke-session": "Revoke session",
"cannot-revoke-current": "Cannot revoke current session",
@ -286,10 +292,15 @@
"member-section": {
"admin": "Admin",
"archive-member": "Archive member",
"archive-warning": "Are you sure to archive {{username}}?",
"archive-warning": "Are you sure you want to archive {{username}}?",
"archive-warning-description": "Archiving disables the account. You can restore or delete it later.",
"archive-success": "{{username}} archived successfully",
"restore-success": "{{username}} restored successfully",
"create-a-member": "Create a member",
"delete-member": "Delete Member",
"delete-warning": "Are you sure to delete {{username}}? THIS ACTION IS IRREVERSIBLE",
"delete-warning": "Are you sure you want to delete {{username}}?",
"delete-warning-description": "**THIS ACTION IS IRREVERSIBLE**",
"delete-success": "{{username}} deleted successfully",
"user": "User"
},
"memo-related": "Memo",
@ -309,12 +320,16 @@
"default-memo-visibility": "Default memo visibility",
"theme": "Theme"
},
"shortcut": {
"delete-confirm": "Are you sure you want to delete shortcut \"{{title}}\"?",
"delete-success": "Shortcut \"{{title}}\" deleted successfully"
},
"sso": "SSO",
"sso-section": {
"authorization-endpoint": "Authorization endpoint",
"client-id": "Client ID",
"client-secret": "Client secret",
"confirm-delete": "Are you sure to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
"confirm-delete": "Are you sure you want to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
"create-sso": "Create SSO",
"custom": "Custom",
"delete-sso": "Confirm delete",
@ -367,7 +382,7 @@
"url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional",
"warning-text": "Are you sure to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
"warning-text": "Are you sure you want to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
},
"system": "System",
"system-section": {
@ -408,6 +423,11 @@
"title": "Title",
"url-example-post-receive": "https://example.com/postreceive"
},
"delete-dialog": {
"delete-webhook-description": "**THIS ACTION IS IRREVERSIBLE**",
"delete-webhook-title": "Are you sure you want to delete webhook `{{name}}`?",
"delete-webhook-success": "Webhook `{{name}}` deleted successfully"
},
"no-webhooks-found": "No webhooks found.",
"title": "Webhooks",
"url": "URL"
@ -427,7 +447,7 @@
"all-tags": "All Tags",
"create-tag": "Create Tag",
"create-tags-guide": "You can create tags by inputting `#tag`.",
"delete-confirm": "Are you sure to delete this tag? All related memos will be archived.",
"delete-confirm": "Are you sure you want to delete this tag? All related memos will be archived.",
"delete-tag": "Delete Tag",
"new-name": "New Name",
"no-tag-found": "No tag found",

@ -7,6 +7,7 @@ import AttachmentIcon from "@/components/AttachmentIcon";
import Empty from "@/components/Empty";
import MobileHeader from "@/components/MobileHeader";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -47,6 +48,7 @@ const Attachments = observer(() => {
const filteredAttachments = attachments.filter((attachment) => includes(attachment.filename, state.searchQuery));
const groupedAttachments = groupAttachmentsByDate(filteredAttachments.filter((attachment) => attachment.memo));
const unusedAttachments = filteredAttachments.filter((attachment) => !attachment.memo);
const [deleteUnusedOpen, setDeleteUnusedOpen] = useState(false);
useEffect(() => {
attachmentServiceClient.listAttachments({}).then(({ attachments }) => {
@ -57,16 +59,18 @@ const Attachments = observer(() => {
}, []);
const handleDeleteUnusedAttachments = async () => {
const confirmed = window.confirm("Are you sure to delete all unused attachments? This action cannot be undone.");
if (confirmed) {
setDeleteUnusedOpen(true);
};
const confirmDeleteUnusedAttachments = async () => {
for (const attachment of unusedAttachments) {
await attachmentServiceClient.deleteAttachment({ name: attachment.name });
}
setAttachments(attachments.filter((attachment) => attachment.memo));
}
};
return (
<>
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6">
@ -141,12 +145,17 @@ const Attachments = observer(() => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleDeleteUnusedAttachments}>
<Button
variant="ghost"
size="sm"
onClick={handleDeleteUnusedAttachments}
aria-label={t("resource.delete-all-unused")}
>
<TrashIcon className="w-4 h-auto opacity-60" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete all</p>
<p>{t("resource.delete-all-unused")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -175,7 +184,18 @@ const Attachments = observer(() => {
</div>
</div>
</section>
<ConfirmDialog
open={deleteUnusedOpen}
onOpenChange={setDeleteUnusedOpen}
title={t("resource.delete-all-unused-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUnusedAttachments}
confirmVariant="destructive"
/>
</>
);
});
export default Attachments;

Loading…
Cancel
Save