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
pull/5111/head
Nic Luckie 3 weeks ago
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 adhoc `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 (12 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 / adhoc 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 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>
</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>
{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>
{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>
</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…
Cancel
Save