feat: Ability to add/remove tags from any list of messages #189

This commit is contained in:
rustmailer
2026-03-26 21:22:21 +08:00
parent 5d0039cb74
commit 3f11c5dbbf
30 changed files with 805 additions and 66 deletions
+22 -2
View File
@@ -19,7 +19,7 @@
import { useRef } from 'react'
import { X, Trash2, Upload } from 'lucide-react'
import { X, Trash2, Upload, TagIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -66,6 +66,11 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
setOpen('restore')
}
const handleUpdateTags = () => {
setOpen('update-tags')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons || buttons.length === 0) return
@@ -174,7 +179,22 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
{t('restore_message.restore_to_imap', 'Restore Mail')}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="sm"
onClick={handleUpdateTags}
className="gap-1"
>
<TagIcon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.manageTags')}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1,259 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Plus, Tag as TagIcon, X, Loader2, Check, AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { TagAction, useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function UpdateTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const [action, setAction] = useState<TagAction>('Overwrite');
const { t } = useTranslation();
const { selected } = useSearchContext()
const handleAddTag = (tag: string) => {
const normalized = tag.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
setCommandOpen(false);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(prev => prev.filter(t => t !== tag));
};
const handleSubmit = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates: Record<number, string[]> = {};
selected.forEach((tagSet, accountId) => {
updates[accountId] = Array.from(tagSet);
});
let finalTags = inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags;
if (finalTags.length === 0 && action !== 'Overwrite') {
return;
}
mutate(
{
updates,
tags: finalTags,
action
},
{
onSuccess: () => {
toast({
title: t('search.updateTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>{t('search.updateTags.updatedDesc')}</span>
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
toast({
title: t('search.updateTags.updateFailedTitle'),
description: error?.message || t('search.updateTags.tryAgain'),
variant: 'destructive',
});
},
}
);
};
const filteredSuggestions = availableTags.filter(
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md min-h-[50vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
{t('search.updateTags.title')}
</DialogTitle>
</DialogHeader>
<Tabs value={action} onValueChange={(v) => setAction(v as TagAction)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="Add" className="text-xs">{t('search.updateTags.actionAdd', "Add")}</TabsTrigger>
<TabsTrigger value="Remove" className="text-xs">{t('search.updateTags.actionRemove', "remove")}</TabsTrigger>
<TabsTrigger value="Overwrite" className="text-xs">{t('search.updateTags.actionOverwrite', "overwrite")}</TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.updateTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} >
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.updateTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
className="h-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
e.stopPropagation();
handleAddTag(inputValue);
}
}}
/>
{inputValue.trim() && (
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1 h-7 w-7 p-0"
onClick={() => handleAddTag(inputValue)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
{t('search.updateTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
<CommandGroup>
{filteredSuggestions.map(tag => (
<CommandItem
key={tag}
onSelect={() => handleAddTag(tag)}
className="cursor-pointer"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</div>
</Command>
</div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{t('search.updateTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isPending} variant={action === 'Remove' ? 'destructive' : 'default'}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t(`search.updateTags.saving${action}`)}
</>
) : (
t(`search.updateTags.submit${action}`)
)}
</Button>
</div>
</div>
{action == "Overwrite" && <div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-500">
<AlertTriangle className="h-5 w-5 shrink-0" />
<p className="text-xs leading-relaxed">
{t('search.updateTags.overwriteWarning')}
</p>
</div>}
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import React from 'react'
import { EmailEnvelope } from '@/api'
import { SortingState } from '@tanstack/react-table'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore' | 'delete-mailbox'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
interface SearchContextType {
open: SearchDialogType | null
@@ -107,6 +107,7 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
tags: inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags,
action: "Overwrite"
},
{
onSuccess: () => {
+8 -1
View File
@@ -28,12 +28,13 @@ import { MailDisplayDrawer } from './mail-display-dialog';
import { EnvelopeDeleteDialog } from './delete-dialog';
import SearchProvider, { SearchDialogType } from './context';
import useDialogState from '@/hooks/use-dialog-state';
import { EditTagsDialog } from './add-tag-dialog';
import { EditTagsDialog } from './edit-tag-dialog';
import { useTranslation } from 'react-i18next';
import { RestoreMessageDialog } from './restore-message-dialog';
import { MailListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
import { UpdateTagsDialog } from './bulk-add-tag-dialog';
export default function Search() {
const { t } = useTranslation()
@@ -154,6 +155,12 @@ export default function Search() {
onOpenChange={() => setOpen('edit-tags')}
/>
<UpdateTagsDialog
key='edit-tags-dialog'
open={open === 'update-tags'}
onOpenChange={() => setOpen('update-tags')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
+16 -10
View File
@@ -146,12 +146,18 @@ export function MailListTable({
<span className="text-[11px] truncate font-medium leading-none">
{mailbox_name}
</span>
{safeTags.length > 0 && (
<span className="text-[9px] text-primary/70 truncate leading-none mt-0.5">
{visibleTags.join(' · ')}
{safeTags.length > 2 && ` · +${safeTags.length - 2}`}
</span>
)}
<div className="flex flex-wrap gap-1 mt-1">
{visibleTags.map((tag, i) => (
<span key={i} className="px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary text-[9px] font-medium leading-none border border-primary/20 whitespace-nowrap">
{tag}
</span>
))}
{safeTags.length > 2 && (
<span className="px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-500 text-[9px] font-medium leading-none border border-gray-200">
+{safeTags.length - 2}
</span>
)}
</div>
</div>
</div>
);
@@ -242,7 +248,7 @@ export function MailListTable({
header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
maxSize: 600,
maxSize: 450,
},
{
id: "text_preview",
@@ -317,14 +323,14 @@ export function MailListTable({
)
},
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
minSize: 130,
maxSize: 130,
},
{
id: 'actions',
header: t('users.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'text-left text-xs' },
meta: { className: 'text-right text-xs' },
minSize: 50,
maxSize: 60,
},
+1 -1
View File
@@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
return (
<div className="flex flex-1 flex-col gap-0.5">
{children && (<>{children(table)}</>)}
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
<ShadcnTable>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (