mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add tag display and editing to email view
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user