mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
update
This commit is contained in:
@@ -357,9 +357,9 @@ pub async fn detach_and_store_attachments(
|
||||
for (raw_start, raw_end, att) in ranges {
|
||||
// Step 2: Extract raw bytes and store them as standalone documents
|
||||
let raw_bytes = &original_body[raw_start..raw_end];
|
||||
let content_hash = compute_content_hash(raw_bytes);
|
||||
let content_hash = compute_content_hash(att.contents());
|
||||
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));
|
||||
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
|
||||
|
||||
// Step 3: Replace raw attachment content with a hash-based placeholder
|
||||
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
|
||||
@@ -474,14 +474,6 @@ pub async fn reattach_eml_content(
|
||||
|
||||
for (start, end, hash) in tasks {
|
||||
if let Some(original_data) = BLOB_MANAGER.get_attachment(&hash)? {
|
||||
let actual_hash = compute_content_hash(&original_data);
|
||||
if actual_hash != hash {
|
||||
error!(
|
||||
"[ERROR] Content Hash Mismatch! Expected: {}, Actual: {}",
|
||||
hash, actual_hash
|
||||
);
|
||||
continue;
|
||||
}
|
||||
restored_eml.splice(start..end, original_data.iter().cloned());
|
||||
} else {
|
||||
error!("[ERROR] Missing attachment blob for hash: {}", hash);
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
// 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/>.
|
||||
|
||||
|
||||
use crate::modules::logger::{validate_log_level, LocalTimer};
|
||||
use crate::modules::logger::LocalTimer;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::modules::settings::dir::DATA_DIR_MANAGER;
|
||||
use std::sync::OnceLock;
|
||||
@@ -30,9 +29,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
|
||||
|
||||
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
validate_log_level(&SETTINGS.bichon_log_level);
|
||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
||||
pub fn setup_file_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||
|
||||
let (server_nonb, server_guard) = server_log_writer();
|
||||
|
||||
+19
-15
@@ -19,9 +19,9 @@
|
||||
use crate::modules::logger::file::setup_file_logger;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use chrono::Local;
|
||||
use tracing_log::LogTracer;
|
||||
use std::process;
|
||||
use tracing::Level;
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
|
||||
|
||||
mod file;
|
||||
@@ -35,17 +35,18 @@ impl FormatTime for LocalTimer {
|
||||
}
|
||||
|
||||
pub fn initialize_logging() {
|
||||
LogTracer::init().unwrap();
|
||||
let level = validate_log_level(&SETTINGS.bichon_log_level);
|
||||
if matches!(level, Level::DEBUG) || matches!(level, Level::TRACE) {
|
||||
LogTracer::init().unwrap();
|
||||
}
|
||||
if SETTINGS.bichon_log_to_file {
|
||||
setup_file_logger().unwrap();
|
||||
setup_file_logger(level).unwrap();
|
||||
} else {
|
||||
setup_stdout_logger().unwrap();
|
||||
setup_stdout_logger(level).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
validate_log_level(&SETTINGS.bichon_log_level);
|
||||
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
|
||||
fn setup_stdout_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
|
||||
let with_ansi = SETTINGS.bichon_ansi_logs;
|
||||
|
||||
let format = tracing_subscriber::fmt::format()
|
||||
@@ -63,13 +64,16 @@ fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultErro
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
}
|
||||
|
||||
fn validate_log_level(value: &String) {
|
||||
if value.parse::<Level>().is_err() {
|
||||
eprintln!(
|
||||
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
||||
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
||||
value
|
||||
);
|
||||
process::exit(1);
|
||||
fn validate_log_level(value: &String) -> Level {
|
||||
match value.parse::<Level>() {
|
||||
Ok(level) => level,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
|
||||
The log level you currently specified is 'rustmailer_log_level'='{}'",
|
||||
value
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ pub async fn retrieve_attachment_content(
|
||||
let (_, eml) = reattach_eml_content(account_id, envelope_id).await?;
|
||||
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Failed to parse parent EML".into(),
|
||||
"Failed to parse EML".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
@@ -51,7 +51,7 @@ pub async fn retrieve_attachment_content(
|
||||
.map(|att| att.contents())
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Target nested EML not found".into(),
|
||||
"Target attachment not found".into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -122,6 +122,7 @@ pub struct AttachmentSearchFilter {
|
||||
pub max_size: Option<u64>,
|
||||
|
||||
pub attachment_name: Option<String>,
|
||||
pub content_hash: Option<String>,
|
||||
|
||||
pub tags: Option<HashSet<String>>,
|
||||
pub attachment_extension: Option<String>,
|
||||
|
||||
@@ -343,6 +343,13 @@ impl IndexManager {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
|
||||
|
||||
if let Some(content_hash) = &filter.content_hash {
|
||||
let term = Term::from_field_text(f.f_content_hash, content_hash);
|
||||
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
|
||||
if let Some(ref name) = filter.attachment_name {
|
||||
let query_parser =
|
||||
QueryParser::for_index(&self.index, vec![f.f_name_text, f.f_name_exact]);
|
||||
|
||||
@@ -315,15 +315,17 @@ impl IndexManager {
|
||||
}
|
||||
|
||||
if let Some(ref subject_val) = filter.subject {
|
||||
let term = Term::from_field_text(f.f_subject, subject_val);
|
||||
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
let query_parser = QueryParser::for_index(&self.index, vec![f.f_subject]);
|
||||
if let Ok(q) = query_parser.parse_query(subject_val) {
|
||||
subqueries.push((Occur::Must, q));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref body_val) = filter.body {
|
||||
let term = Term::from_field_text(f.f_body, body_val);
|
||||
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
let query_parser = QueryParser::for_index(&self.index, vec![f.f_body]);
|
||||
if let Ok(q) = query_parser.parse_query(body_val) {
|
||||
subqueries.push((Occur::Must, q));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref tags) = filter.tags {
|
||||
|
||||
@@ -137,4 +137,11 @@ export interface AttachmentMetadata {
|
||||
export const get_attachment_meta = async () => {
|
||||
const response = await axiosInstance.get<AttachmentMetadata>("api/v1/attachment_metadata");
|
||||
return response.data;
|
||||
};
|
||||
};
|
||||
|
||||
export const get_envelope = async (accountId: number, id: string) => {
|
||||
const response = await axiosInstance.get<EmailEnvelope>(`api/v1/envelope/${accountId}/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ import {
|
||||
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { useAttachmentContext } from './context'
|
||||
|
||||
export function AccountPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { filter, setFilter } = useAttachmentContext()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//
|
||||
// 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 { Paperclip, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from './context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AttachmentFilter() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const hasAttachment = filter?.has_attachment === true
|
||||
|
||||
const toggleAttachment = () => {
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
if (next.has_attachment) {
|
||||
delete next.has_attachment
|
||||
} else {
|
||||
next.has_attachment = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={toggleAttachment}
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
|
||||
hasAttachment
|
||||
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
|
||||
: "text-muted-foreground border-r-0"
|
||||
)}
|
||||
>
|
||||
<Paperclip
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
hasAttachment ? "opacity-100" : "opacity-60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{t('mail.attachments')}
|
||||
</span>
|
||||
|
||||
{hasAttachment && (
|
||||
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -15,7 +15,7 @@ interface MetaFilterProps {
|
||||
|
||||
export function MetadataFilter({ type, icon }: MetaFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { filter, setFilter } = useAttachmentContext()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { data: meta, isLoading } = useAttachmentMetadata(open)
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
//
|
||||
// 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 { useRef } from 'react'
|
||||
import { X, TagIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSearchContext } from './context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MailBulkActionsProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AttachmentBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { selected, setSelected, setOpen } = useSearchContext()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const selectedCount = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
const handleUpdateTags = () => {
|
||||
setOpen('update-tags')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
btn => btn === document.activeElement
|
||||
)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
const next = (currentIndex + 1) % buttons.length
|
||||
buttons[next]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prev]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
const target = e.target as HTMLElement
|
||||
const active = document.activeElement as HTMLElement
|
||||
const isFromDropdown =
|
||||
target.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
target.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (!isFromDropdown) {
|
||||
e.preventDefault()
|
||||
handleClearSelection()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role="toolbar"
|
||||
aria-label={t('search.bulkActions.ariaLabel', {
|
||||
count: selectedCount,
|
||||
})}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleClearSelection}
|
||||
className="size-6 rounded-full"
|
||||
aria-label={t('search.bulkActions.clear')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">{t('search.bulkActions.clear')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="flex items-center gap-x-1 text-sm">
|
||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
</div>
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
//
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -21,13 +21,15 @@ import React from 'react'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
|
||||
export type AttachmentDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
setOpen: (str: SearchDialogType | null) => void
|
||||
currentEnvelope: AttachmentModel | undefined
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
|
||||
interface AttachmentContextType {
|
||||
open: AttachmentDialogType | null
|
||||
setOpen: (str: AttachmentDialogType | null) => void
|
||||
currentAttachment: AttachmentModel | undefined
|
||||
setCurrentAttachment: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
|
||||
toDelete: Map<number, Set<string>>
|
||||
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
|
||||
selected: Map<number, Set<string>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
|
||||
deleteMailboxId: string | undefined
|
||||
@@ -42,25 +44,25 @@ interface SearchContextType {
|
||||
handleTagToggle: (tag: string) => void
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
||||
const AttachmentContext = React.createContext<AttachmentContextType | null>(null)
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
value: SearchContextType
|
||||
value: AttachmentContextType
|
||||
}
|
||||
|
||||
export default function SearchProvider({ children, value }: Props) {
|
||||
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
|
||||
export default function AttachmentProvider({ children, value }: Props) {
|
||||
return <AttachmentContext.Provider value={value}>{children}</AttachmentContext.Provider>
|
||||
}
|
||||
|
||||
export const useSearchContext = () => {
|
||||
const searchContext = React.useContext(SearchContext)
|
||||
export const useAttachmentContext = () => {
|
||||
const attachmentContext = React.useContext(AttachmentContext)
|
||||
|
||||
if (!searchContext) {
|
||||
if (!attachmentContext) {
|
||||
throw new Error(
|
||||
'useSearchContext has to be used within <SearchContext.Provider>'
|
||||
'useAttachmentContext has to be used within <AttachmentContext.Provider>'
|
||||
)
|
||||
}
|
||||
|
||||
return searchContext
|
||||
return attachmentContext
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_messages } from '@/api/mailbox/envelope/api'
|
||||
import { useSearchContext } from './context'
|
||||
import { useAttachmentContext } from './context'
|
||||
import { mapToRecordOfArrays } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
|
||||
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const { toDelete, setToDelete, setSelected } = useSearchContext()
|
||||
const { toDelete, setToDelete, setSelected } = useAttachmentContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -41,8 +41,8 @@ export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
|
||||
delete_messages(payload),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['search-attachments'], exact: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['attachment-tags'] })
|
||||
onOpenChange(false)
|
||||
setToDelete(new Map())
|
||||
setSelected(new Map())
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
//
|
||||
// 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 { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delete_mailbox } from '@/api/mailbox/api';
|
||||
import { useSearchContext } from './context';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
|
||||
delete_mailbox(accountId, mailboxId),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
|
||||
onOpenChange(false);
|
||||
setDeleteMailboxId(undefined);
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.successTitle'),
|
||||
description: t('mailbox.deleteMailboxDialog.successDesc'),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.errorTitle'),
|
||||
description: error.message || "Delete failed",
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedAccountId && deleteMailboxId) {
|
||||
deleteMutation.mutate({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: deleteMailboxId
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) setDeleteMailboxId(undefined);
|
||||
}}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('mailbox.deleteMailboxDialog.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('mailbox.deleteMailboxDialog.desc')}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
//
|
||||
// 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 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { 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';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditTagsDialog({ 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 { t } = useTranslation();
|
||||
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentEnvelope) {
|
||||
setSelectedTags(currentEnvelope.tags || []);
|
||||
}
|
||||
}, [open, currentEnvelope]);
|
||||
|
||||
if (!currentEnvelope) return null;
|
||||
|
||||
const handleAddTag = (tag: string) => {
|
||||
const normalized = tag.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.addTags.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 handleSave = () => {
|
||||
if (inputValue.trim()) {
|
||||
const normalized = inputValue.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.addTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
const updates = {
|
||||
[currentEnvelope.account_id]: [currentEnvelope.id],
|
||||
};
|
||||
|
||||
mutate(
|
||||
{
|
||||
updates,
|
||||
tags: inputValue.trim()
|
||||
? [...selectedTags, inputValue.toLowerCase().trim()]
|
||||
: selectedTags,
|
||||
action: "Overwrite"
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t('search.addTags.updatedTitle'),
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>{t('search.addTags.updatedDesc')}</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('search.addTags.updateFailedTitle'),
|
||||
description: error?.message || t('search.addTags.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">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TagIcon className="h-5 w-5" />
|
||||
{t('search.addTags.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<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.addTags.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.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>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('search.addTags.selectedCount', { count: selectedTags.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('search.addTags.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('search.addTags.saving')}
|
||||
</>
|
||||
) : (
|
||||
t('search.addTags.save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { filter, setFilter } = useAttachmentContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
|
||||
@@ -22,18 +22,22 @@ import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { AttachmentListPagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import AttachmentProvider, { AttachmentDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AttachmentListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
import { useSearchAttachments } from '@/hooks/use-search-attachments';
|
||||
import { AttachmentModel } from '@/api/attachment/api';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
|
||||
export default function AttachmentSearch() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedAttachment, setSelectedAttachment] = React.useState<AttachmentModel | undefined>(undefined);
|
||||
const [open, setOpen] = useDialogState<SearchDialogType>(null)
|
||||
const [currentAttachment, setCurrentAttachment] = React.useState<AttachmentModel | undefined>(undefined);
|
||||
const [open, setOpen] = useDialogState<AttachmentDialogType>(null)
|
||||
const [toDelete, setToDelete] = React.useState<Map<number, Set<string>>>(new Map());
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<string>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
@@ -72,13 +76,15 @@ export default function AttachmentSearch() {
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider
|
||||
<AttachmentProvider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentEnvelope: selectedAttachment,
|
||||
currentAttachment,
|
||||
selectedTags,
|
||||
setCurrentEnvelope: setSelectedAttachment,
|
||||
setCurrentAttachment,
|
||||
toDelete,
|
||||
setToDelete,
|
||||
selected,
|
||||
setSelected,
|
||||
sorting,
|
||||
@@ -109,10 +115,6 @@ export default function AttachmentSearch() {
|
||||
<AttachmentListTable
|
||||
isLoading={isLoading}
|
||||
items={attachments}
|
||||
onAttachmentChanged={(att) => {
|
||||
setOpen('display');
|
||||
setSelectedAttachment(att);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
@@ -128,42 +130,24 @@ export default function AttachmentSearch() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <MailDisplayDrawer
|
||||
key='search-mail-display'
|
||||
<MailDisplayDrawer
|
||||
key='attachment-mail-display'
|
||||
open={open === 'display'}
|
||||
onOpenChange={() => setOpen('display')}
|
||||
/>
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
key='delete-envelope'
|
||||
key='delete-attachment-envelope'
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => setOpen('delete')}
|
||||
/>
|
||||
|
||||
<EditTagsDialog
|
||||
key='edit-attachment-tags-dialog'
|
||||
open={open === 'edit-tags'}
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<UpdateTagsDialog
|
||||
key='update-attachment-tags-dialog'
|
||||
open={open === 'update-tags'}
|
||||
onOpenChange={() => setOpen('update-tags')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
key='attachment-restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete-mailbox'}
|
||||
onOpenChange={() => setOpen('delete-mailbox')}
|
||||
/> */}
|
||||
</SearchProvider>
|
||||
</AttachmentProvider>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useSearchContext } from './context'
|
||||
import { useAttachmentContext } from './context'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { MailMessageView } from './mail-message-view'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MailMessageView } from './mail-message-view'
|
||||
import { useEnvelope } from '@/hooks/use-envelope'
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -31,31 +32,35 @@ interface Props {
|
||||
|
||||
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
const { currentAttachment } = useAttachmentContext()
|
||||
|
||||
const {
|
||||
data: envelope,
|
||||
isLoading,
|
||||
error
|
||||
} = useEnvelope(currentAttachment?.account_id, currentAttachment?.envelope_id);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='w-full md:max-w-6xl mx-auto h-full'>
|
||||
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{t('mail.emailViewer')}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogTitle>{t('mail.emailViewer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-100px)]">
|
||||
<div className='m-5'>
|
||||
{currentEnvelope ? (
|
||||
<MailMessageView envelope={currentEnvelope} />
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('common.loading')}...</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-500">{t('attachment.emailMessageNotFound')}</div>
|
||||
) : envelope ? (
|
||||
<MailMessageView envelope={envelope} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -21,8 +21,7 @@ import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useSearchContext } from "./context"
|
||||
import { AttachmentBulkActions } from "./bulk-actions"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
@@ -34,13 +33,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { DataTableToolbar } from "./table/toolbar"
|
||||
import { AttachmentModel } from "@/api/attachment/api"
|
||||
import { useSearchAttachments } from "@/hooks/use-search-attachments"
|
||||
import { FileIcon } from "lucide-react"
|
||||
import { AttachmentIcon } from "./attachment-icon"
|
||||
|
||||
interface MailListProps {
|
||||
items: AttachmentModel[]
|
||||
isLoading: boolean
|
||||
onAttachmentChanged: (attachment: AttachmentModel) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
}
|
||||
@@ -48,14 +45,13 @@ interface MailListProps {
|
||||
export function AttachmentListTable({
|
||||
items,
|
||||
isLoading,
|
||||
onAttachmentChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
const { selected, setSelected, setOpen, setCurrentAttachment } = useAttachmentContext()
|
||||
|
||||
const columns: ColumnDef<AttachmentModel>[] = [
|
||||
{
|
||||
@@ -133,12 +129,34 @@ export function AttachmentListTable({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left' }
|
||||
meta: { className: 'text-left text-xs' }
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('attachment.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
|
||||
<span className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentAttachment(row.original);
|
||||
setOpen("display");
|
||||
}}
|
||||
className="hover:text-primary hover:underline transition-colors truncate"
|
||||
>
|
||||
<LongText>{row.original.subject}</LongText>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 300,
|
||||
maxSize: 300,
|
||||
@@ -277,11 +295,7 @@ export function AttachmentListTable({
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onAttachmentChanged(row.original)
|
||||
}}
|
||||
onRowClick={() => { }}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
>
|
||||
@@ -290,7 +304,6 @@ export function AttachmentListTable({
|
||||
}}
|
||||
|
||||
</SearchTable>
|
||||
{totalSelected > 0 && <AttachmentBulkActions />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//
|
||||
// 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 { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { AttachmentBulkActions } from "./bulk-actions"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || []);
|
||||
set.add(item.id);
|
||||
next.set(item.account_id, set);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: string) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
const hasSelected = (accountId: number, mailId: string) => {
|
||||
return selected.get(accountId)?.has(mailId) ?? false;
|
||||
}
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setToDelete(new Map());
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalSelected > 0
|
||||
? `${t('search.bulkActions.selected', { count: totalSelected })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => {
|
||||
const hasAttachments = item.regular_attachment_count > 0
|
||||
const isSelectedRow = currentEnvelope?.id === item.id
|
||||
const isChecked = hasSelected(item.account_id, item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelectedRow && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(item)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
|
||||
<span className="truncate">{item.account_email}</span>
|
||||
<span className="scale-75 opacity-50">•</span>
|
||||
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
|
||||
</div>
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{item.regular_attachment_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
|
||||
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("edit-tags");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('search.editTag')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && <AttachmentBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,24 +35,16 @@ import {
|
||||
load_message,
|
||||
} from '@/api/mailbox/envelope/api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useSearchContext } from './context';
|
||||
import { useAttachmentContext } from './context';
|
||||
import { MailThreadDialog } from './thread-dialog';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NestedEmailDialog } from './nested-email-dialog';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
|
||||
|
||||
interface MailMessageViewProps {
|
||||
envelope: {
|
||||
id: string;
|
||||
account_id: number,
|
||||
from?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject?: string;
|
||||
internal_date?: number;
|
||||
};
|
||||
envelope: EmailEnvelope;
|
||||
showActions?: boolean;
|
||||
showHeader?: boolean;
|
||||
showAttachments?: boolean;
|
||||
@@ -120,7 +112,7 @@ export function MailMessageView({
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
||||
const { setToDelete, setOpen, setSelected } = useAttachmentContext();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
|
||||
@@ -403,7 +395,7 @@ export function MailMessageView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
|
||||
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} currentEnvelope={envelope} />
|
||||
<NestedEmailDialog
|
||||
open={!!nestedEmlFile}
|
||||
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
|
||||
|
||||
@@ -58,7 +58,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
import { list_mailboxes } from '@/api/mailbox/api';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { useSearchContext } from './context';
|
||||
import { useAttachmentContext } from './context';
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
|
||||
|
||||
const CustomCollapse = styled(Collapse)({ padding: 0 });
|
||||
@@ -149,7 +149,7 @@ function CustomLabel({
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
|
||||
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useAttachmentContext();
|
||||
const { minimalList = [] } = useMinimalAccountList();
|
||||
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Info, ListFilter } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -49,7 +49,7 @@ const getPresetFromSize = (min?: number, max?: number) => {
|
||||
|
||||
export function MoreFiltersPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { filter, setFilter } = useAttachmentContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
|
||||
@@ -24,8 +24,9 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useSearchContext } from './context'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { useAttachmentContext } from './context'
|
||||
import { useEnvelope } from '@/hooks/use-envelope'
|
||||
|
||||
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
|
||||
return (
|
||||
@@ -82,7 +83,7 @@ export function RestoreMessageDialog({
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope, selected } = useSearchContext()
|
||||
const { selected, currentAttachment } = useAttachmentContext()
|
||||
|
||||
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
|
||||
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
|
||||
@@ -90,6 +91,10 @@ export function RestoreMessageDialog({
|
||||
|
||||
const isBulk = selectedCount > 0;
|
||||
|
||||
const {
|
||||
data: currentEnvelope,
|
||||
} = useEnvelope(currentAttachment?.account_id, currentAttachment?.envelope_id);
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -22,14 +22,14 @@ import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDown, Mail } from "lucide-react"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from "./context"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { userAttachmentSenders } from "@/hooks/use-attachment-senders"
|
||||
import { Group } from "@/api/system/api"
|
||||
import { MetadataSelectorField } from "./attachment-metadata-selector"
|
||||
|
||||
export function SenderFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { filter, setFilter } = useAttachmentContext()
|
||||
const { senders, isLoading } = userAttachmentSenders("")
|
||||
|
||||
const activeCount = filter.from ? 1 : 0
|
||||
|
||||
@@ -23,21 +23,43 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MoreVertical, TagIcon } from 'lucide-react'
|
||||
import { useSearchContext } from '../context'
|
||||
import { Copy, Download, MoreVertical } from 'lucide-react'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
import { useSearchAttachments } from '@/hooks/use-search-attachments'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { download_attachment } from '@/api/mailbox/envelope/api'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AttachmentModel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, setSelected } = useSearchContext()
|
||||
const { setFilter } = useSearchAttachments();
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast();
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: (content_hash: string) =>
|
||||
download_attachment(
|
||||
row.original.account_id,
|
||||
row.original.envelope_id,
|
||||
content_hash,
|
||||
row.original.name ?? row.original.id
|
||||
),
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mail.failedToDownloadFile'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,17 +73,34 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<span className='sr-only'>Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuContent align='end' className='w-[180px]'>
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setOpen("edit-tags")
|
||||
setFilter((prev: any) => ({ ...prev, content_hash: row.original.content_hash }));
|
||||
}}
|
||||
>
|
||||
{t('attachment.editTag')}
|
||||
{t('attachment.showDuplicates')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
disabled={downloadMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault();
|
||||
downloadMutation.mutate(row.original.content_hash);
|
||||
}}
|
||||
>
|
||||
{downloadMutation.isPending
|
||||
? t('attachment.downloading')
|
||||
: t('attachment.download')}
|
||||
<DropdownMenuShortcut>
|
||||
<Download size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from '../context'
|
||||
import { useAttachmentContext } from '../context'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
|
||||
@@ -65,7 +65,7 @@ interface DataTableProps {
|
||||
}
|
||||
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||
const { sorting, setSorting } = useSearchContext()
|
||||
const { sorting, setSorting } = useAttachmentContext()
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { SenderFilterPopover } from '../sender-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
@@ -31,8 +30,6 @@ export function DataTableToolbar<TData>({
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
<SenderFilterPopover />
|
||||
<TagFilterPopover />
|
||||
|
||||
<MetadataFilter
|
||||
type="extension"
|
||||
icon={<FileType className="h-3.5 w-3.5" />}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
//
|
||||
// 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 * as React from 'react'
|
||||
import { Tag, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { useAvailableAttachmentTags } from '@/hooks/use-available-attachment-tags'
|
||||
|
||||
export function TagFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const selectedTags = (filter?.tags as string[]) || []
|
||||
|
||||
const {
|
||||
tagsCount = [],
|
||||
isLoading,
|
||||
} = useAvailableAttachmentTags()
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const currentTags = (next.tags as string[]) || []
|
||||
const isSelected = currentTags.includes(tag)
|
||||
|
||||
const nextTags = isSelected
|
||||
? currentTags.filter(t => t !== tag)
|
||||
: [...currentTags, tag]
|
||||
|
||||
if (nextTags.length > 0) {
|
||||
next.tags = nextTags
|
||||
} else {
|
||||
delete next.tags
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllTags = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.tags
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredTags = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return tagsCount
|
||||
.filter(t =>
|
||||
!q || t.tag.toLowerCase().includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSelected = selectedTags.includes(a.tag)
|
||||
const bSelected = selectedTags.includes(b.tag)
|
||||
if (aSelected && !bSelected) return -1
|
||||
if (!aSelected && bSelected) return 1
|
||||
return b.count - a.count
|
||||
})
|
||||
}, [tagsCount, search, selectedTags])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 gap-1.5 px-3 rounded-none border-l-0',
|
||||
selectedTags.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t('tag.label')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-96 p-1"
|
||||
>
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('tag.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{!search && selectedTags.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
onClick={clearAllTags}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="flex-1 text-xs font-medium">
|
||||
{t('tag.clear_all')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('tag.no_tags_found')}
|
||||
</p>
|
||||
) : (
|
||||
filteredTags.map(({ tag, count }) => {
|
||||
const checked = selectedTags.includes(tag)
|
||||
const id = `tag-${tag}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => handleTagToggle(tag)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
handleTagToggle(tag)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
title={tag}
|
||||
>
|
||||
{tag}
|
||||
</Label>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, X, Clock, Trash2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useAttachmentContext } from "./context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
@@ -34,7 +34,7 @@ const SEARCH_FIELDS: SearchField[] = ["text", "subject", "attachment_name", "fro
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { filter, setFilter } = useAttachmentContext()
|
||||
|
||||
const [value, setValue] = useState("")
|
||||
const [field, setField] = useState<SearchField>("text")
|
||||
|
||||
@@ -30,18 +30,18 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { get_thread_messages } from '@/api/mailbox/envelope/api';
|
||||
import { MailMessageView } from './mail-message-view';
|
||||
import { useSearchContext } from './context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { MailMessageView } from './mail-message-view';
|
||||
|
||||
interface MailThreadDialogProps {
|
||||
open: boolean;
|
||||
currentEnvelope: EmailEnvelope
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
|
||||
const { currentEnvelope } = useSearchContext();
|
||||
export function MailThreadDialog({ open, onOpenChange, currentEnvelope }: MailThreadDialogProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -28,14 +28,14 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { useAttachmentContext } from './context'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
|
||||
const DAY = 86400000
|
||||
|
||||
export function TimePopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { filter, setFilter } = useAttachmentContext()
|
||||
const [customDays, setCustomDays] = React.useState<string>('')
|
||||
|
||||
const since = filter.since
|
||||
@@ -163,10 +163,10 @@ export function TimePopover() {
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
<span className="w-20 text-right text-[10px] font-medium">
|
||||
{t('time.since').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="w-full">
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
@@ -176,10 +176,10 @@ export function TimePopover() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
<span className="w-20 text-right text-[10px] font-medium">
|
||||
{t('time.before').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="w-full">
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
@@ -90,6 +91,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className='text-xs'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
@@ -104,11 +106,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original)
|
||||
}}
|
||||
className='!text-red-500'
|
||||
className='!text-red-500 text-xs'
|
||||
>
|
||||
{t('common.delete')}
|
||||
<DropdownMenuShortcut>
|
||||
|
||||
@@ -163,10 +163,10 @@ export function TimePopover() {
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
<span className="w-20 text-right text-[10px] font-medium">
|
||||
{t('time.since').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="w-full">
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
@@ -176,10 +176,10 @@ export function TimePopover() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
<span className="w-20 text-right text-[10px] font-medium">
|
||||
{t('time.before').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="w-full">
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { get_envelope } from '@/api/mailbox/envelope/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const useEnvelope = (
|
||||
accountId: number | undefined,
|
||||
envelopeId: string | undefined
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['envelope', accountId, envelopeId],
|
||||
queryFn: () => get_envelope(accountId!, envelopeId!),
|
||||
enabled: !!accountId && !!envelopeId,
|
||||
staleTime: 10000,
|
||||
retry: false
|
||||
});
|
||||
};
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "مطابقة اسم المرفق، موضوع الرسالة، والمرسل",
|
||||
"date": "التاريخ",
|
||||
"download": "تنزيل",
|
||||
"downloading": "جاري التنزيل...",
|
||||
"emailMessageNotFound": "تعذر العثور على رسالة البريد الإلكتروني الأصلية. ربما تم حذفها.",
|
||||
"name": "اسم الملف",
|
||||
"search_input_placeholder": "بحث عن المرفقات (استخدم \" \" للبحث عن عبارة)",
|
||||
"sender": "المرسل",
|
||||
"sender_with_count": "المرسل ({{count}})",
|
||||
"showDuplicates": "إظهار الملفات المكررة",
|
||||
"size": "حجم الملف",
|
||||
"source": "المصدر",
|
||||
"subject": "الموضوع"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Matcher vedhæftet filnavn, emne og afsender",
|
||||
"date": "Dato",
|
||||
"download": "Download",
|
||||
"downloading": "Downloader...",
|
||||
"emailMessageNotFound": "Kan ikke finde den originale e-mail. Den er muligvis blevet slettet.",
|
||||
"name": "Filnavn",
|
||||
"search_input_placeholder": "Søg efter vedhæftede filer (brug \" \" til frasesøgning)",
|
||||
"sender": "Afsender",
|
||||
"sender_with_count": "Afsender ({{count}})",
|
||||
"showDuplicates": "Vis dubletter",
|
||||
"size": "Filstørrelse",
|
||||
"source": "Kilde",
|
||||
"subject": "Emne"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Gleicht Anhangsname, Betreff und Absender ab",
|
||||
"date": "Datum",
|
||||
"download": "Herunterladen",
|
||||
"downloading": "Herunterladen...",
|
||||
"emailMessageNotFound": "Die ursprüngliche E-Mail wurde nicht gefunden. Sie wurde möglicherweise gelöscht.",
|
||||
"name": "Dateiname",
|
||||
"search_input_placeholder": "Anhänge durchsuchen (verwenden Sie \" \" für die Phrasensuche)",
|
||||
"sender": "Absender",
|
||||
"sender_with_count": "Absender ({{count}})",
|
||||
"showDuplicates": "Duplikate anzeigen",
|
||||
"size": "Dateigröße",
|
||||
"source": "Quelle",
|
||||
"subject": "Betreff"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Matches attachment name, subject, and sender",
|
||||
"date": "Date",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading...",
|
||||
"emailMessageNotFound": "Unable to find the original email. It may have been deleted.",
|
||||
"name": "Filename",
|
||||
"search_input_placeholder": "Search attachments (use \" \" for phrase search)",
|
||||
"sender": "Sender",
|
||||
"sender_with_count": "Sender ({{count}})",
|
||||
"showDuplicates": "Show duplicates",
|
||||
"size": "File size",
|
||||
"source": "Source",
|
||||
"subject": "Subject"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Coincide con el nombre del adjunto, asunto y remitente",
|
||||
"date": "Fecha",
|
||||
"download": "Descargar",
|
||||
"downloading": "Descargando...",
|
||||
"emailMessageNotFound": "No se pudo encontrar el correo electrónico original. Es posible que se haya eliminado.",
|
||||
"name": "Nombre de archivo",
|
||||
"search_input_placeholder": "Buscar adjuntos (use \" \" para búsqueda de frases)",
|
||||
"sender": "Remitente",
|
||||
"sender_with_count": "Remitente ({{count}})",
|
||||
"showDuplicates": "Mostrar duplicados",
|
||||
"size": "Tamaño del archivo",
|
||||
"source": "Fuente",
|
||||
"subject": "Asunto"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Täsmää liitteen nimeen, aiheeseen ja lähettäjään",
|
||||
"date": "Päivämäärä",
|
||||
"download": "Lataa",
|
||||
"downloading": "Ladataan...",
|
||||
"emailMessageNotFound": "Alkuperäistä sähköpostia ei löytynyt. Se on ehkä poistettu.",
|
||||
"name": "Tiedostonimi",
|
||||
"search_input_placeholder": "Hae liitteitä (käytä \" \" lausehakuun)",
|
||||
"sender": "Lähettäjä",
|
||||
"sender_with_count": "Lähettäjä ({{count}})",
|
||||
"showDuplicates": "Näytä kaksoiskappaleet",
|
||||
"size": "Tiedostokoko",
|
||||
"source": "Lähde",
|
||||
"subject": "Aihe"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Correspond au nom de la pièce jointe, à l'objet et à l'expéditeur",
|
||||
"date": "Date",
|
||||
"download": "Télécharger",
|
||||
"downloading": "Téléchargement en cours...",
|
||||
"emailMessageNotFound": "Impossible de trouver l'e-mail original. Il a peut-être été supprimé.",
|
||||
"name": "Nom du fichier",
|
||||
"search_input_placeholder": "Rechercher des pièces jointes (utilisez \" \" pour la recherche par expression)",
|
||||
"sender": "Expéditeur",
|
||||
"sender_with_count": "Expéditeur ({{count}})",
|
||||
"showDuplicates": "Afficher les doublons",
|
||||
"size": "Taille du fichier",
|
||||
"source": "Source",
|
||||
"subject": "Objet"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Corrisponde al nome dell'allegato, all'oggetto e al mittente",
|
||||
"date": "Data",
|
||||
"download": "Scarica",
|
||||
"downloading": "Download in corso...",
|
||||
"emailMessageNotFound": "Impossibile trovare l'email originale. Potrebbe essere stata eliminata.",
|
||||
"name": "Nome file",
|
||||
"search_input_placeholder": "Cerca allegati (usa \" \" per la ricerca di frasi)",
|
||||
"sender": "Mittente",
|
||||
"sender_with_count": "Mittente ({{count}})",
|
||||
"showDuplicates": "Mostra duplicati",
|
||||
"size": "Dimensione file",
|
||||
"source": "Origine",
|
||||
"subject": "Oggetto"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "添付ファイル名、件名、送信者を照合",
|
||||
"date": "日付",
|
||||
"download": "ダウンロード",
|
||||
"downloading": "ダウンロード中...",
|
||||
"emailMessageNotFound": "元のメールが見つかりません。削除された可能性があります。",
|
||||
"name": "ファイル名",
|
||||
"search_input_placeholder": "添付ファイルを検索 (フレーズ検索は \" \" を使用)",
|
||||
"sender": "送信者",
|
||||
"sender_with_count": "送信者 ({{count}})",
|
||||
"showDuplicates": "重複ファイルを表示",
|
||||
"size": "ファイルサイズ",
|
||||
"source": "ソース",
|
||||
"subject": "件名"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "첨부 파일 이름, 메일 제목 및 발신자 일치",
|
||||
"date": "날짜",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"emailMessageNotFound": "원본 메일을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
|
||||
"name": "파일 이름",
|
||||
"search_input_placeholder": "첨부 파일 검색 (구문 검색은 \" \" 사용)",
|
||||
"sender": "보낸 사람",
|
||||
"sender_with_count": "보낸 사람 ({{count}})",
|
||||
"showDuplicates": "중복 파일 표시",
|
||||
"size": "파일 크기",
|
||||
"source": "출처",
|
||||
"subject": "제목"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Komt overeen met bijlagennaam, onderwerp en afzender",
|
||||
"date": "Datum",
|
||||
"download": "Downloaden",
|
||||
"downloading": "Downloaden...",
|
||||
"emailMessageNotFound": "Kan de originele e-mail niet vinden. Deze is mogelijk verwijderd.",
|
||||
"name": "Bestandsnaam",
|
||||
"search_input_placeholder": "Zoek bijlagen (gebruik \" \" voor woordgroepen)",
|
||||
"sender": "Afzender",
|
||||
"sender_with_count": "Afzender ({{count}})",
|
||||
"showDuplicates": "Duplicaten weergeven",
|
||||
"size": "Bestandsgrootte",
|
||||
"source": "Bron",
|
||||
"subject": "Onderwerp"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Matcher vedleggsnavn, emne og avsender",
|
||||
"date": "Dato",
|
||||
"download": "Last ned",
|
||||
"downloading": "Laster ned...",
|
||||
"emailMessageNotFound": "Fant ikke den originale e-posten. Den kan ha blitt slettet.",
|
||||
"name": "Filnavn",
|
||||
"search_input_placeholder": "Søk etter vedlegg (bruk \" \" for frasesøk)",
|
||||
"sender": "Avsender",
|
||||
"sender_with_count": "Avsender ({{count}})",
|
||||
"showDuplicates": "Vis duplikater",
|
||||
"size": "Filstørrelse",
|
||||
"source": "Kilde",
|
||||
"subject": "Emne"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Dopasuj nazwę załącznika, temat i nadawcę",
|
||||
"date": "Data",
|
||||
"download": "Pobierz",
|
||||
"downloading": "Pobieranie...",
|
||||
"emailMessageNotFound": "Nie można znaleźć oryginalnej wiadomości e-mail. Mogła zostać usunięta.",
|
||||
"name": "Nazwa pliku",
|
||||
"search_input_placeholder": "Wyszukaj załączniki (użyj \" \" do wyszukiwania fraz)",
|
||||
"sender": "Nadawca",
|
||||
"sender_with_count": "Nadawca ({{count}})",
|
||||
"showDuplicates": "Pokaż duplikaty",
|
||||
"size": "Rozmiar pliku",
|
||||
"source": "Źródło",
|
||||
"subject": "Temat"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Corresponde ao nome do anexo, assunto e remetente",
|
||||
"date": "Data",
|
||||
"download": "Baixar",
|
||||
"downloading": "Baixando...",
|
||||
"emailMessageNotFound": "Não foi possível encontrar o e-mail original. Ele pode ter sido excluído.",
|
||||
"name": "Nome do arquivo",
|
||||
"search_input_placeholder": "Pesquisar anexos (use \" \" para pesquisa de frases)",
|
||||
"sender": "Remetente",
|
||||
"sender_with_count": "Remetente ({{count}})",
|
||||
"showDuplicates": "Mostrar duplicados",
|
||||
"size": "Tamanho do arquivo",
|
||||
"source": "Origem",
|
||||
"subject": "Assunto"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Поиск по имени вложения, теме и отправителю",
|
||||
"date": "Дата",
|
||||
"download": "Скачать",
|
||||
"downloading": "Загрузка...",
|
||||
"emailMessageNotFound": "Не удалось найти исходное письмо. Возможно, оно было удалено.",
|
||||
"name": "Имя файла",
|
||||
"search_input_placeholder": "Поиск вложений (используйте \" \" для фразового поиска)",
|
||||
"sender": "Отправитель",
|
||||
"sender_with_count": "Отправитель ({{count}})",
|
||||
"showDuplicates": "Показать дубликаты",
|
||||
"size": "Размер файла",
|
||||
"source": "Источник",
|
||||
"subject": "Тема"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "Matchar bilagenamn, ämne och avsändare",
|
||||
"date": "Datum",
|
||||
"download": "Ladda ner",
|
||||
"downloading": "Laddar ner...",
|
||||
"emailMessageNotFound": "Det går inte att hitta det ursprungliga e-postmeddelandet. Det kan ha raderats.",
|
||||
"name": "Filnamn",
|
||||
"search_input_placeholder": "Sök efter bilagor (använd \" \" för frassökning)",
|
||||
"sender": "Avsändare",
|
||||
"sender_with_count": "Avsändare ({{count}})",
|
||||
"showDuplicates": "Visa dubbletter",
|
||||
"size": "Filstorlek",
|
||||
"source": "Källa",
|
||||
"subject": "Ämne"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "匹配附件名稱、郵件主題和發件人",
|
||||
"date": "日期",
|
||||
"download": "下載",
|
||||
"downloading": "正在下載...",
|
||||
"emailMessageNotFound": "找不到原始郵件。它可能已被刪除。",
|
||||
"name": "檔案名稱",
|
||||
"search_input_placeholder": "搜尋附件(使用 \" \" 進行短語搜尋)",
|
||||
"sender": "寄件人",
|
||||
"sender_with_count": "寄件人 ({{count}})",
|
||||
"showDuplicates": "顯示重複檔案",
|
||||
"size": "檔案大小",
|
||||
"source": "來源",
|
||||
"subject": "主旨"
|
||||
|
||||
@@ -365,10 +365,14 @@
|
||||
"attachment": {
|
||||
"all_fields_desc": "匹配附件名称、邮件主题和发件人",
|
||||
"date": "日期",
|
||||
"download": "下载",
|
||||
"downloading": "正在下载...",
|
||||
"emailMessageNotFound": "找不到原始邮件。它可能已被删除。",
|
||||
"name": "文件名",
|
||||
"search_input_placeholder": "搜索附件(使用 \" \" 进行短语搜索)",
|
||||
"sender": "发件人",
|
||||
"sender_with_count": "发件人 ({{count}})",
|
||||
"showDuplicates": "显示重复文件",
|
||||
"size": "文件大小",
|
||||
"source": "来源",
|
||||
"subject": "主题"
|
||||
|
||||
Reference in New Issue
Block a user