mirror of https://github.com/usememos/memos.git
fix(web): improve destructive flows, i18n specificity, and remove unused attachments dialog
- Attachments: reverted unused bulk "Delete all unused" ConfirmDialog and pruned related unused vars (flow not user-triggered) - Members: capture username before clearing archive/delete targets to avoid brittle state reads - Access tokens: capture fields before clearing delete target; safe toast + functional state update - Sessions: use “Revoke” label instead of generic delete wording - Tags: replace incorrect generic deletion success message with tag.delete-success i18n key - ConfirmDialog: restructured into its own folder (index + module + README) to align with component organization guidelines - General: eliminate reliance on reading state immediately after reset; tighten handler robustness
This commit is contained in:
parent
30795d1d9c
commit
9beb6ca5c1
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("memo.delete-confirm")}
|
||||
description={t("memo.delete-confirm-description")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={handleDelete}
|
||||
confirmVariant="destructive"
|
||||
/>;
|
||||
```
|
||||
|
||||
### With Markdown Description & Interpolation
|
||||
```tsx
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("setting.shortcut.delete-confirm")}
|
||||
descriptionMarkdown={t("setting.shortcut.delete-confirm-markdown", { title: shortcut.title })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={deleteShortcut}
|
||||
confirmVariant="destructive"
|
||||
/>;
|
||||
```
|
||||
|
||||
## 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<void>` | 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
|
||||
<Dialog open={open} onOpenChange={(next) => !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.
|
||||
|
|
@ -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<void>;
|
||||
/** 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 (
|
||||
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||
<DialogContent size="sm" className={styles.container}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{/*
|
||||
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 ? (
|
||||
<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} data-loading={loading ? true : undefined}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !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")}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<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">
|
||||
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="relative w-full flex flex-row justify-between items-center">
|
||||
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
|
||||
<PaperclipIcon className="w-6 h-auto mr-1 opacity-80" />
|
||||
<span className="text-lg">{t("common.attachments")}</span>
|
||||
</p>
|
||||
<div>
|
||||
<div className="relative max-w-32">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t("common.search")}
|
||||
value={state.searchQuery}
|
||||
onChange={(e) => setState({ ...state, searchQuery: e.target.value })}
|
||||
/>
|
||||
{!md && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
<div className="relative w-full flex flex-row justify-between items-center">
|
||||
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
|
||||
<PaperclipIcon className="w-6 h-auto mr-1 opacity-80" />
|
||||
<span className="text-lg">{t("common.attachments")}</span>
|
||||
</p>
|
||||
<div>
|
||||
<div className="relative max-w-32">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t("common.search")}
|
||||
value={state.searchQuery}
|
||||
onChange={(e) => setState({ ...state, searchQuery: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-4 mb-6">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="w-full h-32 flex flex-col justify-center items-center">
|
||||
<p className="w-full text-center text-base my-6 mt-8">{t("resource.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredAttachments.length === 0 ? (
|
||||
<div className="w-full mt-8 mb-8 flex flex-col justify-center items-center italic">
|
||||
<Empty />
|
||||
<p className="mt-4 text-muted-foreground">{t("message.no-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"w-full h-auto px-2 flex flex-col justify-start items-start gap-y-8"}>
|
||||
{Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => {
|
||||
return (
|
||||
<div key={monthStr} className="w-full flex flex-row justify-start items-start">
|
||||
<div className="w-16 sm:w-24 pt-4 sm:pl-4 flex flex-col justify-start items-start">
|
||||
<span className="text-sm opacity-60">{dayjs(monthStr).year()}</span>
|
||||
<span className="font-medium text-xl">
|
||||
{dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<div key={attachment.name} className="w-24 sm:w-32 h-auto flex flex-col justify-start items-start">
|
||||
<div className="w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80">
|
||||
<AttachmentIcon attachment={attachment} strokeWidth={0.5} />
|
||||
</div>
|
||||
<div className="w-full max-w-full flex flex-row justify-between items-center mt-1 px-1">
|
||||
<p className="text-xs shrink text-muted-foreground truncate">{attachment.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{unusedAttachments.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="w-full flex flex-row justify-start items-start">
|
||||
<div className="w-16 sm:w-24 sm:pl-4 flex flex-col justify-start items-start"></div>
|
||||
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
|
||||
<div className="w-full flex flex-row justify-start items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("resource.unused-resources")}</span>
|
||||
<span className="text-muted-foreground opacity-80">({unusedAttachments.length})</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>{t("resource.delete-all-unused")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="w-full flex flex-col justify-start items-start mt-4 mb-6">
|
||||
{loadingState.isLoading ? (
|
||||
<div className="w-full h-32 flex flex-col justify-center items-center">
|
||||
<p className="w-full text-center text-base my-6 mt-8">{t("resource.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredAttachments.length === 0 ? (
|
||||
<div className="w-full mt-8 mb-8 flex flex-col justify-center items-center italic">
|
||||
<Empty />
|
||||
<p className="mt-4 text-muted-foreground">{t("message.no-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"w-full h-auto px-2 flex flex-col justify-start items-start gap-y-8"}>
|
||||
{Array.from(groupedAttachments.entries()).map(([monthStr, attachments]) => {
|
||||
return (
|
||||
<div key={monthStr} className="w-full flex flex-row justify-start items-start">
|
||||
<div className="w-16 sm:w-24 pt-4 sm:pl-4 flex flex-col justify-start items-start">
|
||||
<span className="text-sm opacity-60">{dayjs(monthStr).year()}</span>
|
||||
<span className="font-medium text-xl">
|
||||
{dayjs(monthStr).toDate().toLocaleString(i18n.language, { month: "short" })}
|
||||
</span>
|
||||
</div>
|
||||
{unusedAttachments.map((attachment) => {
|
||||
return (
|
||||
<div key={attachment.name} className="w-24 sm:w-32 h-auto flex flex-col justify-start items-start">
|
||||
<div className="w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80">
|
||||
<AttachmentIcon attachment={attachment} strokeWidth={0.5} />
|
||||
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<div key={attachment.name} className="w-24 sm:w-32 h-auto flex flex-col justify-start items-start">
|
||||
<div className="w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80">
|
||||
<AttachmentIcon attachment={attachment} strokeWidth={0.5} />
|
||||
</div>
|
||||
<div className="w-full max-w-full flex flex-row justify-between items-center mt-1 px-1">
|
||||
<p className="text-xs shrink text-muted-foreground truncate">{attachment.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-w-full flex flex-row justify-between items-center mt-1 px-1">
|
||||
<p className="text-xs shrink text-muted-foreground truncate">{attachment.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{unusedAttachments.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="w-full flex flex-row justify-start items-start">
|
||||
<div className="w-16 sm:w-24 sm:pl-4 flex flex-col justify-start items-start"></div>
|
||||
<div className="w-full max-w-[calc(100%-4rem)] sm:max-w-[calc(100%-6rem)] flex flex-row justify-start items-start gap-4 flex-wrap">
|
||||
<div className="w-full flex flex-row justify-start items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("resource.unused-resources")}</span>
|
||||
<span className="text-muted-foreground opacity-80">({unusedAttachments.length})</span>
|
||||
</div>
|
||||
{unusedAttachments.map((attachment) => {
|
||||
return (
|
||||
<div key={attachment.name} className="w-24 sm:w-32 h-auto flex flex-col justify-start items-start">
|
||||
<div className="w-24 h-24 flex justify-center items-center sm:w-32 sm:h-32 border border-border overflow-clip rounded-xl cursor-pointer hover:shadow hover:opacity-80">
|
||||
<AttachmentIcon attachment={attachment} strokeWidth={0.5} />
|
||||
</div>
|
||||
<div className="w-full max-w-full flex flex-row justify-between items-center mt-1 px-1">
|
||||
<p className="text-xs shrink text-muted-foreground truncate">{attachment.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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…
Reference in New Issue