diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 66fd93471..3c2ed4a28 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: lucide-react: specifier: ^0.486.0 version: 0.486.0(react@18.3.1) + marked: + specifier: ^16.3.0 + version: 16.3.0 mermaid: specifier: ^11.11.0 version: 11.11.0 @@ -2728,6 +2731,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.3.0: + resolution: {integrity: sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6224,6 +6232,8 @@ snapshots: marked@15.0.12: {} + marked@16.3.0: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.14: {} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx index af8b17db8..2691ec00f 100644 --- a/web/src/components/ConfirmDialog.tsx +++ b/web/src/components/ConfirmDialog.tsx @@ -1,8 +1,8 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; 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; @@ -42,10 +42,7 @@ export default function ConfirmDialog({ }; // prepare sanitized HTML if Markdown was provided - const descriptionHtml = - typeof descriptionMarkdown === "string" - ? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown))) - : null; + const descriptionHtml = typeof descriptionMarkdown === "string" ? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown))) : null; return ( !loading && onOpenChange(o)}> diff --git a/web/src/components/ConfirmDialog/README.md b/web/src/components/ConfirmDialog/README.md new file mode 100644 index 000000000..cd9cfc324 --- /dev/null +++ b/web/src/components/ConfirmDialog/README.md @@ -0,0 +1,161 @@ +# ConfirmDialog - Accessible Confirmation Dialog + +## Overview + +`ConfirmDialog` standardizes confirmation flows across the app. It replaces ad‑hoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations and optional Markdown descriptions. + +## Key Features + +### 1. Accessibility & UX +- Uses shared `Dialog` primitives (focus trap, ARIA roles) +- Blocks dismissal while async confirm is pending +- Clear separation of title (action) vs description (context) + +### 2. Markdown Support +- Optional `descriptionMarkdown` renders localized Markdown +- Sanitized via `DOMPurify` after `marked` parsing + +### 3. Async-Aware +- Accepts sync or async `onConfirm` +- Auto-closes on resolve; remains open on error for retry / toast + +### 4. Internationalization Ready +- All labels / text provided by caller through i18n hook +- Supports interpolation for dynamic context + +### 5. Minimal Surface, Easy Extension +- Lightweight API (few required props) +- Style hook via `.container` class (SCSS module) + +## Architecture + +``` +ConfirmDialog +├── State: loading (tracks pending confirm action) +├── Markdown pipeline: marked → DOMPurify → safe HTML (if descriptionMarkdown) +├── Dialog primitives: Header (title + description), Footer (buttons) +└── External control: parent owns open state via onOpenChange +``` + +## Usage + +### Basic +```tsx +import { useTranslate } from "@/utils/i18n"; +import ConfirmDialog from "@/components/ConfirmDialog"; + +const t = useTranslate(); + +; +``` + +### With Markdown Description & Interpolation +```tsx +; +``` + +## Props + +| Prop | Type | Required | Acceptable Values | +|------|------|----------|------------------| +| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) | +| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state | +| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) | +| `description` | `React.ReactNode` | No | Plain content; ignored if `descriptionMarkdown` provided | +| `descriptionMarkdown` | `string` | No | Localized Markdown string (sanitized) | +| `confirmLabel` | `string` | Yes | Non-empty localized action text (1–2 words) | +| `cancelLabel` | `string` | Yes | Localized cancel label | +| `onConfirm` | `() => void \| Promise` | Yes | Sync or async handler; resolve = close, reject = stay open | +| `confirmVariant` | `"default" \| "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions | + +## Benefits vs Previous Implementation + +### Before (window.confirm / ad‑hoc dialogs) +- Blocking native prompt, inconsistent styling +- No async progress handling +- No Markdown / rich formatting +- Hard to localize consistently + +### After (ConfirmDialog) +- Unified styling + accessibility semantics +- Async-safe with loading state shielding +- Markdown or plain description flexibility +- i18n-first via externalized labels + +## Technical Implementation Details + +### Async Handling +```tsx +const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(); // resolve -> close + onOpenChange(false); + } catch (e) { + console.error(e); // remain open for retry + } finally { + setLoading(false); + } +}; +``` + +### Markdown Sanitization +```tsx +const descriptionHtml = descriptionMarkdown + ? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown))) + : null; +``` + +### Close Guard +```tsx + !loading && onOpenChange(next)} /> +``` + +## Browser / Environment Support +- Works anywhere the existing `Dialog` primitives work (modern browsers) +- Requires DOM for Markdown + sanitization (not SSR executed unless guarded) +- No ResizeObserver / layout dependencies + +## Performance Considerations +1. Markdown parse + sanitize runs only when `descriptionMarkdown` changes +2. Minimal renders: loading state toggles once per confirm attempt +3. No portal churn—relies on underlying dialog infra + +## Future Enhancements +1. Severity icon / header accent +2. Auto-focus destructive button toggle +3. Secondary action (e.g. "Archive" vs "Delete") +4. Built-in retry / error slot +5. Optional checkbox confirmation ("I understand the consequences") +6. Motion/animation tokens integration + +## Styling +The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles. + +## Internationalization +All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description. + +## Error Handling +Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.) + +--- + +If you extend this component, update this README to keep usage discoverable. diff --git a/web/src/components/ConfirmDialog/index.tsx b/web/src/components/ConfirmDialog/index.tsx new file mode 100644 index 000000000..340c6ead2 --- /dev/null +++ b/web/src/components/ConfirmDialog/index.tsx @@ -0,0 +1,95 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import styles from "./ConfirmDialog.module.scss"; + +export interface ConfirmDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Open state change callback (closing disabled while loading) */ + onOpenChange: (open: boolean) => void; + /** Title content (plain text or React nodes) */ + title: React.ReactNode; + /** Optional description as React nodes (ignored if descriptionMarkdown provided) */ + description?: React.ReactNode; + /** Optional description in Markdown. Sanitized & rendered as HTML if provided */ + descriptionMarkdown?: string; + /** Confirm / primary action button label */ + confirmLabel: string; + /** Cancel button label */ + cancelLabel: string; + /** Async or sync confirm handler. Dialog auto-closes on resolve, stays open on reject */ + onConfirm: () => void | Promise; + /** Variant style of confirm button */ + confirmVariant?: "default" | "destructive"; +} + +/** + * Accessible confirmation dialog with optional Markdown description. + * - Renders description from either React nodes or sanitized Markdown + * - Prevents closing while async confirm action is in-flight + * - Minimal opinionated styling; leverages existing UI primitives + */ +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) { + // Intentionally swallow errors so user can retry; surface via caller's toast/logging + // TODO: Replace with a proper error reporting service, e.g., Sentry or custom logger + console.error("ConfirmDialog error:", e); + // reportError(e); + } finally { + setLoading(false); + } + }; + + // Prepare sanitized HTML if Markdown was provided, memoized for performance + const descriptionHtml = React.useMemo(() => { + return typeof descriptionMarkdown === "string" ? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown))) : null; + }, [descriptionMarkdown]); + + return ( + !loading && onOpenChange(o)}> + + + {title} + {/* + Rendering sanitized Markdown as HTML. + This is considered safe because DOMPurify removes any potentially dangerous content. + Ensure that Markdown input is trusted or validated upstream. + */} + {descriptionHtml ? ( + + ) : description ? ( + {description} + ) : null} + + + + + + + + ); +} diff --git a/web/src/components/HomeSidebar/ShortcutsSection.tsx b/web/src/components/HomeSidebar/ShortcutsSection.tsx index f50613080..87e767e53 100644 --- a/web/src/components/HomeSidebar/ShortcutsSection.tsx +++ b/web/src/components/HomeSidebar/ShortcutsSection.tsx @@ -1,8 +1,9 @@ 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 toast from "react-hot-toast"; import ConfirmDialog from "@/components/ConfirmDialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { shortcutServiceClient } from "@/grpcweb"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import { cn } from "@/lib/utils"; @@ -12,7 +13,6 @@ 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; diff --git a/web/src/components/HomeSidebar/TagsSection.tsx b/web/src/components/HomeSidebar/TagsSection.tsx index eff77ae4a..bed64fe2a 100644 --- a/web/src/components/HomeSidebar/TagsSection.tsx +++ b/web/src/components/HomeSidebar/TagsSection.tsx @@ -2,8 +2,8 @@ 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 ConfirmDialog from "@/components/ConfirmDialog"; import { Switch } from "@/components/ui/switch"; import { memoServiceClient } from "@/grpcweb"; import { useDialog } from "@/hooks/useDialog"; @@ -63,7 +63,7 @@ const TagsSection = observer((props: Props) => { parent: "memos/-", tag: deleteTagName, }); - toast.success(t("message.deleted-successfully")); + toast.success(t("tag.delete-success")); setDeleteTagName(undefined); }; diff --git a/web/src/components/MemoActionMenu.tsx b/web/src/components/MemoActionMenu.tsx index cb473ff96..a82550596 100644 --- a/web/src/components/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu.tsx @@ -1,5 +1,4 @@ import copy from "copy-to-clipboard"; -import { useState } from "react"; import { ArchiveIcon, ArchiveRestoreIcon, @@ -12,8 +11,10 @@ import { SquareCheckIcon, } from "lucide-react"; import { observer } from "mobx-react-lite"; +import { useState } from "react"; import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { markdownServiceClient } from "@/grpcweb"; import useNavigateTo from "@/hooks/useNavigateTo"; import { memoStore, userStore } from "@/store"; @@ -22,7 +23,6 @@ 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"; diff --git a/web/src/components/Settings/AccessTokenSection.tsx b/web/src/components/Settings/AccessTokenSection.tsx index 55505fa1a..94e63a896 100644 --- a/web/src/components/Settings/AccessTokenSection.tsx +++ b/web/src/components/Settings/AccessTokenSection.tsx @@ -2,8 +2,8 @@ import copy from "copy-to-clipboard"; 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 { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useDialog } from "@/hooks/useDialog"; @@ -30,8 +30,15 @@ const AccessTokenSection = () => { }, []); const handleCreateAccessTokenDialogConfirm = async () => { + const prevTokensSet = new Set(userAccessTokens.map((token) => token.accessToken)); const accessTokens = await listAccessTokens(currentUser.name); setUserAccessTokens(accessTokens); + const newToken = accessTokens.find((token) => !prevTokensSet.has(token.accessToken)); + toast.success( + t("setting.access-token-section.create-dialog.access-token-created", { + description: newToken?.description ?? t("setting.access-token-section.create-dialog.access-token-created-default"), + }), + ); }; const handleCreateToken = () => { @@ -49,10 +56,11 @@ const AccessTokenSection = () => { const confirmDeleteAccessToken = async () => { if (!deleteTarget) return; - await userServiceClient.deleteUserAccessToken({ name: deleteTarget.name }); - setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== deleteTarget.accessToken)); + const { name, accessToken, description } = deleteTarget; + await userServiceClient.deleteUserAccessToken({ name }); + setUserAccessTokens((prev) => prev.filter((token) => token.accessToken !== accessToken)); setDeleteTarget(undefined); - toast.success(t("setting.access-token-section.access-token-deleted", { description: deleteTarget.description })); + toast.success(t("setting.access-token-section.access-token-deleted", { description })); }; const getFormatedAccessToken = (accessToken: string) => { @@ -142,11 +150,7 @@ const AccessTokenSection = () => { !open && setDeleteTarget(undefined)} - title={ - deleteTarget - ? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description }) - : "" - } + 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")} diff --git a/web/src/components/Settings/MemberSection.tsx b/web/src/components/Settings/MemberSection.tsx index f7c03ed5c..2d4cc7b62 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -2,6 +2,8 @@ import { sortBy } from "lodash-es"; import { MoreVerticalIcon, PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -12,8 +14,6 @@ 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(); @@ -74,6 +74,7 @@ const MemberSection = observer(() => { }; const handleRestoreUserClick = async (user: User) => { + const { username } = user; await userServiceClient.updateUser({ user: { name: user.name, @@ -81,7 +82,7 @@ const MemberSection = observer(() => { }, updateMask: ["state"], }); - toast.success(t("setting.member-section.restore-success", { username: user.username })); + toast.success(t("setting.member-section.restore-success", { username })); fetchUsers(); }; @@ -91,9 +92,10 @@ const MemberSection = observer(() => { const confirmDeleteUser = async () => { if (!deleteTarget) return; - await userStore.deleteUser(deleteTarget.name); + const { username, name } = deleteTarget; + await userStore.deleteUser(name); setDeleteTarget(undefined); - toast.success(t("setting.member-section.delete-success", { username: deleteTarget.username })); + toast.success(t("setting.member-section.delete-success", { username })); fetchUsers(); }; diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 3760b1bb3..8cc31690c 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -1,8 +1,8 @@ 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 { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { identityProviderServiceClient } from "@/grpcweb"; diff --git a/web/src/components/Settings/UserSessionsSection.tsx b/web/src/components/Settings/UserSessionsSection.tsx index eceabd605..7e4f8ea89 100644 --- a/web/src/components/Settings/UserSessionsSection.tsx +++ b/web/src/components/Settings/UserSessionsSection.tsx @@ -1,8 +1,8 @@ import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon } 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 { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; import { UserSession } from "@/types/proto/api/v1/user_service"; @@ -162,12 +162,8 @@ const UserSessionsSection = () => { }) : "" } - descriptionMarkdown={ - revokeTarget - ? t("setting.user-sessions-section.session-revocation-description") - : "" - } - confirmLabel={t("common.delete")} + descriptionMarkdown={revokeTarget ? t("setting.user-sessions-section.session-revocation-description") : ""} + confirmLabel={t("setting.user-sessions-section.revoke-session-button")} cancelLabel={t("common.cancel")} onConfirm={confirmRevokeSession} confirmVariant="destructive" diff --git a/web/src/components/Settings/WebhookSection.tsx b/web/src/components/Settings/WebhookSection.tsx index 7e9b2b6df..5c231527e 100644 --- a/web/src/components/Settings/WebhookSection.tsx +++ b/web/src/components/Settings/WebhookSection.tsx @@ -1,14 +1,14 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; import ConfirmDialog from "@/components/ConfirmDialog"; +import { Button } from "@/components/ui/button"; 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(); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 72a0904da..1a72697d9 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -242,8 +242,10 @@ "access-token-copied-to-clipboard": "Access token copied to clipboard", "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", + "access-token-deleted": "Access token \"{{description}}\" deleted", "create-dialog": { + "access-token-created": "Access token \"{{description}}\" created", + "access-token-created-default": "Access token created", "create-access-token": "Create Access Token", "created-at": "Created At", "description": "Description", @@ -271,6 +273,7 @@ "session-revocation-description": "You will need to sign in again on that device.", "session-revoked": "Session revoked successfully", "revoke-session": "Revoke session", + "revoke-session-button": "Revoke", "cannot-revoke-current": "Cannot revoke current session", "no-sessions": "No active sessions found" }, @@ -449,6 +452,7 @@ "create-tags-guide": "You can create tags by inputting `#tag`.", "delete-confirm": "Are you sure you want to delete this tag? All related memos will be archived.", "delete-tag": "Delete Tag", + "delete-success": "Tag deleted successfully", "new-name": "New Name", "no-tag-found": "No tag found", "old-name": "Old Name", diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index 1bc2b1320..e8b743be6 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -1,16 +1,13 @@ import dayjs from "dayjs"; import { includes } from "lodash-es"; -import { PaperclipIcon, SearchIcon, TrashIcon } from "lucide-react"; +import { PaperclipIcon, SearchIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; 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"; import { attachmentServiceClient } from "@/grpcweb"; import useLoading from "@/hooks/useLoading"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; @@ -48,7 +45,6 @@ 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 }) => { @@ -58,144 +54,106 @@ const Attachments = observer(() => { }); }, []); - const handleDeleteUnusedAttachments = async () => { - setDeleteUnusedOpen(true); - }; - - const confirmDeleteUnusedAttachments = async () => { - for (const attachment of unusedAttachments) { - await attachmentServiceClient.deleteAttachment({ name: attachment.name }); - } - setAttachments(attachments.filter((attachment) => attachment.memo)); - }; - return ( <>
- {!md && } -
-
-
-

- - {t("common.attachments")} -

-
-
- - setState({ ...state, searchQuery: e.target.value })} - /> + {!md && } +
+
+
+

+ + {t("common.attachments")} +

+
+
+ + setState({ ...state, searchQuery: e.target.value })} + /> +
-
-
- {loadingState.isLoading ? ( -
-

{t("resource.fetching-data")}

-
- ) : ( - <> - {filteredAttachments.length === 0 ? ( -
- -

{t("message.no-data")}

-
- ) : ( -
- {Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => { - return ( -
-
- {dayjs(monthStr).year()} - - {dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })} - -
-
- {attachments.map((attachment) => { - return ( -
-
- -
-
-

{attachment.filename}

+
+ {loadingState.isLoading ? ( +
+

{t("resource.fetching-data")}

+
+ ) : ( + <> + {filteredAttachments.length === 0 ? ( +
+ +

{t("message.no-data")}

+
+ ) : ( +
+ {Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => { + return ( +
+
+ {dayjs(monthStr).year()} + + {dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })} + +
+
+ {attachments.map((attachment) => { + return ( +
+
+ +
+
+

{attachment.filename}

+
-
- ); - })} + ); + })} +
-
- ); - })} + ); + })} - {unusedAttachments.length > 0 && ( - <> - -
-
-
-
- {t("resource.unused-resources")} - ({unusedAttachments.length}) - - - - - - -

{t("resource.delete-all-unused")}

-
-
-
-
- {unusedAttachments.map((attachment) => { - return ( -
-
- -
-
-

{attachment.filename}

+ {unusedAttachments.length > 0 && ( + <> + +
+
+
+
+ {t("resource.unused-resources")} + ({unusedAttachments.length}) +
+ {unusedAttachments.map((attachment) => { + return ( +
+
+ +
+
+

{attachment.filename}

+
-
- ); - })} + ); + })} +
-
- - )} -
- )} - - )} + + )} +
+ )} + + )} +
-
- ); }); export default Attachments; -