mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e25b0da14 | ||
|
|
e1f471b8f4 | ||
|
|
fcd19b1c9f | ||
|
|
df1a6f8c5b | ||
|
|
7d150c2982 | ||
|
|
9b49005522 | ||
|
|
1f4e9f7b06 | ||
|
|
d0e4cac229 | ||
|
|
30a8d856c1 | ||
|
|
7240be8c31 | ||
|
|
0d20a9676a | ||
|
|
48f8092b5a | ||
|
|
a64351d409 | ||
|
|
a6dd1d19ff | ||
|
|
ad2a43aa35 | ||
|
|
884ae64fc5 | ||
|
|
254de35f0e |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -444,7 +444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -65,7 +65,7 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
|
||||
* **Internationalized WebUI** — Frontend available in 18 languages
|
||||
* **OpenAPI Access** — OpenAPI docs with access-token authentication
|
||||
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
|
||||
* **Email Import (EML & MBOX)** — Import existing mail archives via the bichonctl CLI
|
||||
* **Email Import (EML, MBOX & PST)** — Import existing mail archives via the bichonctl CLI
|
||||
|
||||
## 🐾 Why Create Bichon?
|
||||
|
||||
@@ -92,7 +92,7 @@ It’s not perfect, but I hope it brings you value.
|
||||
<img width="1909" height="904" alt="image" src="https://github.com/user-attachments/assets/ab4bf6ae-faa6-4b49-ae39-705eb9d4487f" />
|
||||
<img width="1910" height="910" alt="image" src="https://github.com/user-attachments/assets/bcf9cca2-d690-4e7b-b2c9-c52a31c7b999" />
|
||||
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
|
||||
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
|
||||
<img width="1910" height="1055" alt="image" src="https://github.com/user-attachments/assets/9bde665e-7717-447f-ad29-f743a32a4dc0" />
|
||||
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
|
||||
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
|
||||
|
||||
|
||||
@@ -254,7 +254,8 @@ fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
|
||||
}
|
||||
|
||||
let from = extract_string_property(properties, 0x5D01)
|
||||
.or_else(|| extract_string_property(properties, 0x5D02));
|
||||
.or_else(|| extract_string_property(properties, 0x5D02))
|
||||
.or_else(|| extract_string_property(properties, 0x0C1F));
|
||||
|
||||
if let Some(f) = from {
|
||||
builder = builder.from(f);
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// 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 std::collections::HashSet;
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::mailbox::MailBox;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
@@ -155,9 +157,9 @@ impl Envelope {
|
||||
let id = create_hash(account_id, &message_id);
|
||||
let full_text = extract_string_field(doc, fields.f_text)?;
|
||||
|
||||
// Take up to the first 120 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 120 {
|
||||
full_text.chars().take(120).collect::<String>() + "..."
|
||||
// Take up to the first 500 characters as a preview;
|
||||
let preview = if full_text.chars().count() > 500 {
|
||||
full_text.chars().take(500).collect::<String>() + "..."
|
||||
} else {
|
||||
full_text
|
||||
};
|
||||
@@ -204,3 +206,28 @@ impl Envelope {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
|
||||
let fields = SchemaTools::envelope_fields();
|
||||
let mut all_contacts = HashSet::new();
|
||||
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||
if !from_val.is_empty() {
|
||||
all_contacts.insert(from_val);
|
||||
}
|
||||
}
|
||||
|
||||
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
|
||||
|
||||
for field in multi_fields {
|
||||
if let Ok(vals) = extract_vec_string_field(doc, field) {
|
||||
for v in vals {
|
||||
if !v.is_empty() {
|
||||
all_contacts.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_contacts)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::modules::message::{search::SortBy, tags::TagCount};
|
||||
use crate::modules::{
|
||||
indexer::envelope::extract_contacts,
|
||||
message::{search::SortBy, tags::TagCount},
|
||||
};
|
||||
use crate::{
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
@@ -57,7 +60,10 @@ use tantivy::{
|
||||
AggregationCollector, Key,
|
||||
},
|
||||
collector::{Count, FacetCollector, TopDocs},
|
||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
||||
query::{
|
||||
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
|
||||
TermQuery,
|
||||
},
|
||||
schema::{Facet, IndexRecordOption, Value},
|
||||
store::{Compressor, ZstdCompressor},
|
||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||
@@ -70,6 +76,7 @@ use tokio::{
|
||||
sync::{mpsc, Mutex},
|
||||
task,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
pub static ENVELOPE_INDEX_MANAGER: LazyLock<EnvelopeIndexManager> =
|
||||
LazyLock::new(EnvelopeIndexManager::new);
|
||||
@@ -182,13 +189,23 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
fn open_or_create_index(index_dir: &PathBuf) -> Index {
|
||||
if !index_dir.exists() {
|
||||
let need_create = !index_dir.exists()
|
||||
|| index_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
if need_create {
|
||||
info!(
|
||||
"Email index not found or empty, creating new index at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
|
||||
panic!("Failed to create index directory {:?}: {}", index_dir, e)
|
||||
});
|
||||
Index::create_in_dir(&index_dir, SchemaTools::envelope_schema())
|
||||
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
|
||||
} else {
|
||||
info!("Opening existing email index at {}", index_dir.display());
|
||||
open(&index_dir)
|
||||
}
|
||||
}
|
||||
@@ -306,11 +323,9 @@ impl EnvelopeIndexManager {
|
||||
(f.f_bcc, &filter.bcc),
|
||||
] {
|
||||
if let Some(ref v) = opt_value {
|
||||
let term = Term::from_field_text(field, v);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,11 +342,9 @@ impl EnvelopeIndexManager {
|
||||
}
|
||||
|
||||
if let Some(ref name) = filter.attachment_name {
|
||||
let term = Term::from_field_text(f.f_attachments, name);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
}
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.since {
|
||||
@@ -351,20 +364,28 @@ impl EnvelopeIndexManager {
|
||||
subqueries.push((Occur::Must, Box::new(q)));
|
||||
}
|
||||
|
||||
if let Some(account_id) = filter.account_id {
|
||||
let term = Term::from_field_u64(f.f_account_id, account_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(account_ids) = filter.account_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in account_ids {
|
||||
let term = Term::from_field_u64(f.f_account_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
if let Some(mailbox_id) = filter.mailbox_id {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
|
||||
subqueries.push((
|
||||
Occur::Must,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
if let Some(mailbox_ids) = filter.mailbox_ids {
|
||||
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||
for id in mailbox_ids {
|
||||
let term = Term::from_field_u64(f.f_mailbox_id, id);
|
||||
should_queries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||
));
|
||||
}
|
||||
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||
}
|
||||
|
||||
let start_bound = if let Some(from) = filter.min_size {
|
||||
@@ -520,6 +541,48 @@ impl EnvelopeIndexManager {
|
||||
Ok(all_facets)
|
||||
}
|
||||
|
||||
pub async fn get_all_contacts(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
) -> BichonResult<HashSet<String>> {
|
||||
let searcher = self.create_searcher()?;
|
||||
|
||||
let query: Box<dyn Query> = match accounts {
|
||||
Some(ref ids) if !ids.is_empty() => {
|
||||
let mut subqueries = Vec::new();
|
||||
for &id in ids {
|
||||
let term =
|
||||
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
|
||||
subqueries.push((
|
||||
Occur::Should,
|
||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
|
||||
));
|
||||
}
|
||||
Box::new(BooleanQuery::new(subqueries))
|
||||
}
|
||||
Some(_) => Box::new(EmptyQuery),
|
||||
None => Box::new(AllQuery),
|
||||
};
|
||||
|
||||
let mut contacts_set: HashSet<String> = HashSet::new();
|
||||
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(1_000_000))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
for (_score, doc_address) in top_docs {
|
||||
let doc: TantivyDocument = searcher
|
||||
.doc_async(doc_address)
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
let contacts = extract_contacts(&doc).await?;
|
||||
for value in contacts {
|
||||
contacts_set.insert(value);
|
||||
}
|
||||
}
|
||||
Ok(contacts_set)
|
||||
}
|
||||
|
||||
pub async fn delete_envelopes_multi_account(
|
||||
&self,
|
||||
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
|
||||
@@ -1279,7 +1342,17 @@ impl EmlIndexManager {
|
||||
}
|
||||
|
||||
fn open_or_create_index(index_dir: &PathBuf) -> Index {
|
||||
if !index_dir.exists() {
|
||||
let need_create = !index_dir.exists()
|
||||
|| index_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(true);
|
||||
|
||||
if need_create {
|
||||
info!(
|
||||
"Email data storage not found or empty, creating new mail storage at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
|
||||
panic!("Failed to create index directory {:?}: {}", index_dir, e)
|
||||
});
|
||||
@@ -1295,6 +1368,10 @@ impl EmlIndexManager {
|
||||
.create_in_dir(&index_dir)
|
||||
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
|
||||
} else {
|
||||
info!(
|
||||
"Opening existing email data storage at {}",
|
||||
index_dir.display()
|
||||
);
|
||||
open(&index_dir)
|
||||
}
|
||||
}
|
||||
|
||||
10
src/modules/message/contacts.rs
Normal file
10
src/modules/message/contacts.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct Contact {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
pub mod append;
|
||||
pub mod contacts;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
|
||||
@@ -39,8 +39,8 @@ pub struct SearchFilter {
|
||||
pub bcc: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
pub before: Option<i64>,
|
||||
pub account_id: Option<u64>,
|
||||
pub mailbox_id: Option<u64>,
|
||||
pub account_ids: Option<Vec<u64>>,
|
||||
pub mailbox_ids: Option<Vec<u64>>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
pub message_id: Option<String>,
|
||||
|
||||
@@ -323,4 +323,25 @@ impl MessageApi {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[oai(
|
||||
path = "/all-contacts",
|
||||
method = "get",
|
||||
operation_id = "get_all_contacts"
|
||||
)]
|
||||
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.get_all_contacts(authorized_ids)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +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::settings::io::check_dir_read_write;
|
||||
use clap::{builder::ValueParser, Parser, ValueEnum};
|
||||
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
|
||||
|
||||
@@ -64,7 +65,7 @@ pub struct Settings {
|
||||
)]
|
||||
pub bichon_bind_ip: Option<String>,
|
||||
|
||||
/// RustMail public URL (default: "http://localhost:15630")
|
||||
/// bichon public URL (default: "http://localhost:15630")
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "http://localhost:15630",
|
||||
@@ -160,20 +161,48 @@ pub struct Settings {
|
||||
help = "Set the file path for bichon database",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("Path must be an absolute directory path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("Path {:?} does not exist", path));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Path {:?} is not a directory", path));
|
||||
return Err("'bichon_root_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_root_dir: String,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email index directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_index_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_index_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Set the file path for email data directory",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_data_dir' must be an absolute directory path".to_string());
|
||||
}
|
||||
|
||||
check_dir_read_write(&path)?;
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_data_dir: Option<String>,
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// 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::context::Initialize;
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
use crate::{
|
||||
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
|
||||
const TLS_CERT: &str = "cert.pem";
|
||||
const TLS_KEY: &str = "key.pem";
|
||||
|
||||
|
||||
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
|
||||
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
|
||||
|
||||
@@ -49,7 +47,7 @@ pub struct DataDirManager {
|
||||
pub tls_key: PathBuf,
|
||||
pub envelope_dir: PathBuf,
|
||||
pub eml_dir: PathBuf,
|
||||
pub log_dir: PathBuf
|
||||
pub log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Initialize for DataDirManager {
|
||||
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
|
||||
|
||||
impl DataDirManager {
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
|
||||
PathBuf::from(index_dir)
|
||||
} else {
|
||||
root_dir.join(ENVELOPE_DIR)
|
||||
};
|
||||
|
||||
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir)
|
||||
} else {
|
||||
root_dir.join(EML_DIR)
|
||||
};
|
||||
|
||||
Self {
|
||||
root_dir: root_dir.clone(),
|
||||
meta_db: root_dir.join(META_FILE),
|
||||
@@ -73,9 +83,9 @@ impl DataDirManager {
|
||||
tls_key: root_dir.join(TLS_KEY),
|
||||
tls_cert: root_dir.join(TLS_CERT),
|
||||
log_dir: root_dir.join(LOG_DIR),
|
||||
envelope_dir: root_dir.join(ENVELOPE_DIR),
|
||||
envelope_dir,
|
||||
temp_dir: root_dir.join(TMP_DIR),
|
||||
eml_dir: root_dir.join(EML_DIR),
|
||||
eml_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
src/modules/settings/io.rs
Normal file
41
src/modules/settings/io.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_dir_read_write(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|e| format!("Cannot create directory {:?}: {}", path, e))?;
|
||||
}
|
||||
|
||||
if !path.is_dir() {
|
||||
return Err(format!("{:?} is not a directory", path));
|
||||
}
|
||||
|
||||
let test_file = path.join(".bichon_perm_test");
|
||||
|
||||
{
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&test_file)
|
||||
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
|
||||
|
||||
f.write_all(b"test")
|
||||
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
let mut f = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&test_file)
|
||||
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
|
||||
|
||||
f.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
|
||||
}
|
||||
let _ = fs::remove_file(&test_file);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use crate::modules::settings::cli::Settings;
|
||||
|
||||
pub mod cli;
|
||||
pub mod dir;
|
||||
pub mod io;
|
||||
pub mod proxy;
|
||||
pub mod system;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
|
||||
|
||||
|
||||
export interface MailboxData {
|
||||
account_id: number;
|
||||
attributes: { attr: string; extension: string | null }[];
|
||||
delimiter: string | null;
|
||||
exists: number;
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface TagCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const get_top_tags = async () => {
|
||||
export const get_tags = async () => {
|
||||
const response = await axiosInstance.get<TagCount[]>("/api/v1/all-tags");
|
||||
return response.data;
|
||||
}
|
||||
@@ -41,3 +41,9 @@ export const update_tags = async (data: Record<string, any>) => {
|
||||
};
|
||||
|
||||
|
||||
export const get_contacts = async () => {
|
||||
const response = await axiosInstance.get<string[]>("/api/v1/all-contacts");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DatePicker({
|
||||
{selected ? (
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
<span className='text-xs'>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,14 +34,15 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { showNumbers } from '@/lib/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
hasNextPage: () => boolean,
|
||||
setPageIndex: (pageIndex: number) => void,
|
||||
setPageSize: (pageSize: number) => void,
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
hasNextPage: () => boolean
|
||||
setPageIndex: (pageIndex: number) => void
|
||||
setPageSize: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function EnvelopeListPagination({
|
||||
@@ -52,8 +54,13 @@ export function EnvelopeListPagination({
|
||||
setPageSize,
|
||||
}: PaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pageInput, setPageInput] = useState(pageIndex + 1)
|
||||
const pageCount = Math.ceil(totalItems / pageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setPageInput(pageIndex + 1)
|
||||
}, [pageIndex])
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const newPageSize = Number(value)
|
||||
setPageSize(newPageSize)
|
||||
@@ -69,7 +76,7 @@ export function EnvelopeListPagination({
|
||||
setPageIndex(newPageIndex)
|
||||
}
|
||||
|
||||
const currentPage = pageIndex + 1;
|
||||
const currentPage = pageIndex + 1
|
||||
const pageNumbers = showNumbers(currentPage, pageCount)
|
||||
|
||||
return (
|
||||
@@ -88,7 +95,7 @@ export function EnvelopeListPagination({
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
||||
{[10, 20, 30, 40, 50, 100, 200].map((size) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -96,8 +103,20 @@ export function EnvelopeListPagination({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-center text-sm font-medium'>
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
<div className='hidden items-center justify-center text-sm font-medium sm:flex'>
|
||||
{t("table.page")}
|
||||
<Input
|
||||
type="number"
|
||||
value={pageInput}
|
||||
onBlur={() => {
|
||||
if (Number.isNaN(pageInput)) return
|
||||
if (pageInput > 0) setPageIndex(pageInput - 1)
|
||||
else setPageIndex(0)
|
||||
}}
|
||||
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||
className='mx-2 h-8 w-20'
|
||||
/>
|
||||
{t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
@@ -154,4 +173,4 @@ export function EnvelopeListPagination({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
interface ScrollAreaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
orientation?: 'horizontal' | 'vertical' | 'both'
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
@@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation={orientation} />
|
||||
{orientation === "both" ? (
|
||||
<>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</>
|
||||
) : <ScrollBar orientation={orientation} />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
@@ -40,9 +45,9 @@ const ScrollBar = React.forwardRef<
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,13 +5,13 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className='relative w-full overflow-auto'>
|
||||
// <div className='relative w-full overflow-auto'>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
// </div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_th]:sticky [&_th]:top-0 [&_th]:bg-background [&_th]:z-20', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ interface VirtualizedSelectProps {
|
||||
defaultValue?: string | string[];
|
||||
noItemsComponent?: React.ReactNode;
|
||||
multiple?: boolean;
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function VirtualizedSelect({
|
||||
@@ -232,6 +233,7 @@ export function VirtualizedSelect({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
size = 'default',
|
||||
isLoading,
|
||||
disabled = false,
|
||||
placeholder = 'Search items...',
|
||||
@@ -273,7 +275,7 @@ export function VirtualizedSelect({
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedLabels.length === 0) return placeholder;
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
||||
return selectedLabels.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,13 +283,14 @@ export function VirtualizedSelect({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('justify-between', className)}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<span className='truncate'>{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -164,7 +164,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
|
||||
<ScrollArea className='h-[13rem] w-full pr-4 -mr-4 py-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='nosync-account-form'
|
||||
|
||||
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
11
web/src/features/search/account-mailbox-filter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AccountPopover } from './account-popover'
|
||||
import { MailboxPopover } from './mailbox-popover'
|
||||
|
||||
export function AccountMailboxFilter() {
|
||||
return (
|
||||
<>
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
</>
|
||||
)
|
||||
}
|
||||
185
web/src/features/search/account-popover.tsx
Normal file
185
web/src/features/search/account-popover.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react'
|
||||
import { AtSign, 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 useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function AccountPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const selectedIds: number[] = filter.account_ids ?? []
|
||||
|
||||
const toggleAccount = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.account_ids ?? [])
|
||||
|
||||
if (set.has(id)) {
|
||||
set.delete(id)
|
||||
} else {
|
||||
set.add(id)
|
||||
}
|
||||
|
||||
if (set.size === 0) {
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
} else {
|
||||
next.account_ids = Array.from(set).sort()
|
||||
delete next.mailbox_ids
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAccounts = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return minimalList
|
||||
.filter(a =>
|
||||
!q ||
|
||||
a.email.toLowerCase().includes(q) ||
|
||||
String(a.id).includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSel = selectedIds.includes(a.id)
|
||||
const bSel = selectedIds.includes(b.id)
|
||||
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.id - b.id
|
||||
})
|
||||
}, [minimalList, search, selectedIds])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
selectedIds.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
{t('search_accounts.label')}
|
||||
{selectedIds.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedIds.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('search_accounts.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{!search && selectedIds.length > 0 && (
|
||||
<div className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAccounts}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_accounts.clear_accounts')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">
|
||||
({selectedIds.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_accounts.no_accounts_found')}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map(account => {
|
||||
const checked = selectedIds.includes(account.id)
|
||||
const id = `account-${account.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
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={() =>
|
||||
toggleAccount(account.id)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
53
web/src/features/search/attachment-filter.tsx
Normal file
53
web/src/features/search/attachment-filter.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -37,7 +37,7 @@ type MailBulkActionsProps = {
|
||||
}
|
||||
|
||||
export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { selected, setSelected, setOpen, setToDelete } = useSearchContext()
|
||||
const { selected, setSelected, setOpen, setToDelete, setCurrentEnvelope } = useSearchContext()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -60,6 +60,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
setOpen('delete')
|
||||
}
|
||||
|
||||
|
||||
const handleRestore = () => {
|
||||
setCurrentEnvelope(undefined);
|
||||
setOpen('restore')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
@@ -161,7 +167,26 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRestore}
|
||||
className="gap-1"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('search.bulkActions.restoreDesc')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
{/* Delete */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
217
web/src/features/search/contact-popover.tsx
Normal file
217
web/src/features/search/contact-popover.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown, Mail, X } from "lucide-react"
|
||||
import React from "react"
|
||||
import { useContacts } from "@/hooks/use-contacts"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
|
||||
export function MailFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const fields = ['from', 'to', 'cc', 'bcc'] as const
|
||||
|
||||
const activeCount = fields.filter(k => !!filter[k]).length
|
||||
|
||||
const updateFilter = (field: string, email: string | undefined) => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
[field]: email
|
||||
}))
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
fields.forEach(k => delete next[k])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>
|
||||
{activeCount > 0
|
||||
? t('search_contacts.label_with_count', { count: activeCount })
|
||||
: t('search_contacts.label')}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
|
||||
>
|
||||
<div className="flex flex-col bg-muted/20 divide-y divide-border">
|
||||
{fields.map((field) => (
|
||||
<ContactSelectorField
|
||||
key={field}
|
||||
label={field}
|
||||
value={filter[field] as string | undefined}
|
||||
onSelect={(email) => updateFilter(field, email)}
|
||||
onReset={() => updateFilter(field, undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_contacts.reset_all')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactSelectorField({
|
||||
label,
|
||||
value,
|
||||
onSelect,
|
||||
onReset
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onSelect: (email: string | undefined) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const { contacts, isLoading } = useContacts(searchTerm)
|
||||
|
||||
const handleToggle = (email: string) => {
|
||||
if (value === email) {
|
||||
onReset()
|
||||
} else {
|
||||
onSelect(email)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center justify-between w-full px-4 py-3 hover:bg-background transition-all text-left relative",
|
||||
"min-h-[52px]",
|
||||
value && "bg-background/60 hover:bg-background/80"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start pr-6">
|
||||
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 truncate max-w-[320px]",
|
||||
value
|
||||
? "text-xs font-semibold text-primary"
|
||||
: "text-xs text-muted-foreground/90"
|
||||
)}
|
||||
>
|
||||
{value || t('search_contacts.any')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReset()
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="p-0 w-auto min-w-[300px] max-w-[420px] shadow-2xl border-border/50"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('search_contacts.search_placeholder', { field: label })}
|
||||
className="h-9"
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
/>
|
||||
<CommandList className="max-h-[360px]">
|
||||
{isLoading && (
|
||||
<div className="p-4 text-xs text-center opacity-50">{t('search_contacts.loading')}</div>
|
||||
)}
|
||||
<CommandEmpty>{t('search_contacts.no_contact_found')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{contacts.slice(0, 100).map((email) => (
|
||||
<CommandItem
|
||||
key={email}
|
||||
onSelect={() => handleToggle(email)}
|
||||
className="flex items-center justify-between py-2.5 px-3 cursor-pointer whitespace-nowrap gap-4 text-xs"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium">
|
||||
{email.split('@')[0]}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[360px]">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
{value === email && (
|
||||
<Check className="h-4 w-4 text-primary shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
{contacts.length > 100 && (
|
||||
<div className="px-3 py-2 text-[10px] text-center text-muted-foreground border-t border-border/50">
|
||||
{t('search_contacts.showing_limit', { total: contacts.length })}
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
@@ -32,6 +33,11 @@ interface SearchContextType {
|
||||
selected: Map<number, Set<number>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
selectedTags: string[]
|
||||
sorting: SortingState
|
||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||
filter: Record<string, any>
|
||||
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
handleTagToggle: (tag: string) => void
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
||||
|
||||
38
web/src/features/search/filter-reset.tsx
Normal file
38
web/src/features/search/filter-reset.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}).length;
|
||||
|
||||
if (activeFiltersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-8 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>{t('search_reset.label')}</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -21,26 +21,18 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { SearchFormDialog } from './search-form';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import { MailList } from './mail-list';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Filter, SearchIcon } from 'lucide-react';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnvelopeTags } from './tag-facet';
|
||||
import { EditTagsDialog } from './add-tag-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { MailListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
@@ -49,24 +41,21 @@ export default function Search() {
|
||||
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
|
||||
const {
|
||||
emails,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
filter,
|
||||
setFilter
|
||||
} = useSearchMessages();
|
||||
|
||||
const handleSetPageSize = (pageSize: number) => {
|
||||
@@ -74,12 +63,6 @@ export default function Search() {
|
||||
setPageSize(pageSize)
|
||||
}
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
@@ -92,92 +75,27 @@ export default function Search() {
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider value={{ open, setOpen, currentEnvelope: selectedEnvelope, selectedTags, setCurrentEnvelope: setSelectedEnvelope, toDelete, setToDelete, selected, setSelected }}>
|
||||
<SearchProvider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentEnvelope: selectedEnvelope,
|
||||
selectedTags,
|
||||
setCurrentEnvelope: setSelectedEnvelope,
|
||||
toDelete,
|
||||
setToDelete,
|
||||
selected,
|
||||
setSelected,
|
||||
sorting,
|
||||
setSorting,
|
||||
filter,
|
||||
setFilter,
|
||||
handleTagToggle
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="mb-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{t('search.tagFilter')}
|
||||
{selectedTags.length > 0 && ` (${selectedTags.length})`}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t('search.tagFilter')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<div className="flex flex-row items-center justify-between w-full border-b pb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => setOpen("search-form")}
|
||||
className="px-4 shadow-sm"
|
||||
>
|
||||
<SearchIcon className="mr-2 h-4 w-4" />
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 bg-muted/50 p-1 rounded-lg border">
|
||||
<span className="text-xs font-medium text-muted-foreground px-2">
|
||||
{t('search.sort')}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={sortBy}
|
||||
onValueChange={(value) => value && setSortBy(value as "DATE" | "SIZE")}
|
||||
className="gap-1"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="DATE"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs data-[state=on]:bg-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
{t('search.date')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="SIZE"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs data-[state=on]:bg-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
{t('search.size')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-background"
|
||||
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
|
||||
>
|
||||
{sortOrder === "asc" ? (
|
||||
<ArrowUpWideNarrow className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
@@ -189,31 +107,16 @@ export default function Search() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{Object.keys(filter).length === 0
|
||||
? t('search.startSearching')
|
||||
: t('search.adjustSearch')}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
{total > 0 && <ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>}
|
||||
<MailListTable
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <EnvelopeListPagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
@@ -244,13 +147,6 @@ export default function Search() {
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<SearchFormDialog
|
||||
key='search-form-dialog'
|
||||
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
|
||||
open={open === 'search-form'}
|
||||
onOpenChange={() => setOpen('search-form')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
|
||||
333
web/src/features/search/mail-list-table.tsx
Executable file
333
web/src/features/search/mail-list-table.tsx
Executable file
@@ -0,0 +1,333 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { MessageSquareText, Paperclip } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import LongText from "@/components/long-text"
|
||||
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
||||
import { SearchTable } from "./table/table"
|
||||
import { DataTableRowActions } from "./table/data-table-row-actions"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DataTableToolbar } from "./table/toolbar"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
}
|
||||
|
||||
export function MailListTable({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
|
||||
const columns: ColumnDef<EmailEnvelope>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={hasSelected(row.original.account_id, row.original.id)}
|
||||
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 25,
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "account_email",
|
||||
header: t('search.account'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.account_email}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "mailbox_name",
|
||||
header: t('search.mailbox'),
|
||||
cell: ({ row }) => {
|
||||
const mailbox = row.original.mailbox_name
|
||||
const tags = row.original.tags ?? []
|
||||
|
||||
if (!mailbox) return null
|
||||
|
||||
const visible = tags.slice(0, 3)
|
||||
const rest = tags.length - visible.length
|
||||
|
||||
const fullTags = tags.join(' · ')
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col leading-tight max-w-[130px] cursor-default">
|
||||
<span className="text-xs truncate">
|
||||
{mailbox}
|
||||
</span>
|
||||
|
||||
{visible.length > 0 && (
|
||||
<span className="text-[10px] text-primary/80 truncate">
|
||||
{visible.join(' · ')}
|
||||
{rest > 0 && ` · +${rest}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<div className="text-xs font-medium mb-1">
|
||||
{mailbox}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground break-words">
|
||||
{fullTags}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 116,
|
||||
maxSize: 116,
|
||||
},
|
||||
{
|
||||
accessorKey: "from",
|
||||
header: t('search.from'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.from}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "to",
|
||||
header: t('search.to'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.to.join(", ")}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 156,
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 450,
|
||||
maxSize: 456,
|
||||
},
|
||||
{
|
||||
id: "text_preview",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const text = row.original.text
|
||||
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-primary transition"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-[520px] max-h-[420px] overflow-auto whitespace-pre-wrap text-xs leading-relaxed"
|
||||
>
|
||||
{text}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
},
|
||||
meta: { className: "text-center max-w-[80px]" },
|
||||
minSize: 36,
|
||||
maxSize: 36,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "attachment_count",
|
||||
header: () => <Paperclip size={16} />,
|
||||
cell: ({ row }) => <span className='text-xs'>{(row.original.attachments ?? []).length}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 40,
|
||||
maxSize: 40
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.size')} />
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('search.date')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.date)
|
||||
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-xs whitespace-nowrap'>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 50,
|
||||
maxSize: 60,
|
||||
},
|
||||
]
|
||||
|
||||
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 toggleSelected = (accountId: number, mailId: number) => {
|
||||
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: number) => selected.get(accountId)?.has(mailId) ?? false
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(row.original)
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
>
|
||||
{(table) => {
|
||||
return <DataTableToolbar table={table} />
|
||||
}}
|
||||
|
||||
</SearchTable>
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio } from 'lucide-react';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -119,7 +119,7 @@ export function MailMessageView({
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setToDelete, setOpen } = useSearchContext();
|
||||
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
||||
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||
@@ -280,6 +280,22 @@ export function MailMessageView({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSelected(new Map())
|
||||
setOpen('restore')
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
263
web/src/features/search/mailbox-popover.tsx
Normal file
263
web/src/features/search/mailbox-popover.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, Folders, X } from 'lucide-react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const [search, setSearch] = React.useState('')
|
||||
|
||||
const accountIds: number[] = filter.account_ids ?? []
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
|
||||
|
||||
const { mailboxes, isLoading } = useQueries({
|
||||
queries: accountIds.map(id => ({
|
||||
queryKey: ['search-mailboxes', id],
|
||||
queryFn: () => list_mailboxes(id, false),
|
||||
enabled: accountIds.length > 0,
|
||||
})),
|
||||
combine: results => ({
|
||||
mailboxes: results.flatMap(r => r.data ?? []),
|
||||
isLoading: results.some(r => r.isLoading),
|
||||
}),
|
||||
})
|
||||
|
||||
const toggleMailbox = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.mailbox_ids ?? [])
|
||||
|
||||
set.has(id) ? set.delete(id) : set.add(id)
|
||||
|
||||
const ids = Array.from(set)
|
||||
|
||||
if (ids.length === 0) delete next.mailbox_ids
|
||||
else next.mailbox_ids = ids
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllMailboxes = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const map = new Map<number, MailboxData[]>()
|
||||
|
||||
for (const mb of mailboxes) {
|
||||
if (q && !mb.name.toLowerCase().includes(q)) continue
|
||||
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
|
||||
map.get(mb.account_id)!.push(mb)
|
||||
}
|
||||
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => {
|
||||
const aSel = selectedMailboxIds.includes(a.id)
|
||||
const bSel = selectedMailboxIds.includes(b.id)
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
}, [mailboxes, search, selectedMailboxIds])
|
||||
|
||||
const defaultOpen = grouped
|
||||
.filter(([, boxes]) =>
|
||||
boxes.some(m => selectedMailboxIds.includes(m.id))
|
||||
)
|
||||
.map(([id]) => id.toString())
|
||||
|
||||
const getAccountEmail = (id: number) =>
|
||||
minimalList.find(a => a.id === id)?.email ?? ''
|
||||
|
||||
const disabled = accountIds.length === 0
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5',
|
||||
selectedMailboxIds.length > 0 &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
{t('search_mailbox.label')}
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-70">
|
||||
{selectedMailboxIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllMailboxes}
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{disabled ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.select_account_first')}
|
||||
</p>
|
||||
) : 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>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_mailbox.no_mailbox_found')}
|
||||
</p>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={defaultOpen}
|
||||
className="space-y-1"
|
||||
>
|
||||
{grouped.map(([accountId, boxes]) => {
|
||||
const selectedCount = boxes.filter(b =>
|
||||
selectedMailboxIds.includes(b.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={accountId}
|
||||
value={accountId.toString()}
|
||||
>
|
||||
<AccordionTrigger className="text-xs px-2 py-1.5">
|
||||
<span className="truncate">
|
||||
{getAccountEmail(accountId)}
|
||||
</span>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary">
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent>
|
||||
<div className="space-y-0.5">
|
||||
{boxes.map(mailbox => {
|
||||
const checked =
|
||||
selectedMailboxIds.includes(mailbox.id)
|
||||
|
||||
return (
|
||||
<TooltipProvider key={mailbox.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors',
|
||||
checked &&
|
||||
'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleMailbox(mailbox.id)
|
||||
}
|
||||
onClick={e =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
|
||||
<span className="text-xs truncate">
|
||||
{mailbox.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="right">
|
||||
<div className="text-sm break-all">
|
||||
{mailbox.name}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
194
web/src/features/search/more-filters-popover.tsx
Normal file
194
web/src/features/search/more-filters-popover.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ListFilter } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
small: { min: undefined, max: 2 * 1024 * 1024 },
|
||||
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
|
||||
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
|
||||
huge: { min: 20 * 1024 * 1024, max: undefined },
|
||||
};
|
||||
|
||||
const getPresetFromSize = (min?: number, max?: number) => {
|
||||
if (min === SIZES.huge.min) return 'huge';
|
||||
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
|
||||
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
|
||||
if (!min && max === SIZES.small.max) return 'small';
|
||||
if (!min && max === SIZES.tiny.max) return 'tiny';
|
||||
return 'any';
|
||||
};
|
||||
|
||||
export function MoreFiltersPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
});
|
||||
}
|
||||
}, [open, filter]);
|
||||
|
||||
const handleApply = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
|
||||
else delete next.attachment_name;
|
||||
|
||||
if (localState.message_id) next.message_id = localState.message_id;
|
||||
else delete next.message_id;
|
||||
|
||||
if (localState.has_attachment) next.has_attachment = true;
|
||||
else delete next.has_attachment;
|
||||
|
||||
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
|
||||
if (range.min) next.min_size = range.min; else delete next.min_size;
|
||||
if (range.max) next.max_size = range.max; else delete next.max_size;
|
||||
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const activeCount = [
|
||||
filter?.attachment_name,
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.message_id,
|
||||
filter?.has_attachment
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 rounded-none border-l-0",
|
||||
activeCount > 0 && "bg-primary/10 border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{t('search_more.trigger_label')}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
delete next.attachment_name;
|
||||
delete next.min_size;
|
||||
delete next.max_size;
|
||||
delete next.message_id;
|
||||
delete next.has_attachment;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('search_more.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center space-x-2 px-1">
|
||||
<Checkbox
|
||||
id="has_attachment"
|
||||
checked={localState.has_attachment}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="has_attachment"
|
||||
className="text-xs font-normal cursor-pointer select-none"
|
||||
>
|
||||
{t('search_more.has_attachment')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.attachment_name}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
|
||||
<Select
|
||||
value={localState.size_preset}
|
||||
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(SIZES).concat('any').map((key) => (
|
||||
<SelectItem key={key} className="text-xs" value={key}>
|
||||
{t(`search_more.size_presets.${key}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_id_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.message_id}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, message_id: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground opacity-70 leading-tight">
|
||||
{t('search_more.message_id_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
|
||||
{t('search_more.apply')}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,52 @@ import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useSearchContext } from './context'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="mt-3 rounded-md border bg-muted/20 p-3 text-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1.5">
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.subject")}:</span>
|
||||
<div className="break-words font-medium">
|
||||
{envelope.subject || <em className="italic opacity-70">(No subject)</em>}
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.from")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.from}
|
||||
</div>
|
||||
|
||||
{envelope.to?.length > 0 && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("mail.to")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.to.slice(0, 2).join(", ")}
|
||||
{envelope.to.length > 2 && " …"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.date")}:</span>
|
||||
<div className="text-foreground/90">
|
||||
{new Date(envelope.date).toLocaleString()}
|
||||
</div>
|
||||
|
||||
{envelope.mailbox_name && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("search.mailbox")}:</span>
|
||||
<div className="truncate text-foreground/90" title={envelope.mailbox_name}>
|
||||
{envelope.mailbox_name}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
@@ -36,12 +82,26 @@ export function RestoreMessageDialog({
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
const { currentEnvelope, selected } = useSearchContext()
|
||||
|
||||
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
|
||||
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
|
||||
const accountCount = accountsWithSelection.length;
|
||||
|
||||
const isBulk = selectedCount > 0;
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]),
|
||||
mutationFn: async () => {
|
||||
if (isBulk) {
|
||||
const promises = accountsWithSelection.map(([accountId, ids]) =>
|
||||
restore_message(accountId, Array.from(ids))
|
||||
);
|
||||
return Promise.all(promises);
|
||||
} else if (currentEnvelope) {
|
||||
return restore_message(currentEnvelope.account_id, [currentEnvelope.id]);
|
||||
}
|
||||
},
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
@@ -90,16 +150,37 @@ export function RestoreMessageDialog({
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('restore_message.title', 'Restore messages')}
|
||||
desc={t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
title={isBulk ? t('restore_message.bulkTitle', 'Restore multiple messages') : t('restore_message.title', 'Restore message')}
|
||||
desc={<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isBulk ? (
|
||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-3 text-sm">
|
||||
<div className="flex justify-between items-center text-primary font-medium">
|
||||
<span>{t('restore_message.summary', 'Summary')}</span>
|
||||
<span className="bg-primary/10 px-2 py-0.5 rounded text-xs">
|
||||
{selectedCount} {t('restore_message.messages', 'messages')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs space-y-1 text-muted-foreground">
|
||||
<p>• {t('restore_message.accountsInvolved', 'Accounts involved')}: {accountCount}</p>
|
||||
<p>• {t('restore_message.bulkWarning', 'Messages will be restored to their original folders.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
currentEnvelope && <MessageSummary envelope={currentEnvelope} t={t} />
|
||||
)}
|
||||
</div>}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate()}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending || (!isBulk && !currentEnvelope)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { DatePicker } from "@/components/date-picker";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchContext } from "./context";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
|
||||
text: z.string().optional().or(z.literal("")),
|
||||
from: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
to: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
cc: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
bcc: z
|
||||
.string()
|
||||
.email({ message: t('validation.invalidEmail') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
has_attachment: z.boolean().optional(),
|
||||
attachment_name: z.string().optional().or(z.literal("")),
|
||||
since: z.date().optional(),
|
||||
before: z.date().optional(),
|
||||
account_id: z.number().optional().or(z.literal("")),
|
||||
mailbox_id: z.number().optional().or(z.literal("")),
|
||||
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
|
||||
message_id: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
|
||||
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: Record<string, any>) => void,
|
||||
isLoading: boolean,
|
||||
reset: () => void,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const isEmptyValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (value === '') return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
if (typeof value === 'number' && isNaN(value)) return true;
|
||||
if (value === false) return true;
|
||||
if (value === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
|
||||
) as Partial<T>;
|
||||
};
|
||||
|
||||
function withSizePreset(values: Record<string, any>) {
|
||||
const { size_preset, ...rest } = values;
|
||||
|
||||
switch (size_preset) {
|
||||
case 'tiny':
|
||||
return { ...rest, max_size: 15 * 1024 };
|
||||
case 'small':
|
||||
return { ...rest, max_size: 2 * 1024 * 1024 };
|
||||
case 'medium':
|
||||
return { ...rest, min_size: 2 * 1024 * 1024, max_size: 10 * 1024 * 1024 };
|
||||
case 'large':
|
||||
return { ...rest, min_size: 10 * 1024 * 1024, max_size: 20 * 1024 * 1024 };
|
||||
case 'huge':
|
||||
return { ...rest, min_size: 20 * 1024 * 1024 };
|
||||
default:
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
|
||||
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
||||
const { selectedTags } = useSearchContext();
|
||||
|
||||
const searchFilterSchema = getSearchFilterSchema(t)
|
||||
const form = useForm<SearchFilterForm>({
|
||||
resolver: zodResolver(searchFilterSchema),
|
||||
defaultValues: {
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
attachment_name: "",
|
||||
message_id: "",
|
||||
size_preset: 'any',
|
||||
has_attachment: false,
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
|
||||
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
||||
enabled: !!selectedAccountId,
|
||||
})
|
||||
|
||||
|
||||
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
|
||||
value: mailbox.id.toString(),
|
||||
label: mailbox.name,
|
||||
})) || [];
|
||||
|
||||
|
||||
const handleSubmit = (values: Record<string, any>) => {
|
||||
let cleaned = cleanEmpty(values);
|
||||
const payload = withSizePreset(cleaned);
|
||||
|
||||
|
||||
const finalPayload =
|
||||
selectedTags.length > 0
|
||||
? { ...payload, tags: selectedTags }
|
||||
: payload;
|
||||
|
||||
if (Object.keys(finalPayload).length > 0) {
|
||||
onSubmit(finalPayload);
|
||||
} else {
|
||||
toast({
|
||||
title: t('search.pleaseSelectAtLeastOne'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
form.reset({
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
has_attachment: false,
|
||||
attachment_name: "",
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
size_preset: 'any',
|
||||
message_id: "",
|
||||
});
|
||||
setSelectedAccountId(undefined);
|
||||
}
|
||||
|
||||
return (<Sheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<SheetContent className='w-full md:max-w-4xl mx-auto'>
|
||||
<SheetHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{t('search.searchArchivedEmails')}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetDescription>
|
||||
{t('search.fullTextMultiAccount')}
|
||||
</SheetDescription>
|
||||
<Form {...form}>
|
||||
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="account_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={accountsOptions}
|
||||
isLoading={accountsIsLoading}
|
||||
onSelectOption={(values) => {
|
||||
const account_id = parseInt(values[0], 10);
|
||||
setSelectedAccountId(account_id);
|
||||
field.onChange(account_id);
|
||||
}}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder={t('search.selectAccount')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">{t('search.noActiveEmailAccount')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: "/accounts" })}
|
||||
>
|
||||
{t('search.addEmailAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mailbox_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={mailboxesOptions}
|
||||
isLoading={isMailboxesLoading}
|
||||
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder={t('search.selectMailbox')}
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">
|
||||
{t('search.noMailboxSelectAccount')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('search.searchInSubjectBody')}
|
||||
className="h-11 text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 sm:ml-auto sm:self-center">
|
||||
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
|
||||
{isLoading ? t('search.searchingButton') : <>{t('search.searchButton')}</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{t('search.advanced')}
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => { handleClear(); reset(); }}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
{t('search.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="since"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.since')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="before"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.before')}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder={t('search.selectDate')}
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="has_attachment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attach"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
|
||||
{t('search.hasAttachment')}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||
{/* Sender & Recipients */}
|
||||
<AccordionItem value="people">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.sender')} / {t('search.recipient')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
|
||||
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize text-xs">
|
||||
{key === 'from' ? t('search.from') : key === 'to' ? t('search.to') : key === 'cc' ? t('search.cc') : t('search.bcc')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${key}@example.com`}
|
||||
className="h-9"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="attachment">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.attachmentsSize')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachment_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">{t('search.attachmentName')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="invoice.pdf" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="size_preset"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">
|
||||
{t('search.size')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder={t('search.any')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{t('search.any')}</SelectItem>
|
||||
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
|
||||
<SelectItem value="small">{t('search.small')}</SelectItem>
|
||||
<SelectItem value="medium">{t('search.medium')}</SelectItem>
|
||||
<SelectItem value="large">{t('search.large')}</SelectItem>
|
||||
<SelectItem value="huge">{t('search.huge')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.sizeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="ids">
|
||||
<AccordionTrigger className="text-sm">
|
||||
{t('search.messageId')}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormControl>
|
||||
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t('search.originalMessageIdHeader')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>);
|
||||
}
|
||||
83
web/src/features/search/table/data-table-column-header.tsx
Executable file
83
web/src/features/search/table/data-table-column-header.tsx
Executable file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className=' h-8 data-[state=open]:bg-accent'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ml-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ml-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ml-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.desc')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
web/src/features/search/table/data-table-row-actions.tsx
Executable file
122
web/src/features/search/table/data-table-row-actions.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MoreVertical, TagIcon, Trash2, Upload } from 'lucide-react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { useSearchContext } from '../context'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<EmailEnvelope>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, setSelected, setToDelete } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||
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 handleDelete = (envelope: EmailEnvelope) => {
|
||||
setToDelete(new Map())
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<MoreVertical size={10} />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setOpen("edit-tags")
|
||||
}}
|
||||
>
|
||||
{t('search.editTag')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setSelected(new Map())
|
||||
setOpen("restore")
|
||||
}}
|
||||
>
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
<DropdownMenuShortcut>
|
||||
<Upload size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original)
|
||||
}}
|
||||
className='!text-red-500'
|
||||
>
|
||||
{t('common.delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
168
web/src/features/search/table/table.tsx
Executable file
168
web/src/features/search/table/table.tsx
Executable file
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react'
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
Table as ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from '../context'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
className: string
|
||||
}
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<EmailEnvelope>[]
|
||||
data: EmailEnvelope[]
|
||||
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<EmailEnvelope>) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
children?: (table: Table<EmailEnvelope>) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||
const { sorting, setSorting } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
useEffect(() => {
|
||||
const [value] = sorting
|
||||
setSortBy(value.id.toUpperCase() as "DATE" | "SIZE")
|
||||
setSortOrder(value.desc ? "desc" : "asc")
|
||||
}, [sorting])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={header.column.columnDef.meta?.className ?? ''}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn("group/row cursor-pointer transition-colors hover:bg-accent/50")}
|
||||
onClick={(e) => onRowClick(e, row)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.columnDef.meta?.className ?? ''}
|
||||
style={{
|
||||
width: cell.column.columnDef.size,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
maxWidth: cell.column.columnDef.maxSize
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
</TableBody>
|
||||
</ShadcnTable>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
web/src/features/search/table/toolbar.tsx
Normal file
41
web/src/features/search/table/toolbar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { AccountMailboxFilter } from '../account-mailbox-filter'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { MailFilterPopover } from '../contact-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||
import { FilterResetButton } from '../filter-reset'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
|
||||
<div className="flex-1">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||
<TagFilterPopover />
|
||||
<AccountMailboxFilter />
|
||||
<MailFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
web/src/features/search/table/view-options.tsx
Normal file
80
web/src/features/search/table/view-options.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const defaultColumns = (t: (key: string) => string) => [
|
||||
{ label: t('search.account'), value: "account_email" },
|
||||
{ label: t('search.mailbox'), value: "mailbox_name" },
|
||||
{ label: t('search.from'), value: "from" },
|
||||
{ label: t('search.to'), value: "to" },
|
||||
{ label: t('search.subject'), value: "subject" },
|
||||
{ label: t('search.size'), value: "size" },
|
||||
{ label: t('search.date'), value: "date" },
|
||||
]
|
||||
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const columnLabels = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
defaultColumns(t).map(col => [col.value, col.label])
|
||||
)
|
||||
}, [t]);
|
||||
|
||||
|
||||
const visibleColumnKeys = React.useMemo(() => {
|
||||
return new Set(defaultColumns(t).map(c => c.value))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
{t('search_view.button_label')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => visibleColumnKeys.has(column.id))
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize text-xs'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{columnLabels[column.id] ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp, Tag } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EnvelopeTagsProps {
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const {
|
||||
tagsCount: tagsCount = [],
|
||||
isLoading: tagsIsLoading,
|
||||
} = useAvailableTags();
|
||||
|
||||
const sortedTags = React.useMemo(() => {
|
||||
return [...tagsCount].sort((a, b) => b.count - a.count);
|
||||
}, [tagsCount]);
|
||||
|
||||
if (tagsIsLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
|
||||
<div className="h-4 w-4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 flex-1 bg-muted animate-pulse rounded" />
|
||||
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('mail.tags')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-0">
|
||||
{sortedTags.length === 0 ? (
|
||||
<p className="py-2 pl-2 text-sm text-muted-foreground">{t('mail.noTagsYet')}</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-12rem)] w-full pr-4 -mr-4">
|
||||
{sortedTags.map(({ tag: facet, count }) => {
|
||||
const checked = selectedTags.includes(facet);
|
||||
const id = `tag-${facet}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={facet}
|
||||
className="flex items-center gap-3 px-2 py-0.5 hover:bg-accent/80 rounded-md transition-colors cursor-pointer group"
|
||||
onClick={() => onTagToggle(facet)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onTagToggle(facet)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 max-w-[140px] lg:max-w-[120px] cursor-pointer truncate text-sm font-medium"
|
||||
title={facet}
|
||||
>
|
||||
{facet}
|
||||
</Label>
|
||||
<div className="shrink-0 ml-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-medium min-w-[1.75rem] text-center"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
193
web/src/features/search/tag-filter-popover.tsx
Normal file
193
web/src/features/search/tag-filter-popover.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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 { useAvailableTags } from '@/hooks/use-available-tags'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function TagFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const selectedTags = (filter?.tags as string[]) || []
|
||||
const {
|
||||
tagsCount = [],
|
||||
isLoading,
|
||||
} = useAvailableTags()
|
||||
|
||||
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-8 gap-1.5 px-3 rounded-none',
|
||||
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>
|
||||
)
|
||||
}
|
||||
200
web/src/features/search/text-search-input.tsx
Normal file
200
web/src/features/search/text-search-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, X, Clock, Trash2, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const STORAGE_KEY = "mail_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [value, setValue] = useState(filter.text || "")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
setHistory(JSON.parse(saved))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load search history", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(filter.text || "")
|
||||
}, [filter.text])
|
||||
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
if (!term.trim()) return
|
||||
|
||||
setHistory(prev => {
|
||||
const trimmed = term.trim()
|
||||
const withoutCurrent = prev.filter(item => item !== trimmed)
|
||||
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
|
||||
} catch (err) {
|
||||
console.warn("Failed to save search history", err)
|
||||
}
|
||||
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const trimmed = value.trim()
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
text: trimmed || undefined
|
||||
}))
|
||||
if (trimmed) {
|
||||
saveToHistory(trimmed)
|
||||
}
|
||||
setShowHistory(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setValue("")
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.text
|
||||
return next
|
||||
})
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
setShowHistory(false)
|
||||
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
setHistory([])
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear search history", err)
|
||||
}
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const isActive = !!filter.text?.trim()
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search_input.placeholder')}
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-9 text-sm",
|
||||
isActive && "border-primary/50 focus-visible:ring-primary/30"
|
||||
)}
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-5"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t('search_input.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围提示 */}
|
||||
<div className="flex items-center gap-1 px-1 opacity-60">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('search_input.hint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
|
||||
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t('search_input.recent_title')}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t('search_input.clear_history')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{term}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
|
||||
{t('search_input.no_history')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
web/src/features/search/time-popover.tsx
Normal file
205
web/src/features/search/time-popover.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import * as React from 'react'
|
||||
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
|
||||
const DAY = 86400000
|
||||
|
||||
export function TimePopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [customDays, setCustomDays] = React.useState<string>('')
|
||||
|
||||
const since = filter.since
|
||||
const before = filter.before
|
||||
|
||||
const toDate = (ts: number) => {
|
||||
return format(ts, t('time.format'))
|
||||
}
|
||||
|
||||
const label = (s?: number, b?: number) => {
|
||||
if (!s && !b) return t('time.label')
|
||||
if (s && b) return `${toDate(s)} → ${toDate(b)}`
|
||||
if (s) return `${t('time.since')} ${toDate(s)}`
|
||||
return `${t('time.before')} ${toDate(b!)}`
|
||||
}
|
||||
|
||||
const setRange = (s?: number, b?: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
s ? (next.since = s) : delete next.since
|
||||
b ? (next.before = b) : delete next.before
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setSince = (s?: number) => setRange(s, before)
|
||||
const setBefore = (b?: number) => setRange(since, b)
|
||||
|
||||
const handleApplyRecent = () => {
|
||||
const days = parseInt(customDays)
|
||||
if (!isNaN(days) && days > 0) {
|
||||
setRange(Date.now() - days * DAY, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setRange()
|
||||
setCustomDays('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
{label(since, before)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
|
||||
<Section title={t('time.recent_range')}>
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 7, 30].map(d => (
|
||||
<Quick key={d} onClick={() => setRange(Date.now() - d * DAY, undefined)}>
|
||||
{d === 1 ? t('time.last_day') : t('time.last_days', { count: d })}
|
||||
</Quick>
|
||||
))}
|
||||
{[3, 6].map(m => (
|
||||
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
|
||||
{t('time.last_months', { count: m })}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">{t('time.recent_prefix')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="10"
|
||||
className="h-8 w-20 text-xs"
|
||||
value={customDays}
|
||||
onChange={e => setCustomDays(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('time.days_ago_to_now')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 ml-auto text-xs"
|
||||
onClick={handleApplyRecent}
|
||||
>
|
||||
{t('time.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title={t('time.historical')}>
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{[1, 2, 3, 5, 10].map(y => (
|
||||
<Quick
|
||||
key={y}
|
||||
onClick={() => setRange(undefined, Date.now() - y * 365 * DAY)}
|
||||
className="border-orange-200 hover:border-orange-400 hover:text-orange-600"
|
||||
>
|
||||
{t('time.over_years_ago', {
|
||||
count: y,
|
||||
unit: y === 1 ? t('time.year') : t('time.years')
|
||||
})}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.since').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
onSelect={(date) => setSince(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.before').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{(since || before) && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clear}
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t('time.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<div className="text-[11px] font-semibold mb-2.5 text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Quick({
|
||||
children,
|
||||
onClick,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-7 px-2.5 text-xs font-normal hover:bg-primary/5 hover:text-primary shrink-0",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { get_top_tags } from '@/api/search/api';
|
||||
import { get_tags } from '@/api/search/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useAvailableTags(): UseAvailableTagsResult {
|
||||
refetch,
|
||||
} = useQuery<TagCount[]>({
|
||||
queryKey: ['all-tags'],
|
||||
queryFn: get_top_tags,
|
||||
queryFn: get_tags,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
26
web/src/hooks/use-contacts.ts
Normal file
26
web/src/hooks/use-contacts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_contacts } from '@/api/search/api';
|
||||
|
||||
export const useContacts = (searchTerm: string = "") => {
|
||||
const { data: allContacts = [], isLoading, isError } = useQuery({
|
||||
queryKey: ['contacts', 'all'],
|
||||
queryFn: get_contacts,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return allContacts;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return allContacts.filter(email =>
|
||||
email.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [allContacts, searchTerm]);
|
||||
|
||||
return {
|
||||
contacts: filtered,
|
||||
isLoading,
|
||||
isError
|
||||
};
|
||||
};
|
||||
@@ -20,18 +20,24 @@
|
||||
import { EmailEnvelope, PaginatedResponse } from '@/api';
|
||||
import { search_messages } from '@/api/search/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
|
||||
export function useSearchMessages() {
|
||||
// const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<Record<string, any>>({});
|
||||
const [filter, _setFilter] = useState<Record<string, any>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(30);
|
||||
const [sortBy, setSortBy] = useState<"DATE" | "SIZE">("DATE");
|
||||
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
|
||||
|
||||
const setFilter = React.useCallback((val: any) => {
|
||||
_setFilter(val);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const onSubmit = (cleaned: Record<string, any>) => {
|
||||
if ('has_attachment' in cleaned && cleaned.has_attachment === false) {
|
||||
delete cleaned.has_attachment;
|
||||
@@ -51,7 +57,6 @@ export function useSearchMessages() {
|
||||
|
||||
const reset = () => {
|
||||
setFilter({});
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -92,6 +97,7 @@ export function useSearchMessages() {
|
||||
setPage,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
filter,
|
||||
setFilter
|
||||
};
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "تسجيل الخروج"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "استعادة الرسائل",
|
||||
"desc": "سيقوم هذا الإجراء بإعادة رفع الرسائل المختارة من Bichon واستعادتها إلى صناديق البريد المقابلة لها على خادم IMAP.",
|
||||
"title": "استعادة رسالة واحدة",
|
||||
"bulkTitle": "استعادة رسائل متعددة",
|
||||
"bulkWarning": "ملاحظة: سيتم استعادة الرسائل إلى مجلداتها الأصلية.",
|
||||
"accountsInvolved": "الحسابات المعنية",
|
||||
"summary": "تفاصيل الاستعادة",
|
||||
"messages": "رسائل",
|
||||
"desc": "سيقوم هذا الإجراء بإعادة رفع الرسائل المحددة من Bichon واستعادتها إلى صناديق البريد المقابلة لها على خادم IMAP.",
|
||||
"confirm": "تنفيذ الاستعادة",
|
||||
"restore_to_imap": "استعادة البريد",
|
||||
"success": "تمت استعادة الرسائل بنجاح",
|
||||
"successDesc": "تمت استعادة الرسائل المختارة إلى خادم IMAP بنجاح.",
|
||||
"failed": "فشلت استعادة الرسائل",
|
||||
"failedTitle": "فشل الاستعادة"
|
||||
"restore_to_imap": "استعادة الرسالة",
|
||||
"success": "تمت الاستعادة بنجاح",
|
||||
"successDesc": "تمت استعادة الرسائل المحددة بنجاح إلى خادم IMAP.",
|
||||
"failed": "فشلت الاستعادة",
|
||||
"failedTitle": "فشل في الاستعادة"
|
||||
},
|
||||
"time": {
|
||||
"label": "الوقت",
|
||||
"since": "منذ",
|
||||
"before": "قبل",
|
||||
"recent_range": "النطاق الأخير (منذ...)",
|
||||
"historical": "النطاق السابق (قبل...)",
|
||||
"absolute_range": "نطاق تاريخ محدد",
|
||||
"last_day": "آخر يوم واحد",
|
||||
"last_days": "آخر {{count}} أيام",
|
||||
"last_months": "آخر {{count}} أشهر",
|
||||
"over_years_ago": "منذ {{count}} {{unit}}",
|
||||
"year": "سنة",
|
||||
"years": "سنوات",
|
||||
"recent_prefix": "الأخيرة:",
|
||||
"days_ago_to_now": "منذ أيام حتى الآن",
|
||||
"apply": "تطبيق",
|
||||
"start_date": "تاريخ البدء",
|
||||
"end_date": "تاريخ الانتهاء",
|
||||
"clear_filters": "مسح فلترة الوقت",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "الوسوم",
|
||||
"search_placeholder": "بحث عن الوسوم...",
|
||||
"clear_all": "مسح جميع الوسوم",
|
||||
"no_tags_found": "لم يتم العثور على وسوم"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "حسابات البريد",
|
||||
"search_placeholder": "بحث عن حسابات البريد...",
|
||||
"clear_accounts": "مسح الحسابات المحددة",
|
||||
"no_accounts_found": "لم يتم العثور على حسابات"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "مجلدات البريد",
|
||||
"search_placeholder": "بحث في المجلدات",
|
||||
"clear_mailboxes": "مسح المجلدات المحددة",
|
||||
"select_account_first": "يرجى اختيار حساب بريد أولاً",
|
||||
"no_mailbox_found": "لم يتم العثور على مجلد"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "جهات الاتصال",
|
||||
"label_with_count": "جهات الاتصال ({{count}})",
|
||||
"any": "الكل",
|
||||
"search_placeholder": "بحث في {{field}}...",
|
||||
"reset_all": "إعادة تعيين جميع جهات الاتصال",
|
||||
"no_contact_found": "لم يتم العثور على جهات اتصال",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"showing_limit": "عرض أول 100 نتيجة فقط • المجموع {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "متقدم",
|
||||
"title": "فلاتر متقدمة",
|
||||
"reset": "إعادة تعيين",
|
||||
"has_attachment": "يحتوي على مرفق",
|
||||
"attachment_name_label": "اسم المرفق",
|
||||
"attachment_name_placeholder": "مثال: invoice.pdf",
|
||||
"message_size_label": "حجم الرسالة",
|
||||
"message_id_label": "معرّف Message-ID الأصلي",
|
||||
"message_id_description": "البحث باستخدام Message-ID في ترويسة البريد",
|
||||
"apply": "تطبيق الفلاتر",
|
||||
"size_presets": {
|
||||
"any": "أي حجم",
|
||||
"tiny": "صغير جداً (< 15 كيلوبايت)",
|
||||
"small": "صغير (< 2 ميغابايت)",
|
||||
"medium": "متوسط (2 – 10 ميغابايت)",
|
||||
"large": "كبير (10 – 20 ميغابايت)",
|
||||
"huge": "ضخم (> 20 ميغابايت)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "العرض",
|
||||
"menu_title": "إعدادات الأعمدة"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "إعادة تعيين",
|
||||
"tooltip": "مسح جميع شروط التصفية"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "البحث في البريد... (استخدم \"علامات الاقتباس\" للمطابقة التامة)",
|
||||
"button": "بحث",
|
||||
"recent_title": "عمليات البحث الأخيرة",
|
||||
"clear_history": "مسح السجل",
|
||||
"no_history": "لا يوجد سجل بحث",
|
||||
"hint": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Log ud"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Gendan meddelelser",
|
||||
"desc": "Denne handling vil uploade de valgte meddelelser fra Bichon og gendanne dem til deres tilsvarende postkasser på IMAP-serveren.",
|
||||
"confirm": "Gendan",
|
||||
"restore_to_imap": "Gendan e-mail",
|
||||
"success": "Meddelelser gendannet",
|
||||
"successDesc": "De valgte meddelelser er blevet gendannet til IMAP-serveren.",
|
||||
"failed": "Kunne ikke gendanne meddelelser",
|
||||
"failedTitle": "Gendannelse mislykkedes"
|
||||
"title": "Gendan enkelt besked",
|
||||
"bulkTitle": "Gendan flere beskeder",
|
||||
"bulkWarning": "Bemærk: Beskeder vil blive gendannet til deres oprindelige mapper.",
|
||||
"accountsInvolved": "Involverede konti",
|
||||
"summary": "Gendannelsesdetaljer",
|
||||
"messages": "beskeder",
|
||||
"desc": "Denne handling vil gen-uploade valgte beskeder fra Bichon og gendanne dem til deres tilsvarende postkasser på IMAP-serveren.",
|
||||
"confirm": "Udfør gendannelse",
|
||||
"restore_to_imap": "Gendan besked",
|
||||
"success": "Gendannelse gennemført",
|
||||
"successDesc": "De valgte beskeder er blevet gendannet til IMAP-serveren.",
|
||||
"failed": "Gendannelse mislykkedes",
|
||||
"failedTitle": "Fejl ved gendannelse"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Siden",
|
||||
"before": "Før",
|
||||
"recent_range": "Seneste interval (siden...)",
|
||||
"historical": "Historisk interval (før...)",
|
||||
"absolute_range": "Absolut datointerval",
|
||||
"last_day": "Seneste 1 dag",
|
||||
"last_days": "Seneste {{count}} dage",
|
||||
"last_months": "Seneste {{count}} måneder",
|
||||
"over_years_ago": "For {{count}} {{unit}} siden",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Seneste:",
|
||||
"days_ago_to_now": "dage siden til nu",
|
||||
"apply": "Anvend",
|
||||
"start_date": "Startdato",
|
||||
"end_date": "Slutdato",
|
||||
"clear_filters": "Ryd tidsfiltre",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Søg tags...",
|
||||
"clear_all": "Ryd alle tags",
|
||||
"no_tags_found": "Ingen tags fundet"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Mailkonti",
|
||||
"search_placeholder": "Søg mailkonti...",
|
||||
"clear_accounts": "Ryd valgte konti",
|
||||
"no_accounts_found": "Ingen konti fundet"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postkasser",
|
||||
"search_placeholder": "Søg postkasser",
|
||||
"clear_mailboxes": "Ryd valgte mapper",
|
||||
"select_account_first": "Vælg venligst en mailkonto først",
|
||||
"no_mailbox_found": "Ingen mappe fundet"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søg i {{field}}...",
|
||||
"reset_all": "Nulstil alle kontakter",
|
||||
"no_contact_found": "Ingen kontakter fundet",
|
||||
"loading": "Indlæser...",
|
||||
"showing_limit": "Viser kun de første 100 resultater • I alt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanceret",
|
||||
"title": "Avancerede filtre",
|
||||
"reset": "Nulstil",
|
||||
"has_attachment": "Har vedhæftning",
|
||||
"attachment_name_label": "Navn på vedhæftning",
|
||||
"attachment_name_placeholder": "F.eks.: invoice.pdf",
|
||||
"message_size_label": "Mailstørrelse",
|
||||
"message_id_label": "Original Message-ID",
|
||||
"message_id_description": "Søg via Message-ID i mailheaderen",
|
||||
"apply": "Anvend filtre",
|
||||
"size_presets": {
|
||||
"any": "Alle størrelser",
|
||||
"tiny": "Meget lille (< 15 KB)",
|
||||
"small": "Lille (< 2 MB)",
|
||||
"medium": "Mellem (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Meget stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
"menu_title": "Indstillinger for kolonner"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Nulstil",
|
||||
"tooltip": "Ryd alle filtre"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Søg i mails... (brug \"anførselstegn\" til nøjagtig match)",
|
||||
"button": "Søg",
|
||||
"recent_title": "Seneste søgninger",
|
||||
"clear_history": "Ryd historik",
|
||||
"no_history": "Ingen søgehistorik",
|
||||
"hint": "Standard søgeområde: emne, indhold og vedhæftningsnavne"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Abmelden"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Nachrichten wiederherstellen",
|
||||
"desc": "Diese Aktion lädt die ausgewählten Nachrichten von Bichon hoch und stellt sie in den entsprechenden Postfächern auf dem IMAP-Server wieder her.",
|
||||
"confirm": "Wiederherstellen",
|
||||
"restore_to_imap": "E-Mail wiederherstellen",
|
||||
"success": "Nachrichten wiederhergestellt",
|
||||
"title": "Einzelne Nachricht wiederherstellen",
|
||||
"bulkTitle": "Mehrere Nachrichten wiederherstellen",
|
||||
"bulkWarning": "Hinweis: Nachrichten werden in ihren ursprünglichen Ordnern wiederhergestellt.",
|
||||
"accountsInvolved": "Beteiligte Konten",
|
||||
"summary": "Details zur Wiederherstellung",
|
||||
"messages": "Nachrichten",
|
||||
"desc": "Diese Aktion lädt die ausgewählten Nachrichten von Bichon erneut hoch und stellt sie in den entsprechenden Postfächern auf dem IMAP-Server wieder her.",
|
||||
"confirm": "Wiederherstellung ausführen",
|
||||
"restore_to_imap": "Nachricht wiederherstellen",
|
||||
"success": "Wiederherstellung erfolgreich",
|
||||
"successDesc": "Die ausgewählten Nachrichten wurden erfolgreich auf dem IMAP-Server wiederhergestellt.",
|
||||
"failed": "Wiederherstellung fehlgeschlagen",
|
||||
"failedTitle": "Fehler bei der Wiederherstellung"
|
||||
},
|
||||
"time": {
|
||||
"label": "Zeit",
|
||||
"since": "Seit",
|
||||
"before": "Vor",
|
||||
"recent_range": "Letzter Zeitraum (seit …)",
|
||||
"historical": "Historischer Zeitraum (vor …)",
|
||||
"absolute_range": "Absoluter Datumsbereich",
|
||||
"last_day": "Letzter 1 Tag",
|
||||
"last_days": "Letzte {{count}} Tage",
|
||||
"last_months": "Letzte {{count}} Monate",
|
||||
"over_years_ago": "Vor {{count}} {{unit}}",
|
||||
"year": "Jahr",
|
||||
"years": "Jahre",
|
||||
"recent_prefix": "Kürzlich:",
|
||||
"days_ago_to_now": "Tage bis heute",
|
||||
"apply": "Anwenden",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Enddatum",
|
||||
"clear_filters": "Zeitfilter löschen",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Tags suchen...",
|
||||
"clear_all": "Alle Tags löschen",
|
||||
"no_tags_found": "Keine Tags gefunden"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-Mail-Konten",
|
||||
"search_placeholder": "E-Mail-Konten suchen...",
|
||||
"clear_accounts": "Ausgewählte Konten löschen",
|
||||
"no_accounts_found": "Keine Konten gefunden"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postfächer",
|
||||
"search_placeholder": "Postfächer durchsuchen",
|
||||
"clear_mailboxes": "Ausgewählte Ordner löschen",
|
||||
"select_account_first": "Bitte zuerst ein E-Mail-Konto auswählen",
|
||||
"no_mailbox_found": "Kein Ordner gefunden"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakte",
|
||||
"label_with_count": "Kontakte ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "{{field}} suchen...",
|
||||
"reset_all": "Alle Kontakte zurücksetzen",
|
||||
"no_contact_found": "Keine Kontakte gefunden",
|
||||
"loading": "Wird geladen...",
|
||||
"showing_limit": "Nur die ersten 100 Ergebnisse angezeigt • Gesamt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Erweitert",
|
||||
"title": "Erweiterte Filter",
|
||||
"reset": "Zurücksetzen",
|
||||
"has_attachment": "Mit Anhang",
|
||||
"attachment_name_label": "Anhangname",
|
||||
"attachment_name_placeholder": "z. B. invoice.pdf",
|
||||
"message_size_label": "Nachrichtengröße",
|
||||
"message_id_label": "Originale Message-ID",
|
||||
"message_id_description": "Suche über die Message-ID im E-Mail-Header",
|
||||
"apply": "Filter anwenden",
|
||||
"size_presets": {
|
||||
"any": "Beliebige Größe",
|
||||
"tiny": "Sehr klein (< 15 KB)",
|
||||
"small": "Klein (< 2 MB)",
|
||||
"medium": "Mittel (2 – 10 MB)",
|
||||
"large": "Groß (10 – 20 MB)",
|
||||
"huge": "Sehr groß (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Ansicht",
|
||||
"menu_title": "Spalteneinstellungen"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Zurücksetzen",
|
||||
"tooltip": "Alle Filter entfernen"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "E-Mails durchsuchen... (\"Anführungszeichen\" für exakte Übereinstimmung)",
|
||||
"button": "Suchen",
|
||||
"recent_title": "Letzte Suchanfragen",
|
||||
"clear_history": "Verlauf löschen",
|
||||
"no_history": "Kein Suchverlauf",
|
||||
"hint": "Standard-Suchbereich: Betreff, Inhalt und Anhangnamen"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Sign out"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Restore Messages",
|
||||
"desc": "This action will append the selected messages from Bichon back to their corresponding mailboxes on the IMAP server.",
|
||||
"confirm": "Restore",
|
||||
"restore_to_imap": "Restore Mail",
|
||||
"title": "Restore Single Message",
|
||||
"bulkTitle": "Bulk Restore Messages",
|
||||
"bulkWarning": "Note: Messages will be restored to their original storage folders.",
|
||||
"accountsInvolved": "Accounts involved",
|
||||
"summary": "Restoration Details",
|
||||
"messages": "messages",
|
||||
"desc": "This action will re-upload selected messages from Bichon and restore them to their corresponding mailboxes on the IMAP server.",
|
||||
"confirm": "Restore Now",
|
||||
"restore_to_imap": "Restore Message",
|
||||
"success": "Messages Restored",
|
||||
"successDesc": "The selected messages have been successfully restored to the IMAP server.",
|
||||
"failed": "Failed to restore messages",
|
||||
"failedTitle": "Restore Failed"
|
||||
"failed": "Restore Failed",
|
||||
"failedTitle": "Failed to Restore"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"since": "Since",
|
||||
"before": "Before",
|
||||
"recent_range": "Recent range (since...)",
|
||||
"historical": "Historical range (before...)",
|
||||
"absolute_range": "Absolute date range",
|
||||
"last_day": "Last 1 day",
|
||||
"last_days": "Last {{count}} days",
|
||||
"last_months": "Last {{count}} months",
|
||||
"over_years_ago": "{{count}} {{unit}} ago",
|
||||
"year": "year",
|
||||
"years": "years",
|
||||
"recent_prefix": "Recent:",
|
||||
"days_ago_to_now": "days ago to now",
|
||||
"apply": "Apply",
|
||||
"start_date": "Start date",
|
||||
"end_date": "End date",
|
||||
"clear_filters": "Clear time filters",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Search tags...",
|
||||
"clear_all": "Clear all tags",
|
||||
"no_tags_found": "No tags found"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Mail accounts",
|
||||
"search_placeholder": "Search mail accounts...",
|
||||
"clear_accounts": "Clear selected accounts",
|
||||
"no_accounts_found": "No accounts found"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Mailboxes",
|
||||
"search_placeholder": "Search mailboxes",
|
||||
"clear_mailboxes": "Clear selected mailboxes",
|
||||
"select_account_first": "Please select an account first",
|
||||
"no_mailbox_found": "No mailbox found"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacts",
|
||||
"label_with_count": "Contacts ({{count}})",
|
||||
"any": "Any",
|
||||
"search_placeholder": "Search {{field}}...",
|
||||
"reset_all": "Reset all contacts",
|
||||
"no_contact_found": "No contacts found",
|
||||
"loading": "Loading...",
|
||||
"showing_limit": "Showing first 100 results • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Advanced",
|
||||
"title": "Advanced filters",
|
||||
"reset": "Reset",
|
||||
"has_attachment": "Has attachment",
|
||||
"attachment_name_label": "Attachment name",
|
||||
"attachment_name_placeholder": "e.g. invoice.pdf",
|
||||
"message_size_label": "Message size",
|
||||
"message_id_label": "Original Message-ID",
|
||||
"message_id_description": "Search by Message-ID header",
|
||||
"apply": "Apply filters",
|
||||
"size_presets": {
|
||||
"any": "Any size",
|
||||
"tiny": "Tiny (< 15 KB)",
|
||||
"small": "Small (< 2 MB)",
|
||||
"medium": "Medium (2 – 10 MB)",
|
||||
"large": "Large (10 – 20 MB)",
|
||||
"huge": "Huge (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "View",
|
||||
"menu_title": "Column settings"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reset",
|
||||
"tooltip": "Clear all filters"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Search emails... (use \"quotes\" for exact match)",
|
||||
"button": "Search",
|
||||
"recent_title": "Recent searches",
|
||||
"clear_history": "Clear history",
|
||||
"no_history": "No search history",
|
||||
"hint": "Default scope: subject, body and attachment names"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Cerrar sesión"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Restaurar mensajes",
|
||||
"desc": "Esta acción volverá a cargar los mensajes seleccionados de Bichon y los restaurará en sus carpetas correspondientes en el servidor IMAP.",
|
||||
"confirm": "Restaurar",
|
||||
"restore_to_imap": "Restaurar correo",
|
||||
"success": "Mensajes restaurados",
|
||||
"title": "Restaurar mensaje individual",
|
||||
"bulkTitle": "Restaurar múltiples mensajes",
|
||||
"bulkWarning": "Nota: Los mensajes se restaurarán en sus carpetas originales.",
|
||||
"accountsInvolved": "Cuentas involucradas",
|
||||
"summary": "Detalles de restauración",
|
||||
"messages": "mensajes",
|
||||
"desc": "Esta acción volverá a cargar los mensajes seleccionados desde Bichon y los restaurará en sus buzones correspondientes en el servidor IMAP.",
|
||||
"confirm": "Ejecutar restauración",
|
||||
"restore_to_imap": "Restaurar mensaje",
|
||||
"success": "Restauración exitosa",
|
||||
"successDesc": "Los mensajes seleccionados se han restaurado correctamente en el servidor IMAP.",
|
||||
"failed": "Error al restaurar los mensajes",
|
||||
"failedTitle": "Error de restauración"
|
||||
"failed": "Error al restaurar",
|
||||
"failedTitle": "Fallo en la restauración"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tiempo",
|
||||
"since": "Desde",
|
||||
"before": "Antes de",
|
||||
"recent_range": "Rango reciente (desde...)",
|
||||
"historical": "Rango histórico (antes de...)",
|
||||
"absolute_range": "Rango de fechas absoluto",
|
||||
"last_day": "Último 1 día",
|
||||
"last_days": "Últimos {{count}} días",
|
||||
"last_months": "Últimos {{count}} meses",
|
||||
"over_years_ago": "Hace {{count}} {{unit}}",
|
||||
"year": "año",
|
||||
"years": "años",
|
||||
"recent_prefix": "Reciente:",
|
||||
"days_ago_to_now": "días atrás hasta ahora",
|
||||
"apply": "Aplicar",
|
||||
"start_date": "Fecha de inicio",
|
||||
"end_date": "Fecha de fin",
|
||||
"clear_filters": "Borrar filtros de tiempo",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiquetas",
|
||||
"search_placeholder": "Buscar etiquetas...",
|
||||
"clear_all": "Borrar todas las etiquetas",
|
||||
"no_tags_found": "No se encontraron etiquetas"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Cuentas de correo",
|
||||
"search_placeholder": "Buscar cuentas de correo...",
|
||||
"clear_accounts": "Borrar cuentas seleccionadas",
|
||||
"no_accounts_found": "No se encontraron cuentas"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Buzones",
|
||||
"search_placeholder": "Buscar buzones",
|
||||
"clear_mailboxes": "Borrar carpetas seleccionadas",
|
||||
"select_account_first": "Seleccione primero una cuenta de correo",
|
||||
"no_mailbox_found": "No se encontró ningún buzón"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contactos",
|
||||
"label_with_count": "Contactos ({{count}})",
|
||||
"any": "Cualquiera",
|
||||
"search_placeholder": "Buscar {{field}}...",
|
||||
"reset_all": "Restablecer todos los contactos",
|
||||
"no_contact_found": "No se encontraron contactos",
|
||||
"loading": "Cargando...",
|
||||
"showing_limit": "Mostrando solo los primeros 100 resultados • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanzado",
|
||||
"title": "Filtros avanzados",
|
||||
"reset": "Restablecer",
|
||||
"has_attachment": "Con archivo adjunto",
|
||||
"attachment_name_label": "Nombre del archivo adjunto",
|
||||
"attachment_name_placeholder": "Ej.: invoice.pdf",
|
||||
"message_size_label": "Tamaño del mensaje",
|
||||
"message_id_label": "Message-ID original",
|
||||
"message_id_description": "Buscar usando el encabezado Message-ID del correo",
|
||||
"apply": "Aplicar filtros",
|
||||
"size_presets": {
|
||||
"any": "Cualquier tamaño",
|
||||
"tiny": "Muy pequeño (< 15 KB)",
|
||||
"small": "Pequeño (< 2 MB)",
|
||||
"medium": "Mediano (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muy grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
"menu_title": "Configuración de columnas"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Restablecer",
|
||||
"tooltip": "Eliminar todos los filtros"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Buscar correos... (usa \"comillas\" para coincidencia exacta)",
|
||||
"button": "Buscar",
|
||||
"recent_title": "Búsquedas recientes",
|
||||
"clear_history": "Borrar historial",
|
||||
"no_history": "Sin historial de búsqueda",
|
||||
"hint": "Ámbito de búsqueda predeterminado: asunto, contenido y nombres de archivos adjuntos"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Kirjaudu ulos"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Palauta viestit",
|
||||
"desc": "Tämä toiminto lataa valitut viestit Bichonista ja palauttaa ne vastaaviin postilaatikoihin IMAP-palvelimella.",
|
||||
"confirm": "Palauta",
|
||||
"restore_to_imap": "Palauta sähköposti",
|
||||
"success": "Viestit palautettu",
|
||||
"title": "Palauta yksittäinen viesti",
|
||||
"bulkTitle": "Palauta useita viestejä",
|
||||
"bulkWarning": "Huomio: Viestit palautetaan niiden alkuperäisiin kansioihin.",
|
||||
"accountsInvolved": "Osalliset tilit",
|
||||
"summary": "Palautuksen tiedot",
|
||||
"messages": "viestiä",
|
||||
"desc": "Tämä toiminto lataa valitut viestit uudelleen Bichonista ja palauttaa ne vastaaviin postilaatikoihin IMAP-palvelimella.",
|
||||
"confirm": "Suorita palautus",
|
||||
"restore_to_imap": "Palauta viesti",
|
||||
"success": "Palautus onnistui",
|
||||
"successDesc": "Valitut viestit on palautettu onnistuneesti IMAP-palvelimelle.",
|
||||
"failed": "Viestien palautus epäonnistui",
|
||||
"failedTitle": "Palautus epäonnistui"
|
||||
"failed": "Palautus epäonnistui",
|
||||
"failedTitle": "Palautusvirhe"
|
||||
},
|
||||
"time": {
|
||||
"label": "Aika",
|
||||
"since": "Alkaen",
|
||||
"before": "Ennen",
|
||||
"recent_range": "Viimeisin aikaväli (alkaen...)",
|
||||
"historical": "Historiallinen aikaväli (ennen...)",
|
||||
"absolute_range": "Kiinteä päivämääräväli",
|
||||
"last_day": "Viimeiset 1 päivä",
|
||||
"last_days": "Viimeiset {{count}} päivää",
|
||||
"last_months": "Viimeiset {{count}} kuukautta",
|
||||
"over_years_ago": "{{count}} {{unit}} sitten",
|
||||
"year": "vuosi",
|
||||
"years": "vuotta",
|
||||
"recent_prefix": "Viimeisin:",
|
||||
"days_ago_to_now": "päivää sitten – nyt",
|
||||
"apply": "Käytä",
|
||||
"start_date": "Aloituspäivä",
|
||||
"end_date": "Päättymispäivä",
|
||||
"clear_filters": "Tyhjennä aikasuodattimet",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tunnisteet",
|
||||
"search_placeholder": "Hae tunnisteita...",
|
||||
"clear_all": "Tyhjennä kaikki tunnisteet",
|
||||
"no_tags_found": "Tunnisteita ei löytynyt"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Sähköpostitilit",
|
||||
"search_placeholder": "Hae sähköpostitilejä...",
|
||||
"clear_accounts": "Poista valitut tilit",
|
||||
"no_accounts_found": "Tilejä ei löytynyt"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postilaatikot",
|
||||
"search_placeholder": "Hae postilaatikoita",
|
||||
"clear_mailboxes": "Poista valitut kansiot",
|
||||
"select_account_first": "Valitse ensin sähköpostitili",
|
||||
"no_mailbox_found": "Kansiota ei löytynyt"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Yhteystiedot",
|
||||
"label_with_count": "Yhteystiedot ({{count}})",
|
||||
"any": "Kaikki",
|
||||
"search_placeholder": "Hae {{field}}...",
|
||||
"reset_all": "Palauta kaikki yhteystiedot",
|
||||
"no_contact_found": "Yhteystietoja ei löytynyt",
|
||||
"loading": "Ladataan...",
|
||||
"showing_limit": "Näytetään vain ensimmäiset 100 tulosta • Yhteensä {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Lisäasetukset",
|
||||
"title": "Lisäsuodattimet",
|
||||
"reset": "Palauta",
|
||||
"has_attachment": "Sisältää liitteen",
|
||||
"attachment_name_label": "Liitteen nimi",
|
||||
"attachment_name_placeholder": "Esim.: invoice.pdf",
|
||||
"message_size_label": "Viestin koko",
|
||||
"message_id_label": "Alkuperäinen Message-ID",
|
||||
"message_id_description": "Hae sähköpostin Message-ID-otsikon perusteella",
|
||||
"apply": "Käytä suodattimia",
|
||||
"size_presets": {
|
||||
"any": "Mikä tahansa koko",
|
||||
"tiny": "Erittäin pieni (< 15 KB)",
|
||||
"small": "Pieni (< 2 MB)",
|
||||
"medium": "Keskikokoinen (2 – 10 MB)",
|
||||
"large": "Suuri (10 – 20 MB)",
|
||||
"huge": "Erittäin suuri (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Näkymä",
|
||||
"menu_title": "Sarakeasetukset"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Palauta",
|
||||
"tooltip": "Poista kaikki suodattimet"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Hae sähköposteja... (käytä \"lainausmerkkejä\" tarkkaan hakuun)",
|
||||
"button": "Hae",
|
||||
"recent_title": "Viimeisimmät haut",
|
||||
"clear_history": "Tyhjennä historia",
|
||||
"no_history": "Ei hakuhistoriaa",
|
||||
"hint": "Oletushakualue: otsikko, sisältö ja liitteiden nimet"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Déconnexion"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Restaurer les messages",
|
||||
"desc": "Cette action téléchargera les messages sélectionnés depuis Bichon et les restaurera dans leurs boîtes aux lettres correspondantes sur le serveur IMAP.",
|
||||
"confirm": "Restaurer",
|
||||
"restore_to_imap": "Restaurer le courrier",
|
||||
"success": "Messages restaurés",
|
||||
"title": "Restaurer un message",
|
||||
"bulkTitle": "Restaurer plusieurs messages",
|
||||
"bulkWarning": "Remarque : les messages seront restaurés dans leurs dossiers d'origine.",
|
||||
"accountsInvolved": "Comptes concernés",
|
||||
"summary": "Détails de la restauration",
|
||||
"messages": "messages",
|
||||
"desc": "Cette action va re-télécharger les messages sélectionnés depuis Bichon et les restaurer dans leurs boîtes aux lettres correspondantes sur le serveur IMAP.",
|
||||
"confirm": "Exécuter la restauration",
|
||||
"restore_to_imap": "Restaurer le message",
|
||||
"success": "Restauration réussie",
|
||||
"successDesc": "Les messages sélectionnés ont été restaurés avec succès sur le serveur IMAP.",
|
||||
"failed": "Échec de la restauration des messages",
|
||||
"failedTitle": "Échec de la restauration"
|
||||
"failed": "Échec de la restauration",
|
||||
"failedTitle": "Erreur de restauration"
|
||||
},
|
||||
"time": {
|
||||
"label": "Temps",
|
||||
"since": "Depuis",
|
||||
"before": "Avant",
|
||||
"recent_range": "Période récente (depuis...)",
|
||||
"historical": "Période historique (avant...)",
|
||||
"absolute_range": "Plage de dates absolue",
|
||||
"last_day": "Dernier jour",
|
||||
"last_days": "Derniers {{count}} jours",
|
||||
"last_months": "Derniers {{count}} mois",
|
||||
"over_years_ago": "Il y a {{count}} {{unit}}",
|
||||
"year": "an",
|
||||
"years": "ans",
|
||||
"recent_prefix": "Récent :",
|
||||
"days_ago_to_now": "jours jusqu’à aujourd’hui",
|
||||
"apply": "Appliquer",
|
||||
"start_date": "Date de début",
|
||||
"end_date": "Date de fin",
|
||||
"clear_filters": "Effacer les filtres de temps",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Étiquettes",
|
||||
"search_placeholder": "Rechercher des étiquettes...",
|
||||
"clear_all": "Effacer toutes les étiquettes",
|
||||
"no_tags_found": "Aucune étiquette trouvée"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Comptes e-mail",
|
||||
"search_placeholder": "Rechercher des comptes e-mail...",
|
||||
"clear_accounts": "Effacer les comptes sélectionnés",
|
||||
"no_accounts_found": "Aucun compte trouvé"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Boîtes mail",
|
||||
"search_placeholder": "Rechercher des dossiers",
|
||||
"clear_mailboxes": "Effacer les dossiers sélectionnés",
|
||||
"select_account_first": "Veuillez d’abord sélectionner un compte e-mail",
|
||||
"no_mailbox_found": "Aucun dossier trouvé"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacts",
|
||||
"label_with_count": "Contacts ({{count}})",
|
||||
"any": "Tous",
|
||||
"search_placeholder": "Rechercher {{field}}...",
|
||||
"reset_all": "Réinitialiser tous les contacts",
|
||||
"no_contact_found": "Aucun contact trouvé",
|
||||
"loading": "Chargement...",
|
||||
"showing_limit": "Affichage des 100 premiers résultats uniquement • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avancé",
|
||||
"title": "Filtres avancés",
|
||||
"reset": "Réinitialiser",
|
||||
"has_attachment": "Contient une pièce jointe",
|
||||
"attachment_name_label": "Nom de la pièce jointe",
|
||||
"attachment_name_placeholder": "Ex. : invoice.pdf",
|
||||
"message_size_label": "Taille du message",
|
||||
"message_id_label": "Message-ID d’origine",
|
||||
"message_id_description": "Recherche via l’en-tête Message-ID de l’e-mail",
|
||||
"apply": "Appliquer les filtres",
|
||||
"size_presets": {
|
||||
"any": "Toutes tailles",
|
||||
"tiny": "Très petite (< 15 Ko)",
|
||||
"small": "Petite (< 2 Mo)",
|
||||
"medium": "Moyenne (2 – 10 Mo)",
|
||||
"large": "Grande (10 – 20 Mo)",
|
||||
"huge": "Très grande (> 20 Mo)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Affichage",
|
||||
"menu_title": "Paramètres des colonnes"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Réinitialiser",
|
||||
"tooltip": "Effacer tous les filtres"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Rechercher des e-mails... (utilisez les \"guillemets\" pour une correspondance exacte)",
|
||||
"button": "Rechercher",
|
||||
"recent_title": "Recherches récentes",
|
||||
"clear_history": "Effacer l’historique",
|
||||
"no_history": "Aucun historique de recherche",
|
||||
"hint": "Portée par défaut : objet, contenu et noms des pièces jointes"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Disconnetti"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Ripristina messaggi",
|
||||
"desc": "Questa azione caricherà i messaggi selezionati da Bichon e li ripristinerà nelle rispettive caselle di posta sul server IMAP.",
|
||||
"confirm": "Ripristina",
|
||||
"restore_to_imap": "Ripristina posta",
|
||||
"success": "Messaggi ripristinati",
|
||||
"title": "Ripristina singolo messaggio",
|
||||
"bulkTitle": "Ripristino multiplo messaggi",
|
||||
"bulkWarning": "Nota: i messaggi verranno ripristinati nelle loro cartelle originali.",
|
||||
"accountsInvolved": "Account coinvolti",
|
||||
"summary": "Dettagli ripristino",
|
||||
"messages": "messaggi",
|
||||
"desc": "Questa azione caricherà nuovamente i messaggi selezionati da Bichon e li ripristinerà nelle rispettive caselle di posta sul server IMAP.",
|
||||
"confirm": "Esegui ripristino",
|
||||
"restore_to_imap": "Ripristina messaggio",
|
||||
"success": "Ripristino completato",
|
||||
"successDesc": "I messaggi selezionati sono stati ripristinati con successo sul server IMAP.",
|
||||
"failed": "Impossibile ripristinare i messaggi",
|
||||
"failedTitle": "Ripristino fallito"
|
||||
"failed": "Ripristino fallito",
|
||||
"failedTitle": "Errore di ripristino"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"since": "Da",
|
||||
"before": "Prima di",
|
||||
"recent_range": "Intervallo recente (da...)",
|
||||
"historical": "Intervallo storico (prima di...)",
|
||||
"absolute_range": "Intervallo di date assoluto",
|
||||
"last_day": "Ultimo 1 giorno",
|
||||
"last_days": "Ultimi {{count}} giorni",
|
||||
"last_months": "Ultimi {{count}} mesi",
|
||||
"over_years_ago": "{{count}} {{unit}} fa",
|
||||
"year": "anno",
|
||||
"years": "anni",
|
||||
"recent_prefix": "Recenti:",
|
||||
"days_ago_to_now": "giorni fa fino ad oggi",
|
||||
"apply": "Applica",
|
||||
"start_date": "Data di inizio",
|
||||
"end_date": "Data di fine",
|
||||
"clear_filters": "Cancella filtri temporali",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tag",
|
||||
"search_placeholder": "Cerca tag...",
|
||||
"clear_all": "Cancella tutti i tag",
|
||||
"no_tags_found": "Nessun tag trovato"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Account email",
|
||||
"search_placeholder": "Cerca account email...",
|
||||
"clear_accounts": "Cancella account selezionati",
|
||||
"no_accounts_found": "Nessun account trovato"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Caselle di posta",
|
||||
"search_placeholder": "Cerca cartelle",
|
||||
"clear_mailboxes": "Cancella cartelle selezionate",
|
||||
"select_account_first": "Seleziona prima un account email",
|
||||
"no_mailbox_found": "Nessuna cartella trovata"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contatti",
|
||||
"label_with_count": "Contatti ({{count}})",
|
||||
"any": "Qualsiasi",
|
||||
"search_placeholder": "Cerca {{field}}...",
|
||||
"reset_all": "Reimposta tutti i contatti",
|
||||
"no_contact_found": "Nessun contatto trovato",
|
||||
"loading": "Caricamento...",
|
||||
"showing_limit": "Mostrati solo i primi 100 risultati • Totale {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avanzato",
|
||||
"title": "Filtri avanzati",
|
||||
"reset": "Reimposta",
|
||||
"has_attachment": "Con allegato",
|
||||
"attachment_name_label": "Nome allegato",
|
||||
"attachment_name_placeholder": "Es.: invoice.pdf",
|
||||
"message_size_label": "Dimensione email",
|
||||
"message_id_label": "Message-ID originale",
|
||||
"message_id_description": "Ricerca tramite Message-ID nell’intestazione email",
|
||||
"apply": "Applica filtri",
|
||||
"size_presets": {
|
||||
"any": "Qualsiasi dimensione",
|
||||
"tiny": "Molto piccola (< 15 KB)",
|
||||
"small": "Piccola (< 2 MB)",
|
||||
"medium": "Media (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Molto grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
"menu_title": "Impostazioni colonne"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reimposta",
|
||||
"tooltip": "Cancella tutti i filtri"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Cerca email... (usa le \"virgolette\" per corrispondenza esatta)",
|
||||
"button": "Cerca",
|
||||
"recent_title": "Ricerche recenti",
|
||||
"clear_history": "Cancella cronologia",
|
||||
"no_history": "Nessuna cronologia di ricerca",
|
||||
"hint": "Ambito predefinito: oggetto, contenuto e nomi degli allegati"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "サインアウト"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "メッセージを復元",
|
||||
"desc": "この操作により、選択したメッセージをBichonから再アップロードし、IMAPサーバー上の対応するメールボックスに復元します。",
|
||||
"title": "単一メールの復元",
|
||||
"bulkTitle": "複数メールの一括復元",
|
||||
"bulkWarning": "注意:メールは元の保存先フォルダーに復元されます。",
|
||||
"accountsInvolved": "対象アカウント",
|
||||
"summary": "復元の詳細",
|
||||
"messages": "通のメール",
|
||||
"desc": "この操作により、選択されたメールがBichonシステムから再アップロードされ、IMAPサーバー上の対応するメールボックスに復元されます。",
|
||||
"confirm": "復元を実行",
|
||||
"restore_to_imap": "メールを復元",
|
||||
"success": "メッセージを復元しました",
|
||||
"successDesc": "選択したメッセージがIMAPサーバーに正常に復元されました。",
|
||||
"failed": "メッセージの復元に失敗しました",
|
||||
"failedTitle": "復元失敗"
|
||||
"success": "復元成功",
|
||||
"successDesc": "選択されたメールはIMAPサーバーに正常に復元されました。",
|
||||
"failed": "復元失敗",
|
||||
"failedTitle": "復元に失敗しました"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"since": "以降",
|
||||
"before": "以前",
|
||||
"recent_range": "最近の範囲(以降)",
|
||||
"historical": "過去の範囲(以前)",
|
||||
"absolute_range": "絶対日付範囲",
|
||||
"last_day": "過去 1 日",
|
||||
"last_days": "過去 {{count}} 日",
|
||||
"last_months": "過去 {{count}} か月",
|
||||
"over_years_ago": "{{count}}{{unit}}前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "日前〜現在",
|
||||
"apply": "適用",
|
||||
"start_date": "開始日",
|
||||
"end_date": "終了日",
|
||||
"clear_filters": "時間フィルターをクリア",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "タグ",
|
||||
"search_placeholder": "タグを検索...",
|
||||
"clear_all": "すべてのタグをクリア",
|
||||
"no_tags_found": "タグが見つかりません"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "メールアカウント",
|
||||
"search_placeholder": "アカウントを検索...",
|
||||
"clear_accounts": "選択したアカウントをクリア",
|
||||
"no_accounts_found": "アカウントが見つかりません"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "メールボックス",
|
||||
"search_placeholder": "メールボックスを検索",
|
||||
"clear_mailboxes": "選択したフォルダをクリア",
|
||||
"select_account_first": "先にアカウントを選択してください",
|
||||
"no_mailbox_found": "フォルダが見つかりません"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "連絡先",
|
||||
"label_with_count": "連絡先 ({{count}})",
|
||||
"any": "指定なし",
|
||||
"search_placeholder": "{{field}} を検索...",
|
||||
"reset_all": "連絡先をリセット",
|
||||
"no_contact_found": "連絡先が見つかりません",
|
||||
"loading": "読み込み中...",
|
||||
"showing_limit": "最初の100件のみ表示 • 合計 {{total}} 件"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "詳細",
|
||||
"title": "詳細フィルター",
|
||||
"reset": "リセット",
|
||||
"has_attachment": "添付ファイルあり",
|
||||
"attachment_name_label": "添付ファイル名",
|
||||
"attachment_name_placeholder": "例: invoice.pdf",
|
||||
"message_size_label": "メールサイズ",
|
||||
"message_id_label": "Message-ID",
|
||||
"message_id_description": "メールヘッダーの Message-ID で検索",
|
||||
"apply": "適用",
|
||||
"size_presets": {
|
||||
"any": "指定なし",
|
||||
"tiny": "極小 (< 15 KB)",
|
||||
"small": "小 (< 2 MB)",
|
||||
"medium": "中 (2 – 10 MB)",
|
||||
"large": "大 (10 – 20 MB)",
|
||||
"huge": "特大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "表示",
|
||||
"menu_title": "列の設定"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "リセット",
|
||||
"tooltip": "すべての条件をクリア"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "メールを検索…(\"引用符\"で完全一致)",
|
||||
"button": "検索",
|
||||
"recent_title": "最近の検索",
|
||||
"clear_history": "履歴をクリア",
|
||||
"no_history": "履歴なし",
|
||||
"hint": "検索対象:件名・本文・添付ファイル名"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "로그아웃"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "메시지 복원",
|
||||
"desc": "이 작업은 선택한 메시지를 Bichon에서 다시 업로드하여 IMAP 서버의 해당 사서함으로 복원합니다.",
|
||||
"title": "개별 메시지 복원",
|
||||
"bulkTitle": "여러 메시지 일괄 복원",
|
||||
"bulkWarning": "참고: 메시지는 원래 저장된 폴더로 복원됩니다.",
|
||||
"accountsInvolved": "관련 계정",
|
||||
"summary": "복원 세부 정보",
|
||||
"messages": "개의 메시지",
|
||||
"desc": "이 작업은 선택한 메시지를 Bichon 시스템에서 다시 업로드하여 IMAP 서버의 해당 사서함으로 복원합니다.",
|
||||
"confirm": "복원 실행",
|
||||
"restore_to_imap": "메일 복원",
|
||||
"success": "메시지 복원 완료",
|
||||
"restore_to_imap": "메시지 복원",
|
||||
"success": "복원 성공",
|
||||
"successDesc": "선택한 메시지가 IMAP 서버로 성공적으로 복원되었습니다.",
|
||||
"failed": "메시지 복원 실패",
|
||||
"failed": "복원 실패",
|
||||
"failedTitle": "복원 실패"
|
||||
},
|
||||
"time": {
|
||||
"label": "시간",
|
||||
"since": "이후",
|
||||
"before": "이전",
|
||||
"recent_range": "최근 범위 (이후...)",
|
||||
"historical": "과거 범위 (이전...)",
|
||||
"absolute_range": "절대 날짜 범위",
|
||||
"last_day": "최근 1일",
|
||||
"last_days": "최근 {{count}}일",
|
||||
"last_months": "최근 {{count}}개월",
|
||||
"over_years_ago": "{{count}}{{unit}} 전",
|
||||
"year": "년",
|
||||
"years": "년",
|
||||
"recent_prefix": "최근:",
|
||||
"days_ago_to_now": "며칠 전부터 현재까지",
|
||||
"apply": "적용",
|
||||
"start_date": "시작 날짜",
|
||||
"end_date": "종료 날짜",
|
||||
"clear_filters": "시간 필터 초기화",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "태그",
|
||||
"search_placeholder": "태그 검색...",
|
||||
"clear_all": "모든 태그 지우기",
|
||||
"no_tags_found": "태그를 찾을 수 없습니다"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "메일 계정",
|
||||
"search_placeholder": "메일 계정 검색...",
|
||||
"clear_accounts": "선택한 계정 지우기",
|
||||
"no_accounts_found": "계정을 찾을 수 없습니다"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "메일함",
|
||||
"search_placeholder": "메일함 검색",
|
||||
"clear_mailboxes": "선택한 폴더 지우기",
|
||||
"select_account_first": "먼저 메일 계정을 선택하세요",
|
||||
"no_mailbox_found": "폴더를 찾을 수 없습니다"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "연락처",
|
||||
"label_with_count": "연락처 ({{count}})",
|
||||
"any": "제한 없음",
|
||||
"search_placeholder": "{{field}} 검색...",
|
||||
"reset_all": "모든 연락처 초기화",
|
||||
"no_contact_found": "연락처를 찾을 수 없습니다",
|
||||
"loading": "불러오는 중...",
|
||||
"showing_limit": "상위 100개 결과만 표시 • 전체 {{total}}개"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "고급",
|
||||
"title": "고급 필터",
|
||||
"reset": "초기화",
|
||||
"has_attachment": "첨부파일 포함",
|
||||
"attachment_name_label": "첨부파일 이름",
|
||||
"attachment_name_placeholder": "예: invoice.pdf",
|
||||
"message_size_label": "메일 크기",
|
||||
"message_id_label": "원본 Message-ID",
|
||||
"message_id_description": "메일 헤더의 Message-ID로 검색",
|
||||
"apply": "필터 적용",
|
||||
"size_presets": {
|
||||
"any": "크기 제한 없음",
|
||||
"tiny": "매우 작음 (< 15 KB)",
|
||||
"small": "작음 (< 2 MB)",
|
||||
"medium": "보통 (2 – 10 MB)",
|
||||
"large": "큼 (10 – 20 MB)",
|
||||
"huge": "매우 큼 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "보기",
|
||||
"menu_title": "표시 열 설정"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "초기화",
|
||||
"tooltip": "모든 필터 조건 지우기"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "메일 검색... (\"따옴표\"로 정확히 일치)",
|
||||
"button": "검색",
|
||||
"recent_title": "최근 검색",
|
||||
"clear_history": "기록 지우기",
|
||||
"no_history": "검색 기록 없음",
|
||||
"hint": "기본 검색 범위: 제목, 본문 및 첨부파일 이름"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Uitloggen"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Berichten herstellen",
|
||||
"desc": "Deze actie uploadt de geselecteerde berichten van Bichon en herstelt ze in de bijbehorende mailboxen op de IMAP-server.",
|
||||
"confirm": "Herstellen",
|
||||
"restore_to_imap": "E-mail herstellen",
|
||||
"success": "Berichten hersteld",
|
||||
"title": "Enkel bericht herstellen",
|
||||
"bulkTitle": "Meerdere berichten herstellen",
|
||||
"bulkWarning": "Let op: berichten worden hersteld in hun oorspronkelijke mappen.",
|
||||
"accountsInvolved": "Betrokken accounts",
|
||||
"summary": "Hersteldetails",
|
||||
"messages": "berichten",
|
||||
"desc": "Deze actie zal de geselecteerde berichten opnieuw uploaden vanuit Bichon en ze herstellen in de bijbehorende mailboxen op de IMAP-server.",
|
||||
"confirm": "Herstel uitvoeren",
|
||||
"restore_to_imap": "Bericht herstellen",
|
||||
"success": "Herstel geslaagd",
|
||||
"successDesc": "De geselecteerde berichten zijn succesvol hersteld op de IMAP-server.",
|
||||
"failed": "Herstellen van berichten mislukt",
|
||||
"failedTitle": "Herstel mislukt"
|
||||
"failed": "Herstel mislukt",
|
||||
"failedTitle": "Herstelfout"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tijd",
|
||||
"since": "Sinds",
|
||||
"before": "Voor",
|
||||
"recent_range": "Recente periode (sinds...)",
|
||||
"historical": "Historische periode (voor...)",
|
||||
"absolute_range": "Absolute datumbereik",
|
||||
"last_day": "Laatste 1 dag",
|
||||
"last_days": "Laatste {{count}} dagen",
|
||||
"last_months": "Laatste {{count}} maanden",
|
||||
"over_years_ago": "{{count}} {{unit}} geleden",
|
||||
"year": "jaar",
|
||||
"years": "jaar",
|
||||
"recent_prefix": "Recent:",
|
||||
"days_ago_to_now": "dagen geleden tot nu",
|
||||
"apply": "Toepassen",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Einddatum",
|
||||
"clear_filters": "Tijdfilters wissen",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tags",
|
||||
"search_placeholder": "Tags zoeken...",
|
||||
"clear_all": "Alle tags wissen",
|
||||
"no_tags_found": "Geen tags gevonden"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-mailaccounts",
|
||||
"search_placeholder": "E-mailaccounts zoeken...",
|
||||
"clear_accounts": "Geselecteerde accounts wissen",
|
||||
"no_accounts_found": "Geen accounts gevonden"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Mailboxen",
|
||||
"search_placeholder": "Mailboxen zoeken",
|
||||
"clear_mailboxes": "Geselecteerde mappen wissen",
|
||||
"select_account_first": "Selecteer eerst een e-mailaccount",
|
||||
"no_mailbox_found": "Geen mailbox gevonden"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contacten",
|
||||
"label_with_count": "Contacten ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "{{field}} zoeken...",
|
||||
"reset_all": "Alle contacten resetten",
|
||||
"no_contact_found": "Geen contacten gevonden",
|
||||
"loading": "Bezig met laden...",
|
||||
"showing_limit": "Alleen de eerste 100 resultaten • Totaal {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Geavanceerd",
|
||||
"title": "Geavanceerde filters",
|
||||
"reset": "Resetten",
|
||||
"has_attachment": "Bevat bijlage",
|
||||
"attachment_name_label": "Bijlagenaam",
|
||||
"attachment_name_placeholder": "Bijv.: invoice.pdf",
|
||||
"message_size_label": "Berichtgrootte",
|
||||
"message_id_label": "Originele Message-ID",
|
||||
"message_id_description": "Zoeken via de Message-ID in de e-mailheader",
|
||||
"apply": "Filters toepassen",
|
||||
"size_presets": {
|
||||
"any": "Elke grootte",
|
||||
"tiny": "Zeer klein (< 15 KB)",
|
||||
"small": "Klein (< 2 MB)",
|
||||
"medium": "Gemiddeld (2 – 10 MB)",
|
||||
"large": "Groot (10 – 20 MB)",
|
||||
"huge": "Zeer groot (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Weergave",
|
||||
"menu_title": "Kolominstellingen"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Resetten",
|
||||
"tooltip": "Alle filters wissen"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "E-mails zoeken... (gebruik \"aanhalingstekens\" voor exacte overeenkomst)",
|
||||
"button": "Zoeken",
|
||||
"recent_title": "Recente zoekopdrachten",
|
||||
"clear_history": "Geschiedenis wissen",
|
||||
"no_history": "Geen zoekgeschiedenis",
|
||||
"hint": "Standaard zoekbereik: onderwerp, inhoud en bijlagenamen"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Logg ut"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Gjenopprett meldinger",
|
||||
"desc": "Denne handlingen vil laste opp de valgte meldingene fra Bichon og gjenopprette dem til deres tilsvarende postbokser på IMAP-serveren.",
|
||||
"confirm": "Gjenopprett",
|
||||
"restore_to_imap": "Gjenopprett e-post",
|
||||
"success": "Meldinger gjenopprettet",
|
||||
"successDesc": "De valgte meldingene har blitt gjenopprettet til IMAP-serveren.",
|
||||
"failed": "Kunne ikke gjenopprette meldinger",
|
||||
"failedTitle": "Gjenoppretting mislyktes"
|
||||
"title": "Gjenopprett enkeltmelding",
|
||||
"bulkTitle": "Gjenopprett flere meldinger",
|
||||
"bulkWarning": "Merk: Meldinger vil bli gjenopprettet til sine opprinnelige mapper.",
|
||||
"accountsInvolved": "Involverte kontoer",
|
||||
"summary": "Gjenopprettingsdetaljer",
|
||||
"messages": "meldinger",
|
||||
"desc": "Denne handlingen vil laste opp valgte meldinger fra Bichon på nytt og gjenopprette dem til de tilsvarende postkassene på IMAP-serveren.",
|
||||
"confirm": "Utfør gjenoppretting",
|
||||
"restore_to_imap": "Gjenopprett melding",
|
||||
"success": "Gjenoppretting vellykket",
|
||||
"successDesc": "De valgte meldingene er gjenopprettet til IMAP-serveren.",
|
||||
"failed": "Gjenoppretting mislykkedes",
|
||||
"failedTitle": "Feil ved gjenoppretting"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Siden",
|
||||
"before": "Før",
|
||||
"recent_range": "Nylig periode (siden...)",
|
||||
"historical": "Historisk periode (før...)",
|
||||
"absolute_range": "Absolutt datointervall",
|
||||
"last_day": "Siste 1 dag",
|
||||
"last_days": "Siste {{count}} dager",
|
||||
"last_months": "Siste {{count}} måneder",
|
||||
"over_years_ago": "For {{count}} {{unit}} siden",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Nylig:",
|
||||
"days_ago_to_now": "dager siden til nå",
|
||||
"apply": "Bruk",
|
||||
"start_date": "Startdato",
|
||||
"end_date": "Sluttdato",
|
||||
"clear_filters": "Fjern tidsfiltre",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiketter",
|
||||
"search_placeholder": "Søk etter etiketter...",
|
||||
"clear_all": "Fjern alle etiketter",
|
||||
"no_tags_found": "Ingen etiketter funnet"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-postkontoer",
|
||||
"search_placeholder": "Søk etter e-postkontoer...",
|
||||
"clear_accounts": "Fjern valgte kontoer",
|
||||
"no_accounts_found": "Ingen kontoer funnet"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Postbokser",
|
||||
"search_placeholder": "Søk i postbokser",
|
||||
"clear_mailboxes": "Fjern valgte mapper",
|
||||
"select_account_first": "Velg først en e-postkonto",
|
||||
"no_mailbox_found": "Ingen mappe funnet"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søk i {{field}}...",
|
||||
"reset_all": "Tilbakestill alle kontakter",
|
||||
"no_contact_found": "Ingen kontakter funnet",
|
||||
"loading": "Laster...",
|
||||
"showing_limit": "Viser kun de første 100 resultatene • Totalt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avansert",
|
||||
"title": "Avanserte filtre",
|
||||
"reset": "Tilbakestill",
|
||||
"has_attachment": "Har vedlegg",
|
||||
"attachment_name_label": "Vedleggsnavn",
|
||||
"attachment_name_placeholder": "F.eks.: invoice.pdf",
|
||||
"message_size_label": "Meldingsstørrelse",
|
||||
"message_id_label": "Opprinnelig Message-ID",
|
||||
"message_id_description": "Søk via Message-ID i e-postheaderen",
|
||||
"apply": "Bruk filtre",
|
||||
"size_presets": {
|
||||
"any": "Alle størrelser",
|
||||
"tiny": "Svært liten (< 15 KB)",
|
||||
"small": "Liten (< 2 MB)",
|
||||
"medium": "Middels (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Svært stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
"menu_title": "Kolonneinnstillinger"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Tilbakestill",
|
||||
"tooltip": "Fjern alle filtre"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Søk i e-poster... (bruk \"anførselstegn\" for eksakt treff)",
|
||||
"button": "Søk",
|
||||
"recent_title": "Nylige søk",
|
||||
"clear_history": "Tøm historikk",
|
||||
"no_history": "Ingen søkehistorikk",
|
||||
"hint": "Standard søkeområde: emne, innhold og vedleggsnavn"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Wyloguj się"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Przywróć wiadomości",
|
||||
"desc": "Ta operacja prześle wybrane wiadomości z Bichon i przywróci je do odpowiednich skrzynek pocztowych na serwerze IMAP.",
|
||||
"confirm": "Przywróć",
|
||||
"restore_to_imap": "Przywróć pocztę",
|
||||
"success": "Wiadomości przywrócone",
|
||||
"title": "Przywróć pojedynczą wiadomość",
|
||||
"bulkTitle": "Masowe przywracanie wiadomości",
|
||||
"bulkWarning": "Uwaga: Wiadomości zostaną przywrócone do ich oryginalnych folderów.",
|
||||
"accountsInvolved": "Zaangażowane konta",
|
||||
"summary": "Szczegóły przywracania",
|
||||
"messages": "wiadomości",
|
||||
"desc": "Ta operacja spowoduje ponowne przesłanie wybranych wiadomości z systemu Bichon i przywrócenie ich do odpowiednich skrzynek pocztowych na serwerze IMAP.",
|
||||
"confirm": "Wykonaj przywracanie",
|
||||
"restore_to_imap": "Przywróć wiadomość",
|
||||
"success": "Przywracanie zakończone sukcesem",
|
||||
"successDesc": "Wybrane wiadomości zostały pomyślnie przywrócone na serwer IMAP.",
|
||||
"failed": "Nie udało się przywrócić wiadomości",
|
||||
"failedTitle": "Przywracanie nie powiodło się"
|
||||
"failed": "Przywracanie nie powiodło się",
|
||||
"failedTitle": "Błąd przywracania"
|
||||
},
|
||||
"time": {
|
||||
"label": "Czas",
|
||||
"since": "Od",
|
||||
"before": "Przed",
|
||||
"recent_range": "Ostatni zakres (od...)",
|
||||
"historical": "Zakres historyczny (przed...)",
|
||||
"absolute_range": "Bezwzględny zakres dat",
|
||||
"last_day": "Ostatni 1 dzień",
|
||||
"last_days": "Ostatnie {{count}} dni",
|
||||
"last_months": "Ostatnie {{count}} miesiące",
|
||||
"over_years_ago": "{{count}} {{unit}} temu",
|
||||
"year": "rok",
|
||||
"years": "lata",
|
||||
"recent_prefix": "Ostatnio:",
|
||||
"days_ago_to_now": "dni temu do teraz",
|
||||
"apply": "Zastosuj",
|
||||
"start_date": "Data rozpoczęcia",
|
||||
"end_date": "Data zakończenia",
|
||||
"clear_filters": "Wyczyść filtry czasu",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Tagi",
|
||||
"search_placeholder": "Szukaj tagów...",
|
||||
"clear_all": "Wyczyść wszystkie tagi",
|
||||
"no_tags_found": "Nie znaleziono tagów"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Konta e-mail",
|
||||
"search_placeholder": "Szukaj kont e-mail...",
|
||||
"clear_accounts": "Wyczyść wybrane konta",
|
||||
"no_accounts_found": "Nie znaleziono kont"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Skrzynki pocztowe",
|
||||
"search_placeholder": "Szukaj skrzynek",
|
||||
"clear_mailboxes": "Wyczyść wybrane foldery",
|
||||
"select_account_first": "Najpierw wybierz konto e-mail",
|
||||
"no_mailbox_found": "Nie znaleziono folderu"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakty",
|
||||
"label_with_count": "Kontakty ({{count}})",
|
||||
"any": "Dowolne",
|
||||
"search_placeholder": "Szukaj {{field}}...",
|
||||
"reset_all": "Zresetuj wszystkie kontakty",
|
||||
"no_contact_found": "Nie znaleziono kontaktów",
|
||||
"loading": "Ładowanie...",
|
||||
"showing_limit": "Wyświetlane tylko pierwsze 100 wyników • Łącznie {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Zaawansowane",
|
||||
"title": "Filtry zaawansowane",
|
||||
"reset": "Resetuj",
|
||||
"has_attachment": "Zawiera załącznik",
|
||||
"attachment_name_label": "Nazwa załącznika",
|
||||
"attachment_name_placeholder": "Np.: invoice.pdf",
|
||||
"message_size_label": "Rozmiar wiadomości",
|
||||
"message_id_label": "Oryginalny Message-ID",
|
||||
"message_id_description": "Wyszukiwanie według nagłówka Message-ID",
|
||||
"apply": "Zastosuj filtry",
|
||||
"size_presets": {
|
||||
"any": "Dowolny rozmiar",
|
||||
"tiny": "Bardzo mały (< 15 KB)",
|
||||
"small": "Mały (< 2 MB)",
|
||||
"medium": "Średni (2 – 10 MB)",
|
||||
"large": "Duży (10 – 20 MB)",
|
||||
"huge": "Bardzo duży (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Widok",
|
||||
"menu_title": "Ustawienia kolumn"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Reset",
|
||||
"tooltip": "Wyczyść wszystkie filtry"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Szukaj e-maili... (użyj \"cudzysłowów\" dla dokładnego dopasowania)",
|
||||
"button": "Szukaj",
|
||||
"recent_title": "Ostatnie wyszukiwania",
|
||||
"clear_history": "Wyczyść historię",
|
||||
"no_history": "Brak historii wyszukiwania",
|
||||
"hint": "Domyślny zakres wyszukiwania: temat, treść i nazwy załączników"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Sair"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Restaurar mensagens",
|
||||
"desc": "Esta ação irá carregar as mensagens selecionadas do Bichon e restaurá-las nas respetivas caixas de correio no servidor IMAP.",
|
||||
"confirm": "Restaurar",
|
||||
"restore_to_imap": "Restaurar e-mail",
|
||||
"success": "Mensagens restauradas",
|
||||
"successDesc": "As mensagens selecionadas foram restauradas com sucesso para o servidor IMAP.",
|
||||
"failed": "Falha ao restaurar mensagens",
|
||||
"title": "Restaurar mensagem individual",
|
||||
"bulkTitle": "Restaurar múltiplas mensagens",
|
||||
"bulkWarning": "Nota: As mensagens serão restauradas nas suas pastas originais.",
|
||||
"accountsInvolved": "Contas envolvidas",
|
||||
"summary": "Detalhes da restauração",
|
||||
"messages": "mensagens",
|
||||
"desc": "Esta ação irá recarregar as mensagens selecionadas do Bichon e restaurá-las nas pastas correspondentes no servidor IMAP.",
|
||||
"confirm": "Executar restauração",
|
||||
"restore_to_imap": "Restaurar mensagem",
|
||||
"success": "Restauração bem-sucedida",
|
||||
"successDesc": "As mensagens selecionadas foram restauradas com sucesso no servidor IMAP.",
|
||||
"failed": "Falha ao restaurar",
|
||||
"failedTitle": "Falha na restauração"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"since": "Desde",
|
||||
"before": "Antes de",
|
||||
"recent_range": "Intervalo recente (desde...)",
|
||||
"historical": "Intervalo histórico (antes de...)",
|
||||
"absolute_range": "Intervalo de datas absoluto",
|
||||
"last_day": "Último 1 dia",
|
||||
"last_days": "Últimos {{count}} dias",
|
||||
"last_months": "Últimos {{count}} meses",
|
||||
"over_years_ago": "Há {{count}} {{unit}}",
|
||||
"year": "ano",
|
||||
"years": "anos",
|
||||
"recent_prefix": "Recente:",
|
||||
"days_ago_to_now": "dias atrás até agora",
|
||||
"apply": "Aplicar",
|
||||
"start_date": "Data de início",
|
||||
"end_date": "Data de fim",
|
||||
"clear_filters": "Limpar filtros de tempo",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Etiquetas",
|
||||
"search_placeholder": "Pesquisar etiquetas...",
|
||||
"clear_all": "Limpar todas as etiquetas",
|
||||
"no_tags_found": "Nenhuma etiqueta encontrada"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Contas de e-mail",
|
||||
"search_placeholder": "Pesquisar contas de e-mail...",
|
||||
"clear_accounts": "Limpar contas selecionadas",
|
||||
"no_accounts_found": "Nenhuma conta encontrada"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Caixas de correio",
|
||||
"search_placeholder": "Pesquisar pastas",
|
||||
"clear_mailboxes": "Limpar pastas selecionadas",
|
||||
"select_account_first": "Selecione primeiro uma conta de e-mail",
|
||||
"no_mailbox_found": "Nenhuma pasta encontrada"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Contatos",
|
||||
"label_with_count": "Contatos ({{count}})",
|
||||
"any": "Qualquer",
|
||||
"search_placeholder": "Pesquisar {{field}}...",
|
||||
"reset_all": "Redefinir todos os contatos",
|
||||
"no_contact_found": "Nenhum contato encontrado",
|
||||
"loading": "Carregando...",
|
||||
"showing_limit": "Mostrando apenas os primeiros 100 resultados • Total {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avançado",
|
||||
"title": "Filtros avançados",
|
||||
"reset": "Redefinir",
|
||||
"has_attachment": "Contém anexo",
|
||||
"attachment_name_label": "Nome do anexo",
|
||||
"attachment_name_placeholder": "Ex.: invoice.pdf",
|
||||
"message_size_label": "Tamanho da mensagem",
|
||||
"message_id_label": "Message-ID original",
|
||||
"message_id_description": "Pesquisar pelo Message-ID no cabeçalho do e-mail",
|
||||
"apply": "Aplicar filtros",
|
||||
"size_presets": {
|
||||
"any": "Qualquer tamanho",
|
||||
"tiny": "Muito pequeno (< 15 KB)",
|
||||
"small": "Pequeno (< 2 MB)",
|
||||
"medium": "Médio (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muito grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visualização",
|
||||
"menu_title": "Configurações de colunas"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Redefinir",
|
||||
"tooltip": "Limpar todos os filtros"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Pesquisar e-mails... (use \"aspas\" para correspondência exata)",
|
||||
"button": "Pesquisar",
|
||||
"recent_title": "Pesquisas recentes",
|
||||
"clear_history": "Limpar histórico",
|
||||
"no_history": "Nenhum histórico de pesquisa",
|
||||
"hint": "Escopo padrão: assunto, conteúdo e nomes dos anexos"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Выйти"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Восстановить сообщения",
|
||||
"desc": "Это действие загрузит выбранные сообщения из Bichon и восстановит их в соответствующих почтовых ящиках на IMAP-сервере.",
|
||||
"confirm": "Восстановить",
|
||||
"restore_to_imap": "Восстановить почту",
|
||||
"success": "Сообщения восстановлены",
|
||||
"successDesc": "Выбранные сообщения были успешно восстановлены на IMAP-сервере.",
|
||||
"failed": "Не удалось восстановить сообщения",
|
||||
"failedTitle": "Ошибка восстановления"
|
||||
"title": "Восстановить одно сообщение",
|
||||
"bulkTitle": "Массовое восстановление сообщений",
|
||||
"bulkWarning": "Примечание: сообщения будут восстановлены в исходные папки.",
|
||||
"accountsInvolved": "Задействованные аккаунты",
|
||||
"summary": "Детали восстановления",
|
||||
"messages": "сообщений",
|
||||
"desc": "Это действие повторно загрузит выбранные сообщения из Bichon и восстановит их в соответствующих почтовых ящиках на сервере IMAP.",
|
||||
"confirm": "Выполнить восстановление",
|
||||
"restore_to_imap": "Восстановить сообщение",
|
||||
"success": "Восстановление успешно",
|
||||
"successDesc": "Выбранные сообщения были успешно восстановлены на сервере IMAP.",
|
||||
"failed": "Ошибка восстановления",
|
||||
"failedTitle": "Восстановление не удалось"
|
||||
},
|
||||
"time": {
|
||||
"label": "Время",
|
||||
"since": "С",
|
||||
"before": "До",
|
||||
"recent_range": "Недавний диапазон (с...)",
|
||||
"historical": "Исторический диапазон (до...)",
|
||||
"absolute_range": "Абсолютный диапазон дат",
|
||||
"last_day": "Последний 1 день",
|
||||
"last_days": "Последние {{count}} дней",
|
||||
"last_months": "Последние {{count}} месяцев",
|
||||
"over_years_ago": "{{count}} {{unit}} назад",
|
||||
"year": "год",
|
||||
"years": "лет",
|
||||
"recent_prefix": "Недавно:",
|
||||
"days_ago_to_now": "дней назад — по настоящее время",
|
||||
"apply": "Применить",
|
||||
"start_date": "Дата начала",
|
||||
"end_date": "Дата окончания",
|
||||
"clear_filters": "Очистить фильтр времени",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Теги",
|
||||
"search_placeholder": "Поиск тегов...",
|
||||
"clear_all": "Очистить все теги",
|
||||
"no_tags_found": "Теги не найдены"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "Почтовые аккаунты",
|
||||
"search_placeholder": "Поиск почтовых аккаунтов...",
|
||||
"clear_accounts": "Очистить выбранные аккаунты",
|
||||
"no_accounts_found": "Аккаунты не найдены"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "Почтовые папки",
|
||||
"search_placeholder": "Поиск папок",
|
||||
"clear_mailboxes": "Очистить выбранные папки",
|
||||
"select_account_first": "Сначала выберите почтовый аккаунт",
|
||||
"no_mailbox_found": "Папки не найдены"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Контакты",
|
||||
"label_with_count": "Контакты ({{count}})",
|
||||
"any": "Любые",
|
||||
"search_placeholder": "Поиск по {{field}}...",
|
||||
"reset_all": "Сбросить все контакты",
|
||||
"no_contact_found": "Контакты не найдены",
|
||||
"loading": "Загрузка...",
|
||||
"showing_limit": "Показаны первые 100 результатов • Всего {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Дополнительно",
|
||||
"title": "Расширенные фильтры",
|
||||
"reset": "Сброс",
|
||||
"has_attachment": "Есть вложение",
|
||||
"attachment_name_label": "Имя вложения",
|
||||
"attachment_name_placeholder": "Например: invoice.pdf",
|
||||
"message_size_label": "Размер письма",
|
||||
"message_id_label": "Исходный Message-ID",
|
||||
"message_id_description": "Поиск по Message-ID из заголовков письма",
|
||||
"apply": "Применить фильтры",
|
||||
"size_presets": {
|
||||
"any": "Любой размер",
|
||||
"tiny": "Очень маленький (< 15 КБ)",
|
||||
"small": "Маленький (< 2 МБ)",
|
||||
"medium": "Средний (2–10 МБ)",
|
||||
"large": "Большой (10–20 МБ)",
|
||||
"huge": "Очень большой (> 20 МБ)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Вид",
|
||||
"menu_title": "Настройки отображаемых столбцов"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Сброс",
|
||||
"tooltip": "Очистить все фильтры"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Поиск писем... (используйте \"кавычки\" для точного совпадения)",
|
||||
"button": "Поиск",
|
||||
"recent_title": "Недавние поиски",
|
||||
"clear_history": "Очистить историю",
|
||||
"no_history": "История поиска пуста",
|
||||
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "Logga ut"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "Återställ meddelanden",
|
||||
"desc": "Denna åtgärd kommer att ladda upp de valda meddelandena från Bichon och återställa dem till deras motsvarande brevlådor på IMAP-servern.",
|
||||
"confirm": "Återställ",
|
||||
"restore_to_imap": "Återställ e-post",
|
||||
"success": "Meddelanden återställda",
|
||||
"title": "Återställ enstaka meddelande",
|
||||
"bulkTitle": "Återställ flera meddelanden",
|
||||
"bulkWarning": "Obs: Meddelanden kommer att återställas till sina ursprungliga mappar.",
|
||||
"accountsInvolved": "Inblandade konton",
|
||||
"summary": "Återställningsdetaljer",
|
||||
"messages": "meddelanden",
|
||||
"desc": "Denna åtgärd kommer att ladda upp de valda meddelandena igen från Bichon och återställa dem till deras motsvarande inkorgar på IMAP-servern.",
|
||||
"confirm": "Utför återställning",
|
||||
"restore_to_imap": "Återställ meddelande",
|
||||
"success": "Återställning lyckades",
|
||||
"successDesc": "De valda meddelandena har återställts till IMAP-servern.",
|
||||
"failed": "Misslyckades med att återställa meddelanden",
|
||||
"failedTitle": "Återställning misslyckades"
|
||||
"failed": "Återställning misslyckades",
|
||||
"failedTitle": "Fel vid återställning"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tid",
|
||||
"since": "Sedan",
|
||||
"before": "Före",
|
||||
"recent_range": "Senaste intervall (sedan...)",
|
||||
"historical": "Historiskt intervall (före...)",
|
||||
"absolute_range": "Absolut datumintervall",
|
||||
"last_day": "Senaste 1 dagen",
|
||||
"last_days": "Senaste {{count}} dagarna",
|
||||
"last_months": "Senaste {{count}} månaderna",
|
||||
"over_years_ago": "För {{count}} {{unit}} sedan",
|
||||
"year": "år",
|
||||
"years": "år",
|
||||
"recent_prefix": "Senaste:",
|
||||
"days_ago_to_now": "dagar sedan till nu",
|
||||
"apply": "Använd",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Slutdatum",
|
||||
"clear_filters": "Rensa tidsfilter",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "Taggar",
|
||||
"search_placeholder": "Sök taggar...",
|
||||
"clear_all": "Rensa alla taggar",
|
||||
"no_tags_found": "Inga taggar hittades"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "E-postkonton",
|
||||
"search_placeholder": "Sök e-postkonton...",
|
||||
"clear_accounts": "Rensa valda konton",
|
||||
"no_accounts_found": "Inga konton hittades"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "E-postmappar",
|
||||
"search_placeholder": "Sök mappar",
|
||||
"clear_mailboxes": "Rensa valda mappar",
|
||||
"select_account_first": "Välj först ett e-postkonto",
|
||||
"no_mailbox_found": "Inga mappar hittades"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "Kontakter",
|
||||
"label_with_count": "Kontakter ({{count}})",
|
||||
"any": "Alla",
|
||||
"search_placeholder": "Sök {{field}}...",
|
||||
"reset_all": "Återställ alla kontakter",
|
||||
"no_contact_found": "Inga kontakter hittades",
|
||||
"loading": "Laddar...",
|
||||
"showing_limit": "Visar endast de första 100 resultaten • Totalt {{total}}"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "Avancerat",
|
||||
"title": "Avancerade filter",
|
||||
"reset": "Återställ",
|
||||
"has_attachment": "Har bilaga",
|
||||
"attachment_name_label": "Bilagans namn",
|
||||
"attachment_name_placeholder": "t.ex. invoice.pdf",
|
||||
"message_size_label": "Meddelandestorlek",
|
||||
"message_id_label": "Ursprungligt Message-ID",
|
||||
"message_id_description": "Sök via Message-ID i e-posthuvudet",
|
||||
"apply": "Använd filter",
|
||||
"size_presets": {
|
||||
"any": "Valfri storlek",
|
||||
"tiny": "Mycket liten (< 15 KB)",
|
||||
"small": "Liten (< 2 MB)",
|
||||
"medium": "Mellan (2–10 MB)",
|
||||
"large": "Stor (10–20 MB)",
|
||||
"huge": "Mycket stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vy",
|
||||
"menu_title": "Inställningar för kolumner"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "Återställ",
|
||||
"tooltip": "Rensa alla filter"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "Sök e-post... (använd \"citattecken\" för exakt matchning)",
|
||||
"button": "Sök",
|
||||
"recent_title": "Senaste sökningar",
|
||||
"clear_history": "Rensa historik",
|
||||
"no_history": "Ingen sökhistorik",
|
||||
"hint": "Standardomfattning: ämne, innehåll och bilagenamn"
|
||||
}
|
||||
}
|
||||
@@ -1453,13 +1453,104 @@
|
||||
"confirm": "登出"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "還原郵件",
|
||||
"desc": "此操作將把選定的郵件從 Bichon 系統重新上傳並還原到其在 IMAP 伺服器上對應的信箱中。",
|
||||
"title": "還原單個郵件",
|
||||
"bulkTitle": "批量還原多個郵件",
|
||||
"bulkWarning": "請注意:郵件將被還原至其原始存儲的資料夾中。",
|
||||
"accountsInvolved": "涉及帳戶",
|
||||
"summary": "待還原詳情",
|
||||
"messages": "封郵件",
|
||||
"desc": "此操作將把選定的郵件從 Bichon 系統重新上傳並恢復到其在 IMAP 服務器上對應的郵箱中。",
|
||||
"confirm": "執行還原",
|
||||
"restore_to_imap": "還原郵件",
|
||||
"success": "郵件還原成功",
|
||||
"successDesc": "選定的郵件已成功還原到 IMAP 伺服器。",
|
||||
"successDesc": "選定的郵件已成功恢復到 IMAP 服務器。",
|
||||
"failed": "還原郵件失敗",
|
||||
"failedTitle": "還原失敗"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"since": "自",
|
||||
"before": "早於",
|
||||
"recent_range": "近期範圍(自…)",
|
||||
"historical": "歷史範圍(早於…)",
|
||||
"absolute_range": "絕對日期範圍",
|
||||
"last_day": "最近 1 天",
|
||||
"last_days": "最近 {{count}} 天",
|
||||
"last_months": "最近 {{count}} 個月",
|
||||
"over_years_ago": "{{count}} {{unit}} 前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "天前至今",
|
||||
"apply": "套用",
|
||||
"start_date": "開始日期",
|
||||
"end_date": "結束日期",
|
||||
"clear_filters": "清除時間篩選",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "標籤",
|
||||
"search_placeholder": "搜尋標籤…",
|
||||
"clear_all": "清除所有標籤",
|
||||
"no_tags_found": "未找到標籤"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "郵件帳戶",
|
||||
"search_placeholder": "搜尋郵件帳戶…",
|
||||
"clear_accounts": "清除已選帳戶",
|
||||
"no_accounts_found": "未找到帳戶"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "信箱資料夾",
|
||||
"search_placeholder": "搜尋信箱資料夾",
|
||||
"clear_mailboxes": "清除已選資料夾",
|
||||
"select_account_first": "請先選擇郵件帳戶",
|
||||
"no_mailbox_found": "未找到資料夾"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "聯絡人",
|
||||
"label_with_count": "聯絡人({{count}})",
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜尋 {{field}}…",
|
||||
"reset_all": "重設所有聯絡人",
|
||||
"no_contact_found": "未找到聯絡人",
|
||||
"loading": "載入中…",
|
||||
"showing_limit": "僅顯示前 100 筆結果 • 共 {{total}} 筆"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "進階",
|
||||
"title": "進階篩選",
|
||||
"reset": "重設",
|
||||
"has_attachment": "包含附件",
|
||||
"attachment_name_label": "附件名稱",
|
||||
"attachment_name_placeholder": "例如:invoice.pdf",
|
||||
"message_size_label": "郵件大小",
|
||||
"message_id_label": "原始 Message-ID",
|
||||
"message_id_description": "依郵件標頭中的 Message-ID 搜尋",
|
||||
"apply": "套用篩選",
|
||||
"size_presets": {
|
||||
"any": "不限大小",
|
||||
"tiny": "極小(< 15 KB)",
|
||||
"small": "較小(< 2 MB)",
|
||||
"medium": "一般(2–10 MB)",
|
||||
"large": "較大(10–20 MB)",
|
||||
"huge": "極大(> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "檢視",
|
||||
"menu_title": "顯示欄位設定"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "重設",
|
||||
"tooltip": "清除所有篩選條件"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "搜尋郵件…(使用「雙引號」進行精確比對)",
|
||||
"button": "搜尋",
|
||||
"recent_title": "最近搜尋",
|
||||
"clear_history": "清除紀錄",
|
||||
"no_history": "尚無搜尋紀錄",
|
||||
"hint": "預設搜尋範圍:郵件標題、內容與附件名稱"
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1453,12 @@
|
||||
"confirm": "退出登录"
|
||||
},
|
||||
"restore_message": {
|
||||
"title": "还原邮件",
|
||||
"title": "还原单个邮件",
|
||||
"bulkTitle": "批量还原多个邮件",
|
||||
"bulkWarning": "请注意:邮件将被还原至其原始存储的文件夹中。",
|
||||
"accountsInvolved": "涉及账户",
|
||||
"summary": "待还原详情",
|
||||
"messages": "封邮件",
|
||||
"desc": "此操作将把选定的邮件从 Bichon 系统重新上传并恢复到其在 IMAP 服务器上对应的邮箱中。",
|
||||
"confirm": "执行还原",
|
||||
"restore_to_imap": "还原邮件",
|
||||
@@ -1461,5 +1466,91 @@
|
||||
"successDesc": "选定的邮件已成功恢复到 IMAP 服务器。",
|
||||
"failed": "还原邮件失败",
|
||||
"failedTitle": "还原失败"
|
||||
},
|
||||
"time": {
|
||||
"label": "时间",
|
||||
"since": "自",
|
||||
"before": "早于",
|
||||
"recent_range": "近期范围 (自...)",
|
||||
"historical": "历史范围 (早于...)",
|
||||
"absolute_range": "绝对日期范围",
|
||||
"last_day": "最近 1 天",
|
||||
"last_days": "最近 {{count}} 天",
|
||||
"last_months": "最近 {{count}} 个月",
|
||||
"over_years_ago": "{{count}} {{unit}}前",
|
||||
"year": "年",
|
||||
"years": "年",
|
||||
"recent_prefix": "最近:",
|
||||
"days_ago_to_now": "天前至今",
|
||||
"apply": "应用",
|
||||
"start_date": "开始日期",
|
||||
"end_date": "结束日期",
|
||||
"clear_filters": "清除时间筛选",
|
||||
"format": "yyyy-MM-dd"
|
||||
},
|
||||
"tag": {
|
||||
"label": "标签",
|
||||
"search_placeholder": "搜索标签...",
|
||||
"clear_all": "清除所有标签",
|
||||
"no_tags_found": "未找到标签"
|
||||
},
|
||||
"search_accounts": {
|
||||
"label": "邮件账户",
|
||||
"search_placeholder": "搜索邮件账户...",
|
||||
"clear_accounts": "清除已选账户",
|
||||
"no_accounts_found": "未找到账户"
|
||||
},
|
||||
"search_mailbox": {
|
||||
"label": "邮箱文件夹",
|
||||
"search_placeholder": "搜索邮箱文件夹",
|
||||
"clear_mailboxes": "清除已选文件夹",
|
||||
"select_account_first": "请先选择邮件账户",
|
||||
"no_mailbox_found": "未找到文件夹"
|
||||
},
|
||||
"search_contacts": {
|
||||
"label": "联系人",
|
||||
"label_with_count": "联系人 ({{count}})",
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜索 {{field}}...",
|
||||
"reset_all": "重置所有联系人",
|
||||
"no_contact_found": "未找到联系人",
|
||||
"loading": "加载中...",
|
||||
"showing_limit": "仅显示前 100 条结果 • 总计 {{total}} 条"
|
||||
},
|
||||
"search_more": {
|
||||
"trigger_label": "高级",
|
||||
"title": "高级筛选",
|
||||
"reset": "重置",
|
||||
"has_attachment": "包含附件",
|
||||
"attachment_name_label": "附件名称",
|
||||
"attachment_name_placeholder": "例如: invoice.pdf",
|
||||
"message_size_label": "邮件大小",
|
||||
"message_id_label": "原始消息 ID",
|
||||
"message_id_description": "通过邮件头中的 'Message-ID' 进行搜索",
|
||||
"apply": "应用筛选",
|
||||
"size_presets": {
|
||||
"any": "不限大小",
|
||||
"tiny": "极小 (< 15 KB)",
|
||||
"small": "较小 (< 2 MB)",
|
||||
"medium": "普通 (2 - 10 MB)",
|
||||
"large": "较大 (10 - 20 MB)",
|
||||
"huge": "极大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "视图",
|
||||
"menu_title": "显示列设置"
|
||||
},
|
||||
"search_reset": {
|
||||
"label": "重置",
|
||||
"tooltip": "清除所有筛选条件"
|
||||
},
|
||||
"search_input": {
|
||||
"placeholder": "搜索邮件... (使用 \"双引号\" 进行精确匹配)",
|
||||
"button": "搜索",
|
||||
"recent_title": "最近搜索",
|
||||
"clear_history": "清空记录",
|
||||
"no_history": "暂无搜索记录",
|
||||
"hint": "默认搜索范围:邮件标题、正文及附件名称"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user