feat: add tag display and editing to email view

This commit is contained in:
rustmailer
2026-07-08 00:30:20 +08:00
parent 524201c26d
commit 166ac21549
5 changed files with 101 additions and 65 deletions
@@ -42,6 +42,8 @@ interface SearchContextType {
filter: Record<string, any>
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
handleTagToggle: (tag: string) => void
editTagsOpen: boolean
setEditTagsOpen: (open: boolean) => void
}
const SearchContext = React.createContext<SearchContextType | null>(null)
+61 -54
View File
@@ -19,8 +19,8 @@
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 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Plus, Tag as TagIcon, X, Loader2, Check, Search } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { useUpdateTags } from '@/hooks/use-update-tags';
@@ -29,6 +29,7 @@ import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
import { ScrollArea } from '@/components/ui/scroll-area';
interface Props {
open: boolean
@@ -44,7 +45,7 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
const [commandOpen, setCommandOpen] = useState(false);
const { t } = useTranslation();
const { currentEnvelope } = useSearchContext()
const { currentEnvelope, setCurrentEnvelope } = useSearchContext()
useEffect(() => {
if (open && currentEnvelope) {
@@ -111,6 +112,10 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
},
{
onSuccess: () => {
const finalTags = inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags;
setCurrentEnvelope(prev => prev ? { ...prev, tags: finalTags } : prev);
toast({
title: t('search.addTags.updatedTitle'),
description: (
@@ -149,7 +154,7 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
</DialogHeader>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.addTags.none')}</p>
) : (
@@ -166,60 +171,62 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.addTags.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.addTags.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 className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('search.addTags.searchPlaceholder')}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setCommandOpen(true);
}}
onFocus={() => setCommandOpen(true)}
className="h-9 pl-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
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>
</Command>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground">
{t('search.addTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<ScrollArea className="h-[15rem] w-full pr-4 -mr-4 py-1">
<div className="p-1">
{filteredSuggestions.map(tag => (
<button
key={tag}
type="button"
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={() => handleAddTag(tag)}
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</button>
))}
</div>
</ScrollArea>
)}
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex justify-between items-center pt-4 border-t">
<p className="text-xs text-muted-foreground">
{t('search.addTags.selectedCount', { count: selectedTags.length })}
</p>
+6 -3
View File
@@ -46,6 +46,7 @@ export default function EmailSearch() {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
const [editTagsOpen, setEditTagsOpen] = React.useState(false);
const {
emails,
@@ -98,7 +99,9 @@ export default function EmailSearch() {
setDeleteMailboxId,
selectedAccountId,
setSelectedAccountId,
handleTagToggle
handleTagToggle,
editTagsOpen,
setEditTagsOpen,
}}
>
<div className="mx-auto w-full px-4">
@@ -151,8 +154,8 @@ export default function EmailSearch() {
<EditTagsDialog
key='edit-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')}
open={editTagsOpen}
onOpenChange={setEditTagsOpen}
/>
<UpdateTagsDialog
+30 -6
View File
@@ -19,8 +19,9 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Pencil } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -53,6 +54,7 @@ interface MailMessageViewProps {
bcc?: string[];
subject?: string;
internal_date?: number;
tags: string[];
};
showActions?: boolean;
showHeader?: boolean;
@@ -121,7 +123,7 @@ export function MailMessageView({
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen, setSelected } = useSearchContext();
const { setToDelete, setOpen, setSelected, setEditTagsOpen } = useSearchContext();
const [content, setContent] = useState<string | null>(null);
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
@@ -260,6 +262,28 @@ export function MailMessageView({
<span>{formatTimestamp(envelope.internal_date)}</span>
</div>
)}
<div className="flex items-center space-x-2">
<span className="font-medium text-gray-400">{t('mail.tags')}:</span>
<div className="flex flex-wrap gap-1 items-center">
{envelope.tags && envelope.tags.length > 0 ? (
envelope.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] h-5 px-1.5">
{tag}
</Badge>
))
) : (
<span className="text-gray-400 italic">{t('mail.noTagsYet')}</span>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 ml-0.5"
onClick={() => setEditTagsOpen(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
</div>
</div>}
{showActions && (
@@ -268,7 +292,7 @@ export function MailMessageView({
<Separator orientation="horizontal" className="flex-1 bg-border" />
</div>
<div className="flex items-center justify-start gap-3 text-xs text-gray-500">
<Tooltip>
<Tooltip delayDuration={800}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleDelete} className="hover:text-destructive">
<Trash2 className="h-4 w-4" />
@@ -277,7 +301,7 @@ export function MailMessageView({
<TooltipContent>{t('mail.delete')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<Tooltip delayDuration={800}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
<Download className="h-4 w-4" />
@@ -286,7 +310,7 @@ export function MailMessageView({
<TooltipContent>{t('mail.download')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<Tooltip delayDuration={800}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@@ -299,7 +323,7 @@ export function MailMessageView({
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<Tooltip delayDuration={800}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@@ -37,7 +37,7 @@ interface DataTableRowActionsProps {
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentEnvelope, setSelected, setToDelete } = useSearchContext()
const { setOpen, setCurrentEnvelope, setSelected, setToDelete, setEditTagsOpen } = useSearchContext()
const { t } = useTranslation()
const toggleToDelete = (accountId: number, mailId: string) => {
@@ -81,7 +81,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
setOpen("edit-tags")
setEditTagsOpen(true)
}}
>
{t('search.editTag')}