mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1453f3cda | ||
|
|
9e656b1f91 | ||
|
|
afac192280 | ||
|
|
c00ffe8d11 | ||
|
|
c90a2d552d | ||
|
|
a4627a63cb | ||
|
|
684b9765fe | ||
|
|
b0ce7671a8 | ||
|
|
4b13bf14c4 | ||
|
|
0015192b4d | ||
|
|
2f3acdd759 | ||
|
|
0fc83b693e | ||
|
|
9ba56ca5bb | ||
|
|
8f7244ccb9 | ||
|
|
454950374f | ||
|
|
3a2b42f5c3 | ||
|
|
34ec3a7d5b | ||
|
|
82397ab0cd | ||
|
|
1cfc12324f | ||
|
|
dffdac3eb6 | ||
|
|
7d02e58e4e | ||
|
|
847cc6825a | ||
|
|
6ce1420714 | ||
|
|
9a72ce9154 | ||
|
|
9f713ef044 | ||
|
|
275cde180b | ||
|
|
c82fb4f301 | ||
|
|
736270b08c |
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem you encountered
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Version
|
||||
Which version are you using?
|
||||
|
||||
### Steps to Reproduce
|
||||
Describe the steps to reproduce the issue clearly.
|
||||
|
||||
### Issue Description
|
||||
What is the problem you encountered?
|
||||
|
||||
### Screenshots or Logs (optional)
|
||||
Attach any screenshots or logs if available.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
What feature would you like to see?
|
||||
|
||||
### Purpose / Use Case
|
||||
Why is this feature needed? What problem does it solve?
|
||||
|
||||
### Additional Information (optional)
|
||||
Any extra ideas or context.
|
||||
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Other Issue
|
||||
about: Any other question or topic
|
||||
title: ""
|
||||
labels: ["question"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
Describe your question or topic.
|
||||
|
||||
### Additional Information (optional)
|
||||
Provide any additional context if needed.
|
||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -424,7 +424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
@@ -3536,9 +3536,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -4633,9 +4633,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
@@ -4709,9 +4709,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
version = "0.3.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -39,9 +39,9 @@ ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing = "0.1.43"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
snafu = "0.8.9"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
@@ -83,7 +83,7 @@ async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.0"
|
||||
rustls-pki-types = "1.13.1"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
|
||||
163
README.md
163
README.md
@@ -22,7 +22,7 @@
|
||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<a href="https://discord.gg/evFnSpdpaE">
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
<a href="https://x.com/rustmailer">
|
||||
@@ -124,6 +124,12 @@ It’s not perfect, but I hope it brings you value.
|
||||
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
|
||||
<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" />
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
|
||||
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Docker Deployment (Recommended)
|
||||
@@ -145,41 +151,116 @@ docker run -d \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
## CORS Configuration (Important for Browser Access)
|
||||
|
||||
* **Accessing Bichon from a browser:**
|
||||
You need to add the exact address you use in your browser to `BICHON_CORS_ORIGINS`.
|
||||
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
|
||||
|
||||
* If you access via **IP**, add `IP:port`, e.g.:
|
||||
### **🔄 New Behavior in v0.1.4**
|
||||
|
||||
```
|
||||
http://192.168.1.16:15630
|
||||
```
|
||||
* If you access via **hostname**, add `hostname:port`, e.g.:
|
||||
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
|
||||
This makes local testing and simple deployments much easier.
|
||||
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
|
||||
* `*` is **not supported** and will **not work** — you must provide exact URLs.
|
||||
|
||||
```
|
||||
http://myserver.local:15630
|
||||
```
|
||||
* If you access via **domain name**, add the domain, e.g.:
|
||||
#### How CORS Matching Works
|
||||
|
||||
```
|
||||
http://mydomain.com
|
||||
```
|
||||
* **If Bichon is running on port 80**, you **do not need to include the port**.
|
||||
* If you want to access Bichon in **multiple ways**, include all of them separated by commas.
|
||||
When a browser accesses Bichon, it will send an `Origin` header.
|
||||
|
||||
Example Docker run:
|
||||
* **Incoming Origin** = the exact address the browser is using
|
||||
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
|
||||
|
||||
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
|
||||
|
||||
Example debug log:
|
||||
|
||||
```
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
* Browser is using `http://localhost:15630`
|
||||
* But the configured origin is `http://192.168.3.2:15630`
|
||||
|
||||
→ **CORS will fail**, and you can immediately see why.
|
||||
|
||||
#### When Should You Configure CORS?
|
||||
|
||||
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
|
||||
If you want to access Bichon from a browser:
|
||||
|
||||
* Add the exact **IP** with port
|
||||
* Or the exact **hostname** with port
|
||||
* Or the **domain** (port optional if it's 80)
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
http://192.168.1.16:15630
|
||||
http://myserver.local:15630
|
||||
http://mydomain.com
|
||||
```
|
||||
|
||||
If you access Bichon in **multiple different ways**, list all of them:
|
||||
|
||||
```
|
||||
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
|
||||
```
|
||||
|
||||
> **Do not add a trailing slash**
|
||||
> (`http://192.168.1.16:15630/` will not match)
|
||||
>
|
||||
> **Do not use `*`**, it is not supported.
|
||||
|
||||
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
|
||||
|
||||
Set environment variable:
|
||||
|
||||
```
|
||||
BICHON_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Or via command-line:
|
||||
|
||||
```
|
||||
--bichon-log-level debug
|
||||
```
|
||||
|
||||
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
|
||||
|
||||
---
|
||||
|
||||
#### ⚠️ Note on Running Bichon in a Container
|
||||
|
||||
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
|
||||
|
||||
For example, **do not** write:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name bichon \
|
||||
-p 15630:15630 \
|
||||
-v $(pwd)/bichon-data:/data \
|
||||
-e BICHON_LOG_LEVEL=info \
|
||||
-e BICHON_ROOT_DIR=/data \
|
||||
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com" \
|
||||
rustmailer/bichon:latest
|
||||
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
|
||||
```
|
||||
> **Tip:** Do not add a trailing `/`. Using `*` allows all addresses, but is **not recommended** for security.
|
||||
|
||||
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
|
||||
|
||||
**Correct way:**
|
||||
|
||||
```bash
|
||||
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
Or using YAML literal style for Docker Compose:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BICHON_CORS_ORIGINS: |
|
||||
http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
This ensures that the configured origins are interpreted correctly inside the container.
|
||||
|
||||
> ⚠️ **Note:** This fucking problem I actually didn’t know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
|
||||
|
||||
|
||||
### Binary Deployment
|
||||
|
||||
@@ -283,6 +364,13 @@ You can change the password via the WebUI:
|
||||
> Under construction. Documentation will be available soon.
|
||||
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
|
||||
|
||||
## FAQ
|
||||
|
||||
please see the FAQ in the project Wiki:
|
||||
|
||||
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
|
||||
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Backend**: Rust + Poem
|
||||
@@ -293,10 +381,14 @@ You can change the password via the WebUI:
|
||||
- Used exclusively for lightweight configuration and account metadata.
|
||||
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
Contributions of all kinds are welcome!
|
||||
Whether you’d like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
|
||||
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if you’d like to discuss ideas or improvements.
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
|
||||
## 🧑💻 Developer Guide
|
||||
|
||||
@@ -313,7 +405,7 @@ To build or contribute to Bichon, the following environment is recommended:
|
||||
```bash
|
||||
git clone https://github.com/rustmailer/bichon.git
|
||||
cd bichon
|
||||
````
|
||||
```
|
||||
|
||||
#### 2. Build the WebUI
|
||||
|
||||
@@ -360,13 +452,14 @@ This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
|
||||
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
|
||||
- [Discord](https://discord.gg/evFnSpdpaE)
|
||||
- [Discord](https://discord.gg/Bq4M2cDmF4)
|
||||
|
||||
|
||||
## Get Involved
|
||||
## 💖 Support & Promotion
|
||||
|
||||
If you enjoy using Bichon, you’re welcome to help spread the word 😄
|
||||
Feel free to share your experience on communities like Reddit’s r/selfhosted, r/opensource, or elsewhere—installation tips, challenges you ran into, or cool features you discovered.
|
||||
Completely voluntary—just sharing your experience helps more people discover Bichon!
|
||||
If this project has been helpful to you and you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
|
||||
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
@@ -349,7 +349,11 @@ impl AccountV2 {
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
new.name = Some(name.clone());
|
||||
if name.trim().is_empty() {
|
||||
new.name = None;
|
||||
} else {
|
||||
new.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::IMAP) {
|
||||
|
||||
@@ -69,7 +69,7 @@ impl AccountRunningState {
|
||||
errors: vec![],
|
||||
is_initial_sync_completed: false,
|
||||
progress: None,
|
||||
initial_sync_start_time: None,
|
||||
initial_sync_start_time: Some(utc_now!()),
|
||||
initial_sync_end_time: None,
|
||||
initial_sync_failed_time: None,
|
||||
};
|
||||
@@ -125,14 +125,14 @@ impl AccountRunningState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.initial_sync_start_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
// Self::update_account_running_state(account_id, move |current| {
|
||||
// let mut updated = current.clone();
|
||||
// updated.initial_sync_start_time = Some(utc_now!());
|
||||
// Ok(updated)
|
||||
// })
|
||||
// .await
|
||||
// }
|
||||
|
||||
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
|
||||
5
src/modules/cache/imap/sync/mod.rs
vendored
5
src/modules/cache/imap/sync/mod.rs
vendored
@@ -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::{
|
||||
account::{
|
||||
@@ -46,13 +45,13 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
|
||||
let start_time = Instant::now();
|
||||
let account_id = account.id;
|
||||
let sync_type = determine_sync_type(account).await?;
|
||||
|
||||
if matches!(sync_type, SyncType::SkipSync) {
|
||||
return Ok(());
|
||||
}
|
||||
let remote_mailboxes = get_sync_folders(account).await?;
|
||||
if matches!(sync_type, SyncType::InitialSync) {
|
||||
AccountRunningState::set_initial_sync_start(account_id).await?;
|
||||
AccountRunningState::add(account.id).await?;
|
||||
// AccountRunningState::set_initial_sync_start(account_id).await?;
|
||||
let result = match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
|
||||
|
||||
6
src/modules/cache/imap/sync/sync_type.rs
vendored
6
src/modules/cache/imap/sync/sync_type.rs
vendored
@@ -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::{
|
||||
account::{migration::AccountModel, state::AccountRunningState},
|
||||
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
|
||||
SyncType::SkipSync
|
||||
}
|
||||
}
|
||||
None => {
|
||||
AccountRunningState::add(account.id).await?;
|
||||
SyncType::InitialSync
|
||||
}
|
||||
None => SyncType::InitialSync,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +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::account::migration::AccountType;
|
||||
use crate::modules::context::Initialize;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::raise_error;
|
||||
@@ -33,8 +33,7 @@ use dashmap::DashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tracing::info;
|
||||
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
|
||||
LazyLock::new(EmailClientExecutors::new);
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
|
||||
|
||||
pub struct EmailClientExecutors {
|
||||
start_at: i64,
|
||||
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
|
||||
|
||||
pub async fn start_account_syncers(&self) -> BichonResult<()> {
|
||||
let accounts = AccountModel::list_all().await?;
|
||||
let active_accounts: Vec<AccountModel> =
|
||||
accounts.into_iter().filter(|a| a.enabled).collect();
|
||||
let active_accounts: Vec<AccountModel> = accounts
|
||||
.into_iter()
|
||||
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
|
||||
.collect();
|
||||
|
||||
if active_accounts.is_empty() {
|
||||
info!("No active accounts found for account initialization.");
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
"System has {} active accounts to initialize.",
|
||||
"System has {} active IMAP accounts to initialize.",
|
||||
active_accounts.len()
|
||||
);
|
||||
for account in active_accounts {
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
// 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 poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::{schema::Value, TantivyDocument};
|
||||
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
@@ -45,6 +45,8 @@ pub struct DashboardStats {
|
||||
pub with_attachment_count: u64, // Emails with attachments
|
||||
pub without_attachment_count: u64, // Emails without attachments
|
||||
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
|
||||
pub system_version: String, // The semantic version string of the currently running backend service
|
||||
pub commit_hash: String, // Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
impl DashboardStats {
|
||||
@@ -57,6 +59,8 @@ impl DashboardStats {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
stat.system_version = bichon_version!().to_string();
|
||||
stat.commit_hash = env!("GIT_HASH").to_string();
|
||||
Ok(stat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::common::AddrVec;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0);
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let size = fetch.size.unwrap_or(0);
|
||||
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
|
||||
|
||||
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
@@ -118,6 +117,92 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn extract_envelope_from_eml(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<Envelope> {
|
||||
let uid = 0;
|
||||
let size = body.len() as u32;
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||
text
|
||||
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||
from_read(html.as_bytes(), 0)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let message_id = message
|
||||
.message_id()
|
||||
.map(String::from)
|
||||
.unwrap_or(generate_message_id());
|
||||
let in_reply_to = message.in_reply_to().as_text().map(String::from);
|
||||
let references = extract_references(&message);
|
||||
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
|
||||
let subject = message.subject().map(String::from).unwrap_or("".into());
|
||||
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let cc: Option<Vec<String>> = message.cc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let to: Option<Vec<String>> = message.to().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let from = message
|
||||
.from()
|
||||
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
|
||||
.and_then(|add| add.address)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
let envelope = Envelope {
|
||||
id: create_hash(account_id, &message_id),
|
||||
message_id,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
subject,
|
||||
text,
|
||||
from,
|
||||
to: to.unwrap_or_default(),
|
||||
cc: cc.unwrap_or_default(),
|
||||
bcc: bcc.unwrap_or_default(),
|
||||
date,
|
||||
internal_date: date,
|
||||
size,
|
||||
thread_id,
|
||||
attachments,
|
||||
tags: None,
|
||||
};
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn compute_thread_id(
|
||||
in_reply_to: Option<String>,
|
||||
references: Option<Vec<String>>,
|
||||
|
||||
@@ -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::error::code::ErrorCode;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::{modules::error::BichonResult, raise_error};
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
@@ -205,7 +205,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::modules::imap::client::Client;
|
||||
use crate::modules::imap::oauth2::OAuth2;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::{decrypt, raise_error};
|
||||
use crate::{bichon_version, decrypt, raise_error};
|
||||
use async_imap::Session;
|
||||
use tracing::error;
|
||||
|
||||
@@ -67,6 +67,7 @@ impl ImapConnectionManager {
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
let username = account.name.clone().unwrap_or(account.email.clone());
|
||||
match &imap.auth.auth_type {
|
||||
AuthType::Password => {
|
||||
let password = &imap.auth.password.clone().ok_or_else(|| {
|
||||
@@ -77,7 +78,7 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&account.email, &password).await
|
||||
client.login(&username, &password).await
|
||||
}
|
||||
AuthType::OAuth2 => {
|
||||
let record = OAuth2AccessToken::get(self.account_id).await?;
|
||||
@@ -89,7 +90,7 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(account.email.clone(), access_token))
|
||||
.authenticate(OAuth2::new(username, access_token))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -143,6 +144,19 @@ impl ImapConnectionManager {
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
||||
session
|
||||
.id([
|
||||
("name", Some("bichon")),
|
||||
("version", Some(bichon_version!())),
|
||||
("vendor", Some("rustmailer")),
|
||||
])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Failed to fetch IMAP capabilities: {:#?}", error);
|
||||
|
||||
162
src/modules/import/mod.rs
Normal file
162
src/modules/import/mod.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::doc;
|
||||
|
||||
use crate::{
|
||||
base64_decode_url_safe,
|
||||
modules::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::extract_envelope_from_eml,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::{
|
||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
utils::create_hash,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
/// A list of emails in base64-encoded format. Each element represents one .eml file.
|
||||
pub emls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct FailedEmlDetail {
|
||||
/// The 0-based index of the failed EML in the request list
|
||||
pub index: usize,
|
||||
/// The error message that caused the import to fail
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlResult {
|
||||
/// Total number of emails processed
|
||||
pub total: usize,
|
||||
/// Number of emails successfully imported
|
||||
pub success: usize,
|
||||
/// Number of emails failed to import
|
||||
pub failed: usize,
|
||||
/// A list of details for failed imports
|
||||
pub failed_details: Vec<FailedEmlDetail>,
|
||||
}
|
||||
|
||||
pub struct ImportEmls;
|
||||
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
let account = AccountModel::check_account_exists(request.account_id).await?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||
}
|
||||
|
||||
let mailbox_id = match account.account_type {
|
||||
AccountType::IMAP => {
|
||||
let all_mailboxes = MailBox::list_all(account.id).await?;
|
||||
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
||||
|
||||
match mailbox {
|
||||
Some(mailbox) => mailbox.id,
|
||||
None => return Err(raise_error!(
|
||||
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
|
||||
request.mail_folder,
|
||||
request.account_id).into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)),
|
||||
}
|
||||
},
|
||||
AccountType::NoSync => {
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(request.account_id, &request.mail_folder),
|
||||
account_id: request.account_id,
|
||||
name: request.mail_folder.clone(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
MailBox::batch_upsert(&[mailbox]).await?;
|
||||
mailbox_id
|
||||
},
|
||||
};
|
||||
|
||||
let fields = SchemaTools::eml_fields();
|
||||
let account_id = account.id;
|
||||
let mut success_count = 0;
|
||||
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
index, e
|
||||
);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
|
||||
.await;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.add_document(
|
||||
envelope.id,
|
||||
doc!(
|
||||
fields.f_id => envelope.id,
|
||||
fields.f_account_id => account_id,
|
||||
fields.f_mailbox_id => mailbox_id,
|
||||
fields.f_eml => decoded
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
success_count += 1;
|
||||
}
|
||||
|
||||
let failed_count = failed_details.len();
|
||||
|
||||
Ok(BatchEmlResult {
|
||||
total,
|
||||
success: success_count,
|
||||
failed: failed_count,
|
||||
failed_details, // Return the list of failure details
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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/>.
|
||||
|
||||
|
||||
pub mod account;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
@@ -27,6 +26,7 @@ pub mod database;
|
||||
pub mod envelope;
|
||||
pub mod error;
|
||||
pub mod imap;
|
||||
pub mod import;
|
||||
pub mod indexer;
|
||||
pub mod logger;
|
||||
pub mod mailbox;
|
||||
|
||||
49
src/modules/rest/api/import.rs
Normal file
49
src/modules/rest/api/import.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::import::BatchEmlResult;
|
||||
use crate::modules::import::{BatchEmlRequest, ImportEmls};
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
pub struct ImportApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
|
||||
impl ImportApi {
|
||||
/// Batch import one or more EML files into a specified account and mail folder.
|
||||
///
|
||||
/// This endpoint accepts a JSON payload containing:
|
||||
/// - `account_id`: the target account to import emails into
|
||||
/// - `mail_folder`: the mailbox/folder name
|
||||
/// - `emls`: a list of base64-encoded .eml files
|
||||
///
|
||||
/// Returns a summary of the import result, including total processed, successful, and failed emails.
|
||||
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
|
||||
async fn do_batch_import(
|
||||
&self,
|
||||
/// JSON payload with account info and EML files to import
|
||||
payload: Json<BatchEmlRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<BatchEmlResult>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(ImportEmls::do_import(payload.0).await?))
|
||||
}
|
||||
}
|
||||
@@ -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 access_token::AccessTokenApi;
|
||||
use account::AccountApi;
|
||||
use auto_config::AutoConfigApi;
|
||||
@@ -26,11 +25,12 @@ use oauth2::OAuth2Api;
|
||||
use poem_openapi::{OpenApiService, Tags};
|
||||
use system::SystemApi;
|
||||
|
||||
use crate::bichon_version;
|
||||
use crate::{bichon_version, modules::rest::api::import::ImportApi};
|
||||
|
||||
pub mod access_token;
|
||||
pub mod account;
|
||||
pub mod auto_config;
|
||||
pub mod import;
|
||||
pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod oauth2;
|
||||
@@ -45,6 +45,7 @@ pub enum ApiTags {
|
||||
OAuth2,
|
||||
Message,
|
||||
System,
|
||||
Import,
|
||||
}
|
||||
|
||||
type RustMailOpenApi = (
|
||||
@@ -55,6 +56,7 @@ type RustMailOpenApi = (
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
);
|
||||
|
||||
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
@@ -67,6 +69,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
),
|
||||
"BichonApi",
|
||||
bichon_version!(),
|
||||
|
||||
@@ -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::common::error::ErrorCapture;
|
||||
use crate::modules::common::log::Tracing;
|
||||
use crate::modules::common::tls::rustls_config;
|
||||
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use crate::raise_error;
|
||||
use api::create_openapi_service;
|
||||
use assets::FrontEndAssets;
|
||||
use http::HeaderValue;
|
||||
use http::{HeaderValue, Method};
|
||||
use poem::endpoint::EmbeddedFilesEndpoint;
|
||||
use poem::listener::{Listener, TcpListener};
|
||||
use poem::middleware::{CatchPanic, Compression, SetHeader};
|
||||
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
|
||||
use poem::{get, post};
|
||||
use public::oauth2::oauth2_callback;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod api;
|
||||
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
};
|
||||
|
||||
let api_service = create_openapi_service()
|
||||
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
|
||||
.summary("A lightweight, high-performance Rust email archiver with WebUI");
|
||||
|
||||
let swagger = api_service.swagger_ui();
|
||||
let redoc = api_service.redoc();
|
||||
@@ -78,10 +78,35 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(Timeout)
|
||||
.with(Tracing);
|
||||
|
||||
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
|
||||
if cors_origins.is_empty() {
|
||||
cors_origins = ["*".to_string()].into_iter().collect();
|
||||
}
|
||||
let cors_origins: Option<HashSet<String>> =
|
||||
SETTINGS.bichon_cors_origins.clone();
|
||||
|
||||
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins_fn(move |origin| {
|
||||
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
|
||||
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
|
||||
if cors_origins.is_empty() {
|
||||
tracing::debug!("CORS: No origins configured, allowing all");
|
||||
return true;
|
||||
}
|
||||
cors_origins.iter().any(|o| o == origin)
|
||||
})
|
||||
//.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(&[
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
Method::HEAD,
|
||||
Method::PATCH,
|
||||
])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let cache_static = || {
|
||||
SetHeader::new().overriding(
|
||||
@@ -90,14 +115,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
)
|
||||
};
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let route = Route::new()
|
||||
.nest("/api-docs/swagger", swagger)
|
||||
.nest("/api-docs/redoc", redoc)
|
||||
|
||||
@@ -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 clap::{builder::ValueParser, Parser, ValueEnum};
|
||||
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
|
||||
|
||||
@@ -77,7 +76,6 @@ pub struct Settings {
|
||||
/// CORS allowed origins (default: "*")
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "http://localhost:5173, http://localhost:15630, *",
|
||||
env,
|
||||
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
|
||||
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
|
||||
@@ -88,7 +86,7 @@ pub struct Settings {
|
||||
Ok(set)
|
||||
})
|
||||
)]
|
||||
pub bichon_cors_origins: HashSet<String>,
|
||||
pub bichon_cors_origins: Option<HashSet<String>>,
|
||||
|
||||
/// CORS max age in seconds (default: 86400)
|
||||
#[clap(
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@mui/x-tree-view": "^8.19.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
@@ -37,6 +41,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"@tabler/icons-react": "^3.24.0",
|
||||
"@tanstack/react-query": "^5.62.3",
|
||||
"@tanstack/react-router": "^1.86.1",
|
||||
|
||||
581
web/pnpm-lock.yaml
generated
581
web/pnpm-lock.yaml
generated
@@ -8,9 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@emotion/react':
|
||||
specifier: ^11.14.0
|
||||
version: 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/styled':
|
||||
specifier: ^11.14.1
|
||||
version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1(react-hook-form@7.54.0(react@18.3.1))
|
||||
'@mui/material':
|
||||
specifier: ^7.3.5
|
||||
version: 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@mui/x-tree-view':
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -80,6 +92,9 @@ importers:
|
||||
'@radix-ui/react-visually-hidden':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@react-spring/web':
|
||||
specifier: ^10.0.3
|
||||
version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.24.0
|
||||
version: 3.24.0(react@18.3.1)
|
||||
@@ -350,10 +365,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.26.0':
|
||||
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.28.4':
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -378,6 +389,70 @@ packages:
|
||||
resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui-components/utils@0.1.2':
|
||||
resolution: {integrity: sha512-aEitDGpMsYO2qnSpYOwZNykn9Rzn2ioyEVk2fyDRH7t+TIHVKpp9CeV7SPTq43M9mMSDxQ+7UeZJVkrj2dCVIQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
||||
|
||||
'@emotion/cache@11.14.0':
|
||||
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
|
||||
|
||||
'@emotion/hash@0.9.2':
|
||||
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
|
||||
|
||||
'@emotion/is-prop-valid@1.4.0':
|
||||
resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
|
||||
|
||||
'@emotion/memoize@0.9.0':
|
||||
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
|
||||
|
||||
'@emotion/react@11.14.0':
|
||||
resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@emotion/serialize@1.3.3':
|
||||
resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
|
||||
|
||||
'@emotion/sheet@1.4.0':
|
||||
resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
|
||||
|
||||
'@emotion/styled@11.14.1':
|
||||
resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11.0.0-rc.0
|
||||
'@types/react': '*'
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@emotion/unitless@0.10.0':
|
||||
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
|
||||
|
||||
'@emotion/use-insertion-effect-with-fallbacks@1.2.0':
|
||||
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emotion/utils@1.4.2':
|
||||
resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
|
||||
|
||||
'@emotion/weak-memoize@0.4.0':
|
||||
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.23.1':
|
||||
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -722,6 +797,9 @@ packages:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@floating-ui/utils@0.2.8':
|
||||
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
||||
|
||||
@@ -775,6 +853,108 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@mui/core-downloads-tracker@7.3.5':
|
||||
resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==}
|
||||
|
||||
'@mui/material@7.3.5':
|
||||
resolution: {integrity: sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11.5.0
|
||||
'@emotion/styled': ^11.3.0
|
||||
'@mui/material-pigment-css': ^7.3.5
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/react':
|
||||
optional: true
|
||||
'@emotion/styled':
|
||||
optional: true
|
||||
'@mui/material-pigment-css':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/private-theming@7.3.5':
|
||||
resolution: {integrity: sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/styled-engine@7.3.5':
|
||||
resolution: {integrity: sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11.4.1
|
||||
'@emotion/styled': ^11.3.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/react':
|
||||
optional: true
|
||||
'@emotion/styled':
|
||||
optional: true
|
||||
|
||||
'@mui/system@7.3.5':
|
||||
resolution: {integrity: sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11.5.0
|
||||
'@emotion/styled': ^11.3.0
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/react':
|
||||
optional: true
|
||||
'@emotion/styled':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/types@7.4.8':
|
||||
resolution: {integrity: sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/utils@7.3.5':
|
||||
resolution: {integrity: sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@mui/x-internals@8.19.0':
|
||||
resolution: {integrity: sha512-mMmiyJAN5fW27srXJjhXhXJa+w2xGO45rwcjws6OQc9rdXGdJqRXhBwJd+OT7J1xwSdFIIUhjZRTz1KAfCSGBg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@mui/x-tree-view@8.19.0':
|
||||
resolution: {integrity: sha512-1JPIczqK5qdDmo4p8KEpR4XZKxDHkw5dBoY1o+FNUdKmOipklQvg0+XL5wY75pjwzADngZR/6RYW08bhkt+Z3Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@emotion/react': ^11.9.0
|
||||
'@emotion/styled': ^11.8.1
|
||||
'@mui/material': ^5.15.14 || ^6.0.0 || ^7.0.0
|
||||
'@mui/system': ^5.15.14 || ^6.0.0 || ^7.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/react':
|
||||
optional: true
|
||||
'@emotion/styled':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -879,6 +1059,9 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@popperjs/core@2.11.8':
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
|
||||
'@radix-ui/number@1.1.0':
|
||||
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
|
||||
|
||||
@@ -1648,6 +1831,33 @@ packages:
|
||||
'@radix-ui/rect@1.1.0':
|
||||
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
|
||||
|
||||
'@react-spring/animated@10.0.3':
|
||||
resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-spring/core@10.0.3':
|
||||
resolution: {integrity: sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-spring/rafz@10.0.3':
|
||||
resolution: {integrity: sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==}
|
||||
|
||||
'@react-spring/shared@10.0.3':
|
||||
resolution: {integrity: sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-spring/types@10.0.3':
|
||||
resolution: {integrity: sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==}
|
||||
|
||||
'@react-spring/web@10.0.3':
|
||||
resolution: {integrity: sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.26.0':
|
||||
resolution: {integrity: sha512-gJNwtPDGEaOEgejbaseY6xMFu+CPltsc8/T+diUTTbOQLqD+bnrJq9ulH6WD69TqwqWmrfRAtUv30cCFZlbGTQ==}
|
||||
cpu: [arm]
|
||||
@@ -2020,14 +2230,25 @@ packages:
|
||||
'@types/node@22.10.1':
|
||||
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
|
||||
|
||||
'@types/parse-json@4.0.2':
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
|
||||
'@types/prop-types@15.7.14':
|
||||
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/react-dom@18.3.5':
|
||||
resolution: {integrity: sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.0.0
|
||||
|
||||
'@types/react-transition-group@4.4.12':
|
||||
resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
|
||||
'@types/react@18.3.18':
|
||||
resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==}
|
||||
|
||||
@@ -2243,6 +2464,10 @@ packages:
|
||||
babel-dead-code-elimination@1.0.6:
|
||||
resolution: {integrity: sha512-JxFi9qyRJpN0LjEbbjbN8g0ux71Qppn9R8Qe3k6QzHg2CaKsbUQtbn307LQGiDLGjV6JCtEFqfxzVig9MyDCHQ==}
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
@@ -2358,9 +2583,16 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
convert-source-map@1.9.0:
|
||||
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
cross-spawn@7.0.5:
|
||||
resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2491,6 +2723,9 @@ packages:
|
||||
resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
@@ -2624,6 +2859,9 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-root@1.1.0:
|
||||
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2731,6 +2969,9 @@ packages:
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
@@ -2780,6 +3021,9 @@ packages:
|
||||
is-alphanumerical@2.0.1:
|
||||
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3166,6 +3410,10 @@ packages:
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-ms@4.0.0:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3185,6 +3433,10 @@ packages:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
path-type@4.0.0:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3377,6 +3629,9 @@ packages:
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-is@19.2.0:
|
||||
resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@@ -3456,9 +3711,6 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
regenerator-runtime@0.14.1:
|
||||
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -3475,6 +3727,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3592,6 +3847,9 @@ packages:
|
||||
style-to-object@1.0.8:
|
||||
resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
|
||||
|
||||
stylis@4.2.0:
|
||||
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
|
||||
|
||||
sucrase@3.35.0:
|
||||
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -3891,6 +4149,10 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yaml@2.6.0:
|
||||
resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4049,10 +4311,6 @@ snapshots:
|
||||
'@babel/core': 7.26.0
|
||||
'@babel/helper-plugin-utils': 7.25.9
|
||||
|
||||
'@babel/runtime@7.26.0':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/template@7.25.9':
|
||||
@@ -4098,6 +4356,100 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
|
||||
'@base-ui-components/utils@0.1.2(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.25.9
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
babel-plugin-macros: 3.1.0
|
||||
convert-source-map: 1.9.0
|
||||
escape-string-regexp: 4.0.0
|
||||
find-root: 1.1.0
|
||||
source-map: 0.5.7
|
||||
stylis: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/cache@11.14.0':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/sheet': 1.4.0
|
||||
'@emotion/utils': 1.4.2
|
||||
'@emotion/weak-memoize': 0.4.0
|
||||
stylis: 4.2.0
|
||||
|
||||
'@emotion/hash@0.9.2': {}
|
||||
|
||||
'@emotion/is-prop-valid@1.4.0':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.9.0
|
||||
|
||||
'@emotion/memoize@0.9.0': {}
|
||||
|
||||
'@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/cache': 11.14.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1)
|
||||
'@emotion/utils': 1.4.2
|
||||
'@emotion/weak-memoize': 0.4.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/serialize@1.3.3':
|
||||
dependencies:
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/unitless': 0.10.0
|
||||
'@emotion/utils': 1.4.2
|
||||
csstype: 3.1.3
|
||||
|
||||
'@emotion/sheet@1.4.0': {}
|
||||
|
||||
'@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/serialize': 1.3.3
|
||||
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1)
|
||||
'@emotion/utils': 1.4.2
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/unitless@0.10.0': {}
|
||||
|
||||
'@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@emotion/utils@1.4.2': {}
|
||||
|
||||
'@emotion/weak-memoize@0.4.0': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.23.1':
|
||||
optional: true
|
||||
|
||||
@@ -4303,6 +4655,8 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@floating-ui/utils@0.2.8': {}
|
||||
|
||||
'@hookform/resolvers@3.9.1(react-hook-form@7.54.0(react@18.3.1))':
|
||||
@@ -4354,6 +4708,115 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@mui/core-downloads-tracker@7.3.5': {}
|
||||
|
||||
'@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mui/core-downloads-tracker': 7.3.5
|
||||
'@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
'@mui/types': 7.4.8(@types/react@18.3.18)
|
||||
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
'@popperjs/core': 2.11.8
|
||||
'@types/react-transition-group': 4.4.12(@types/react@18.3.18)
|
||||
clsx: 2.1.1
|
||||
csstype: 3.1.3
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-is: 19.2.0
|
||||
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@mui/private-theming@7.3.5(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@mui/styled-engine@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@emotion/cache': 11.14.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
'@emotion/sheet': 1.4.0
|
||||
csstype: 3.1.3
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
|
||||
'@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mui/private-theming': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
'@mui/styled-engine': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(react@18.3.1)
|
||||
'@mui/types': 7.4.8(@types/react@18.3.18)
|
||||
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
clsx: 2.1.1
|
||||
csstype: 3.1.3
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@mui/types@7.4.8(@types/react@18.3.18)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@mui/utils@7.3.5(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mui/types': 7.4.8(@types/react@18.3.18)
|
||||
'@types/prop-types': 15.7.15
|
||||
clsx: 2.1.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-is: 19.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@mui/x-internals@8.19.0(@types/react@18.3.18)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mui/x-tree-view@8.19.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@base-ui-components/utils': 0.1.2(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
'@mui/utils': 7.3.5(@types/react@18.3.18)(react@18.3.1)
|
||||
'@mui/x-internals': 8.19.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@types/react-transition-group': 4.4.12(@types/react@18.3.18)
|
||||
clsx: 2.1.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -4430,6 +4893,8 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@radix-ui/number@1.1.0': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.0': {}
|
||||
@@ -5201,6 +5666,38 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.0': {}
|
||||
|
||||
'@react-spring/animated@10.0.3(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/shared': 10.0.3(react@18.3.1)
|
||||
'@react-spring/types': 10.0.3
|
||||
react: 18.3.1
|
||||
|
||||
'@react-spring/core@10.0.3(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/animated': 10.0.3(react@18.3.1)
|
||||
'@react-spring/shared': 10.0.3(react@18.3.1)
|
||||
'@react-spring/types': 10.0.3
|
||||
react: 18.3.1
|
||||
|
||||
'@react-spring/rafz@10.0.3': {}
|
||||
|
||||
'@react-spring/shared@10.0.3(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/rafz': 10.0.3
|
||||
'@react-spring/types': 10.0.3
|
||||
react: 18.3.1
|
||||
|
||||
'@react-spring/types@10.0.3': {}
|
||||
|
||||
'@react-spring/web@10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/animated': 10.0.3(react@18.3.1)
|
||||
'@react-spring/core': 10.0.3(react@18.3.1)
|
||||
'@react-spring/shared': 10.0.3(react@18.3.1)
|
||||
'@react-spring/types': 10.0.3
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.26.0':
|
||||
optional: true
|
||||
|
||||
@@ -5526,12 +6023,20 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
|
||||
'@types/parse-json@4.0.2': {}
|
||||
|
||||
'@types/prop-types@15.7.14': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/react-dom@18.3.5(@types/react@18.3.18)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@types/react-transition-group@4.4.12(@types/react@18.3.18)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@types/react@18.3.18':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.14
|
||||
@@ -5822,6 +6327,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
cosmiconfig: 7.1.0
|
||||
resolve: 1.22.8
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -5934,8 +6445,18 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
convert-source-map@1.9.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
dependencies:
|
||||
'@types/parse-json': 4.0.2
|
||||
import-fresh: 3.3.0
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
yaml: 1.10.2
|
||||
|
||||
cross-spawn@7.0.5:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -6024,7 +6545,7 @@ snapshots:
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.28.4
|
||||
csstype: 3.1.3
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
@@ -6046,6 +6567,10 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.1
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
optional: true
|
||||
|
||||
@@ -6237,6 +6762,8 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-root@1.1.0: {}
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -6350,6 +6877,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
@@ -6391,6 +6922,8 @@ snapshots:
|
||||
is-alphabetical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
@@ -6450,8 +6983,7 @@ snapshots:
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
optional: true
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
@@ -6965,6 +7497,13 @@ snapshots:
|
||||
is-decimal: 2.0.1
|
||||
is-hexadecimal: 2.0.1
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@@ -6978,6 +7517,8 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.2
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -7098,6 +7639,8 @@ snapshots:
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-is@19.2.0: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -7159,7 +7702,7 @@ snapshots:
|
||||
|
||||
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.0
|
||||
'@babel/runtime': 7.28.4
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
@@ -7198,8 +7741,6 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
regenerator-runtime@0.14.1: {}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -7237,6 +7778,8 @@ snapshots:
|
||||
require-from-string@2.0.2:
|
||||
optional: true
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -7371,6 +7914,8 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.4
|
||||
|
||||
stylis@4.2.0: {}
|
||||
|
||||
sucrase@3.35.0:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.5
|
||||
@@ -7701,6 +8246,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@1.10.2: {}
|
||||
|
||||
yaml@2.6.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface DashboardStats {
|
||||
with_attachment_count: number; // Emails with attachments
|
||||
without_attachment_count: number; // Emails without attachments
|
||||
top_largest_emails: LargestEmail[]; // Top 10 largest emails
|
||||
system_version: string, //The semantic version string of the currently running backend service
|
||||
commit_hash: string //Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
export interface TimeBucket {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function EnvelopeListPagination({
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((size) => (
|
||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -91,7 +91,7 @@ export function EnvelopeListPagination({
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-center text-sm font-medium'>
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
|
||||
@@ -1,529 +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 React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const treeVariants = cva(
|
||||
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10'
|
||||
)
|
||||
|
||||
const selectedTreeVariants = cva(
|
||||
'before:opacity-100 before:bg-accent/70 text-accent-foreground'
|
||||
)
|
||||
|
||||
interface TreeDataItem {
|
||||
id: string
|
||||
name: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
openIcon?: React.ComponentType<{ className?: string }>
|
||||
children?: TreeDataItem[]
|
||||
badge?: React.ReactNode,
|
||||
attributes?: React.ReactNode,
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
data: TreeDataItem[] | TreeDataItem
|
||||
onSelectChange?: (item: TreeDataItem | undefined) => void
|
||||
onSelectItemsChange?: (items: TreeDataItem[]) => void
|
||||
expandAll?: boolean
|
||||
multiple?: boolean
|
||||
clickRowToSelect?: boolean
|
||||
initialSelectedItemIds?: string[]
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
(
|
||||
{
|
||||
data,
|
||||
onSelectChange,
|
||||
onSelectItemsChange,
|
||||
expandAll,
|
||||
defaultLeafIcon,
|
||||
defaultNodeIcon,
|
||||
clickRowToSelect = true,
|
||||
className,
|
||||
multiple,
|
||||
initialSelectedItemIds = [],
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = React.useState<Set<string>>(
|
||||
new Set(initialSelectedItemIds)
|
||||
);
|
||||
|
||||
const callbacksRef = React.useRef({
|
||||
onSelectChange,
|
||||
onSelectItemsChange
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onSelectChange,
|
||||
onSelectItemsChange
|
||||
};
|
||||
}, [onSelectChange, onSelectItemsChange]);
|
||||
|
||||
|
||||
const handleSelectChange = React.useCallback(
|
||||
(item: TreeDataItem | undefined) => {
|
||||
if (!item) return;
|
||||
|
||||
setSelectedItemIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
if (!multiple) {
|
||||
newSet.clear();
|
||||
}
|
||||
newSet.add(item.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (callbacksRef.current.onSelectChange) {
|
||||
callbacksRef.current.onSelectChange(newSet.has(item.id) ? item : undefined);
|
||||
}
|
||||
if (callbacksRef.current.onSelectItemsChange) {
|
||||
const selectedItems = Array.from(newSet)
|
||||
.map(id => findItemById(data, id))
|
||||
.filter(Boolean) as TreeDataItem[];
|
||||
callbacksRef.current.onSelectItemsChange(selectedItems);
|
||||
}
|
||||
}, 0);
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[multiple, onSelectChange, onSelectItemsChange, data]
|
||||
);
|
||||
|
||||
const expandedItemIds = React.useMemo(() => {
|
||||
if (!initialSelectedItemIds || initialSelectedItemIds.length === 0) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
const ids: string[] = []
|
||||
|
||||
function walkTreeItems(
|
||||
items: TreeDataItem[] | TreeDataItem,
|
||||
targetIds: string[]
|
||||
) {
|
||||
if (Array.isArray(items)) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
ids.push(items[i]!.id)
|
||||
if (walkTreeItems(items[i]!, targetIds) && !expandAll) {
|
||||
return true
|
||||
}
|
||||
if (!expandAll) ids.pop()
|
||||
}
|
||||
} else if (!expandAll && targetIds.includes(items.id)) {
|
||||
return true
|
||||
} else if (items.children) {
|
||||
return walkTreeItems(items.children, targetIds)
|
||||
}
|
||||
}
|
||||
|
||||
walkTreeItems(data, initialSelectedItemIds)
|
||||
return ids
|
||||
}, [data, expandAll, initialSelectedItemIds])
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden relative p-2', className)}>
|
||||
<TreeItem
|
||||
data={data}
|
||||
ref={ref}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
selectedItemIds={selectedItemIds}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeView.displayName = 'TreeView'
|
||||
|
||||
// Helper function to find item by ID in tree
|
||||
function findItemById(items: TreeDataItem[] | TreeDataItem, id: string): TreeDataItem | undefined {
|
||||
if (Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
const found = findItemById(item, id);
|
||||
if (found) return found;
|
||||
}
|
||||
} else {
|
||||
if (items.id === id) return items;
|
||||
if (items.children) {
|
||||
return findItemById(items.children, id);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type TreeItemProps = TreeProps & {
|
||||
selectedItemIds: Set<string>
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
expandedItemIds: string[]
|
||||
clickRowToSelect?: boolean
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
data,
|
||||
selectedItemIds,
|
||||
handleSelectChange,
|
||||
clickRowToSelect,
|
||||
expandedItemIds,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
if (!Array.isArray(data)) {
|
||||
data = [data]
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} role="tree" className={className} {...props}>
|
||||
<ul>
|
||||
{data.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.children ? (
|
||||
<TreeNode
|
||||
item={item}
|
||||
selectedItemIds={selectedItemIds}
|
||||
expandedItemIds={expandedItemIds}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
handleSelectChange={handleSelectChange}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
/>
|
||||
) : (
|
||||
<TreeLeaf
|
||||
item={item}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
selectedItemIds={selectedItemIds}
|
||||
handleSelectChange={handleSelectChange}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeItem.displayName = 'TreeItem'
|
||||
|
||||
interface TreeNodeProps {
|
||||
item: TreeDataItem
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
expandedItemIds: string[]
|
||||
clickRowToSelect?: boolean
|
||||
selectedItemIds: Set<string>
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeNode = ({
|
||||
item,
|
||||
handleSelectChange,
|
||||
expandedItemIds,
|
||||
clickRowToSelect,
|
||||
selectedItemIds,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon
|
||||
}: TreeNodeProps) => {
|
||||
const [value, setValue] = React.useState(
|
||||
expandedItemIds.includes(item.id) ? [item.id] : []
|
||||
)
|
||||
|
||||
const isSelected = selectedItemIds.has(item.id);
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
type="multiple"
|
||||
value={value}
|
||||
onValueChange={(s) => setValue(s)}
|
||||
>
|
||||
<AccordionPrimitive.Item value={item.id}>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"flex items-center w-full py-2",
|
||||
treeVariants(),
|
||||
isSelected && selectedTreeVariants()
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (clickRowToSelect) {
|
||||
handleSelectChange(item);
|
||||
}
|
||||
item.onClick?.();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
isOpen={value.includes(item.id)}
|
||||
default={defaultNodeIcon}
|
||||
onCheck={() => { handleSelectChange(item) }}
|
||||
/>
|
||||
<span className="ml-2 text-sm truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.attributes && (
|
||||
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
|
||||
{item.attributes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.badge && (
|
||||
<TreeBadge isSelected={isSelected}>
|
||||
{item.badge}
|
||||
</TreeBadge>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="ml-4 pl-1 border-l">
|
||||
<TreeItem
|
||||
data={item.children ? item.children : item}
|
||||
selectedItemIds={selectedItemIds}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPrimitive.Item>
|
||||
</AccordionPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// function hasSelectedChildrenRecursive(item: TreeDataItem, selectedItemIds: Set<string>): boolean {
|
||||
// if (!item.children) return false;
|
||||
// return item.children.some(child =>
|
||||
// selectedItemIds.has(child.id) ||
|
||||
// (child.children && hasSelectedChildrenRecursive(child, selectedItemIds)));
|
||||
// }
|
||||
|
||||
interface TreeLeafProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
item: TreeDataItem
|
||||
selectedItemIds: Set<string>
|
||||
clickRowToSelect?: boolean
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeLeaf = React.forwardRef<HTMLDivElement, TreeLeafProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
item,
|
||||
clickRowToSelect,
|
||||
selectedItemIds,
|
||||
handleSelectChange,
|
||||
defaultLeafIcon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ml-5 flex items-center py-2 cursor-pointer before:right-1",
|
||||
treeVariants(),
|
||||
className,
|
||||
selectedItemIds.has(item.id) && selectedTreeVariants()
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (clickRowToSelect) {
|
||||
handleSelectChange(item);
|
||||
}
|
||||
item.onClick?.();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={selectedItemIds.has(item.id)}
|
||||
default={defaultLeafIcon}
|
||||
onCheck={() => { handleSelectChange(item) }}
|
||||
/>
|
||||
<span className="ml-2 text-sm truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.attributes && (
|
||||
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
|
||||
{item.attributes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.badge && (
|
||||
<TreeBadge isSelected={selectedItemIds.has(item.id)}>
|
||||
{item.badge}
|
||||
</TreeBadge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeLeaf.displayName = 'TreeLeaf'
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header>
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (props.onClick) {
|
||||
props.onClick(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
|
||||
{children}
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-1 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
interface TreeIconProps {
|
||||
item: TreeDataItem;
|
||||
isOpen?: boolean;
|
||||
isSelected?: boolean;
|
||||
default?: React.ComponentType<{ className?: string }>;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const TreeIcon = ({
|
||||
item,
|
||||
isOpen,
|
||||
isSelected,
|
||||
default: defaultIcon,
|
||||
onCheck,
|
||||
}: TreeIconProps) => {
|
||||
let Icon = defaultIcon;
|
||||
if (isOpen && item.openIcon) {
|
||||
Icon = item.openIcon;
|
||||
} else if (item.icon) {
|
||||
Icon = item.icon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onCheck?.(!isSelected)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"h-4 w-4 rounded border border-primary dark:border-white shadow transition-all duration-200",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isSelected
|
||||
? "bg-black dark:bg-white text-primary-foreground"
|
||||
: "bg-transparent",
|
||||
"appearance-none cursor-pointer flex items-center justify-center relative",
|
||||
"after:content-[''] after:w-1.5 after:h-2",
|
||||
"after:border-r-2 after:border-b-2 after:rotate-45 after:mt-[-2px]",
|
||||
isSelected
|
||||
? "after:block after:border-white dark:after:border-black after:z-10"
|
||||
: "after:hidden"
|
||||
)}
|
||||
/>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TreeBadgeProps {
|
||||
children: React.ReactNode
|
||||
isSelected: boolean
|
||||
showOnSelectedOnly?: boolean
|
||||
}
|
||||
|
||||
const TreeBadge = ({
|
||||
children,
|
||||
isSelected,
|
||||
showOnSelectedOnly = false
|
||||
}: TreeBadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
showOnSelectedOnly
|
||||
? isSelected
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
: 'block',
|
||||
'absolute right-3 group-hover:block'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TreeView, type TreeDataItem }
|
||||
@@ -23,11 +23,11 @@ import LongText from '@/components/long-text'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { format, formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { AccountCellAction } from './account-action'
|
||||
import { AclCellAction } from './acl-action'
|
||||
|
||||
export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[] => [
|
||||
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
|
||||
{
|
||||
accessorKey: 'token',
|
||||
header: ({ column }) => (
|
||||
@@ -114,7 +114,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
|
||||
if (last_access_at === 0) {
|
||||
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
|
||||
}
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
|
||||
return <LongText className='max-w-40'>{result}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-40' },
|
||||
|
||||
@@ -38,9 +38,12 @@ import { list_access_tokens } from '@/api/access-tokens/api'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
export default function AccessTokens() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
// Dialog states
|
||||
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
|
||||
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
|
||||
@@ -50,7 +53,7 @@ export default function AccessTokens() {
|
||||
queryFn: list_access_tokens,
|
||||
})
|
||||
|
||||
const columns = getColumns(t)
|
||||
const columns = getColumns(t, locale)
|
||||
|
||||
return (
|
||||
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
@@ -58,43 +61,43 @@ export default function AccessTokens() {
|
||||
<FixedHeader />
|
||||
|
||||
<Main>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('accessTokens.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>{t('common.add')}</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] 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('accessTokens.noTokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
{t('accessTokens.noTokensDesc')}
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('accessTokens.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>{t('common.add')}</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] 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('accessTokens.noTokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
{t('accessTokens.noTokensDesc')}
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
|
||||
<TokensActionDialog
|
||||
|
||||
@@ -120,6 +120,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.encryption')}:</span>
|
||||
<span>{currentRow.imap?.encryption}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.useDangerous')}:</span>
|
||||
<span>{`${currentRow.use_dangerous}`}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.auth')}:</span>
|
||||
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
|
||||
|
||||
@@ -139,7 +139,7 @@ export type Steps = [
|
||||
|
||||
const getSteps = (t: (key: string) => string): Steps => [
|
||||
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
|
||||
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous"] },
|
||||
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
|
||||
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
|
||||
];
|
||||
|
||||
@@ -37,10 +37,10 @@ import { AccountModel } from '../data/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
const accountSchema = () =>
|
||||
const accountSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const form = useForm<NoSyncAccount>({
|
||||
mode: "all",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema()),
|
||||
resolver: zodResolver(accountSchema(t)),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -118,7 +118,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -134,7 +134,8 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const commonData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
enabled: data.enabled
|
||||
enabled: data.enabled,
|
||||
use_dangerous: false
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
@@ -189,7 +190,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
@@ -204,7 +205,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
|
||||
@@ -33,7 +33,7 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
|
||||
const account_type = mailer.account_type;
|
||||
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
|
||||
}
|
||||
|
||||
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"
|
||||
|
||||
@@ -42,6 +42,8 @@ import { IconCopy } from '@tabler/icons-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
@@ -50,7 +52,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const navigate = useNavigate()
|
||||
const { data: oauth2Tokens, isLoading } = useQuery({
|
||||
queryKey: ['oauth2-tokens', currentRow.id],
|
||||
@@ -151,7 +154,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true, locale })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
|
||||
@@ -34,6 +34,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
|
||||
import { FolderSyncProgress } from './folder-sync-progress'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -42,7 +44,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { data: state, isLoading } = useQuery({
|
||||
queryKey: ['running-state', currentRow.id],
|
||||
queryFn: () => account_state(currentRow.id),
|
||||
@@ -124,7 +127,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_start_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
@@ -143,7 +146,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_end_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })}
|
||||
</span>
|
||||
) : state.initial_sync_start_time ? (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
@@ -192,7 +195,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_start ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale })
|
||||
) : (
|
||||
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
|
||||
)}
|
||||
@@ -202,7 +205,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_end ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale })
|
||||
) : state.last_incremental_sync_start ? (
|
||||
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
|
||||
) : (
|
||||
@@ -243,7 +246,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })}
|
||||
</div>
|
||||
<div className="font-medium break-words">{item.error}</div>
|
||||
</div>
|
||||
|
||||
@@ -66,22 +66,6 @@ export default function Step1({ isEdit }: StepProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
{t('accounts.name')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{t('accounts.optional')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,22 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
{t('accounts.name')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.auth.auth_type"
|
||||
|
||||
@@ -58,6 +58,10 @@ export default function Step4() {
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.encryption')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useDangerous')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{`${summaryData.use_dangerous}`}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.authType')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
|
||||
|
||||
@@ -26,20 +26,119 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, CheckSquare, Square } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { list_mailboxes } from '@/api/mailbox/api'
|
||||
import { buildTree } from '@/lib/build-tree'
|
||||
import { TreeDataItem, TreeView } from '@/components/tree-view'
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { update_account } from '@/api/account/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import React from 'react'
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { TreeItemCheckbox, TreeItemContent, TreeItemIconContainer, TreeItemLabel, TreeItemRoot } from '@mui/x-tree-view/TreeItem'
|
||||
import { TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemProvider, TreeViewBaseItem, TreeViewSelectionPropagation, useTreeItem, useTreeItemModel, UseTreeItemParameters } from '@mui/x-tree-view'
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
|
||||
|
||||
function getParentIds(tree: TreeViewBaseItem[]): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
function traverse(nodes: TreeViewBaseItem[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(node.id);
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
<div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
|
||||
{attributes?.map((attr) => {
|
||||
const text =
|
||||
attr.attr === 'Extension'
|
||||
? attr.extension
|
||||
: attr.attr;
|
||||
|
||||
return (
|
||||
<span key={attr.attr} className="text-inherit">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
)}
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomCollapse = styled(Collapse)({
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
const AnimatedCollapse = animated(CustomCollapse);
|
||||
|
||||
function TransitionComponent(props: TransitionProps) {
|
||||
const style = useSpring({
|
||||
to: {
|
||||
opacity: props.in ? 1 : 0,
|
||||
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
||||
},
|
||||
});
|
||||
|
||||
return <AnimatedCollapse style={style} {...props} />;
|
||||
}
|
||||
|
||||
interface CustomTreeItemProps
|
||||
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
||||
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -48,71 +147,151 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
|
||||
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [allIds, setAllIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
const [itemsWithChildren, setItemsWithChildren] = useState<string[]>([]);
|
||||
const [mailboxes, setMailboxes] = useState<MailboxData[]>([]);
|
||||
const [selectionPropagation, setSelectionPropagation] =
|
||||
React.useState<TreeViewSelectionPropagation>({
|
||||
parents: false,
|
||||
descendants: true,
|
||||
});
|
||||
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation()
|
||||
const { data: mailboxes, isLoading } = useQuery({
|
||||
queryKey: ['account-mailboxes', currentRow.id],
|
||||
queryFn: () => list_mailboxes(currentRow.id, true),
|
||||
enabled: open,
|
||||
});
|
||||
const { theme } = useTheme()
|
||||
|
||||
// Convert mailbox names to IDs for initial selection
|
||||
const initialSelectedItemIds = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return mailboxes
|
||||
.filter(mailbox => selectedFolders.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
}, [mailboxes, selectedFolders]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const fetchMailboxes = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await list_mailboxes(currentRow.id, true);
|
||||
if (!cancelled) {
|
||||
setMailboxes(data);
|
||||
const allIds = data.map(mailbox => String(mailbox.id));
|
||||
setAllIds(allIds);
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return buildTree(mailboxes, undefined, true, true);
|
||||
}, [mailboxes]);
|
||||
const tree = buildTree(data);
|
||||
setTreeData(tree);
|
||||
const itemsWithChildren = getParentIds(tree);
|
||||
setItemsWithChildren(itemsWithChildren);
|
||||
setExpandedItems(itemsWithChildren);
|
||||
const sync_folders = data
|
||||
.filter(mailbox => currentRow.sync_folders.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
setSelectedItems(sync_folders);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const resData = err.response?.data;
|
||||
if (resData) {
|
||||
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMailboxes();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentRow, open]);
|
||||
|
||||
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
|
||||
const allMailboxes = mailboxes || [];
|
||||
const selected = selectedItems
|
||||
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const allMailSelected = selected.some(selectedName => {
|
||||
const mailbox = allMailboxes.find(m => m.name === selectedName);
|
||||
if (!mailbox) return false;
|
||||
return mailbox.attributes.some(a => a.attr === 'All');
|
||||
|
||||
const handleExpandedItemsChange = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
itemIds: string[],
|
||||
) => {
|
||||
setExpandedItems(itemIds);
|
||||
};
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setExpandedItems((oldExpanded) =>
|
||||
oldExpanded.length === 0 ? itemsWithChildren : [],
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTreeItem = useMemo(() => {
|
||||
return React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
ref: React.Ref<HTMLLIElement>,
|
||||
) {
|
||||
const { id, itemId, label, disabled, children, ...other } = props;
|
||||
|
||||
const {
|
||||
getContextProviderProps,
|
||||
getRootProps,
|
||||
getContentProps,
|
||||
getIconContainerProps,
|
||||
getCheckboxProps,
|
||||
getLabelProps,
|
||||
getGroupTransitionProps,
|
||||
getDragAndDropOverlayProps,
|
||||
status,
|
||||
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
||||
|
||||
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
||||
|
||||
return (
|
||||
<TreeItemProvider {...getContextProviderProps()}>
|
||||
<TreeItemRoot {...getRootProps(other)}>
|
||||
<TreeItemContent {...getContentProps()}>
|
||||
<TreeItemIconContainer {...getIconContainerProps()}>
|
||||
<TreeItemIcon status={status} />
|
||||
</TreeItemIconContainer>
|
||||
<TreeItemCheckbox {...getCheckboxProps()} />
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
/>
|
||||
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
||||
</TreeItemContent>
|
||||
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
||||
</TreeItemRoot>
|
||||
</TreeItemProvider>
|
||||
);
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
if (allMailSelected) {
|
||||
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const selectedSet = new Set(allIds);
|
||||
|
||||
const selectedAllMailbox = mailboxes.find((mb) =>
|
||||
selectedSet.has(String(mb.id)) &&
|
||||
mb.attributes?.some(a => a.attr === 'All')
|
||||
);
|
||||
|
||||
if (selectedAllMailbox) {
|
||||
toast({
|
||||
title: t('accounts.allMailFolderSelected'),
|
||||
description: t('accounts.allMailFolderSelectedDesc'),
|
||||
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
|
||||
});
|
||||
}
|
||||
setSelectedFolders(selected);
|
||||
}, [mailboxes]);
|
||||
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!mailboxes) return;
|
||||
const validFolderNames = mailboxes
|
||||
.filter(mailbox => {
|
||||
const isAllMail = mailbox.attributes.some(a => a.attr === 'All');
|
||||
if (isAllMail) return false;
|
||||
return true;
|
||||
})
|
||||
.map(m => m.name);
|
||||
setSelectedFolders(validFolderNames);
|
||||
if (validFolderNames.length < mailboxes.length) {
|
||||
toast({
|
||||
description: t('accounts.allMailSkipped'),
|
||||
});
|
||||
}
|
||||
}, [mailboxes]);
|
||||
setSelectedItems(allIds);
|
||||
}, [allIds]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedFolders([]);
|
||||
setSelectedItems([]);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -149,8 +328,30 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const handleSelectedItemsChange = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
newSelectedItems: string[],
|
||||
) => {
|
||||
const selectedSet = new Set(newSelectedItems);
|
||||
|
||||
const selectedAllMailbox = mailboxes.find((mb) =>
|
||||
selectedSet.has(String(mb.id)) &&
|
||||
mb.attributes?.some(a => a.attr === 'All')
|
||||
);
|
||||
|
||||
if (selectedAllMailbox) {
|
||||
toast({
|
||||
title: t('accounts.allMailFolderSelected'),
|
||||
description: t('accounts.allMailFolderSelectedDesc'),
|
||||
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedItems(newSelectedItems);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedFolders.length === 0) {
|
||||
if (selectedItems.length === 0) {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('accounts.selectAtLeastOneFolder'),
|
||||
@@ -159,14 +360,23 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
|
||||
const selectedNames: string[] = [];
|
||||
const idSet = new Set(selectedItems);
|
||||
for (const mailbox of mailboxes) {
|
||||
if (idSet.has(String(mailbox.id))) {
|
||||
selectedNames.push(mailbox.name);
|
||||
}
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
sync_folders: selectedFolders,
|
||||
sync_folders: selectedNames,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -175,13 +385,13 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col pt-2 gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={isLoading || !mailboxes || mailboxes.length === 0}
|
||||
disabled={isLoading || !allIds || allIds.length === 0}
|
||||
className="h-8"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-2" />
|
||||
@@ -191,23 +401,62 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={isLoading || selectedFolders.length === 0}
|
||||
disabled={isLoading || allIds.length === 0}
|
||||
className="h-8"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{t('common.deselectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectionPropagation(prev => ({
|
||||
...prev,
|
||||
descendants: !prev.descendants,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="h-8"
|
||||
>
|
||||
{selectionPropagation.descendants ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
|
||||
{t('accounts.folderSync.autoSelectDescendants')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectionPropagation(prev => ({
|
||||
...prev,
|
||||
parents: !prev.parents,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="h-8"
|
||||
>
|
||||
{selectionPropagation.parents ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
|
||||
{t('accounts.folderSync.autoSelectParents')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExpandClick}
|
||||
disabled={isLoading}>
|
||||
{expandedItems.length === 0 ? t('accounts.folderSync.expandAll') : t('accounts.folderSync.collapseAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accounts.foldersSelected', { count: selectedFolders.length })}
|
||||
{t('accounts.foldersSelected', { count: selectedItems.length })}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[30rem] w-full pr-4 -mr-4 py-1">
|
||||
|
||||
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
|
||||
{isLoading && (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm font-medium">Loading mailbox folders…</span>
|
||||
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -218,16 +467,23 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<TreeView
|
||||
key={selectedFolders.length > 0 ? `tree-${selectedFolders.length}-${selectedFolders[0]}` : 'tree-empty'}
|
||||
data={treeData}
|
||||
multiple
|
||||
expandAll
|
||||
clickRowToSelect={false}
|
||||
initialSelectedItemIds={initialSelectedItemIds}
|
||||
onSelectItemsChange={handleSelectItems}
|
||||
<RichTreeView
|
||||
multiSelect
|
||||
checkboxSelection
|
||||
items={treeData}
|
||||
expandedItems={expandedItems}
|
||||
onExpandedItemsChange={handleExpandedItemsChange}
|
||||
selectionPropagation={selectionPropagation}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={handleSelectedItemsChange}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-auto p-2 text-red-600 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -237,14 +493,14 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isLoading}
|
||||
disabled={isSubmitting || isLoading || !!error}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
//
|
||||
// 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
|
||||
// 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/>.
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -22,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
|
||||
import { Mail, HardDrive, Database, Users, Inbox } from 'lucide-react';
|
||||
import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react';
|
||||
import { formatBytes, formatNumber } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_dashboard_stats, TimeBucket } from '@/api/system/api';
|
||||
@@ -35,18 +35,31 @@ interface DailyActivity {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function convertRecentActivity(timeBuckets: TimeBucket[]): DailyActivity[] {
|
||||
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return timeBuckets.map(bucket => {
|
||||
const date = new Date(bucket.timestamp_ms);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return {
|
||||
date: `${mm}-${dd}`,
|
||||
date: dateFormatter.format(date),
|
||||
count: bucket.count,
|
||||
timestamp_ms: bucket.timestamp_ms,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const formatTooltipDate = (timestamp_ms: number, locale: string): string => {
|
||||
const date = new Date(timestamp_ms);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
// Skeleton Components
|
||||
@@ -94,7 +107,10 @@ export default function MailArchiveDashboard() {
|
||||
queryFn: get_dashboard_stats,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language;
|
||||
|
||||
const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0);
|
||||
const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0;
|
||||
|
||||
@@ -123,8 +139,8 @@ export default function MailArchiveDashboard() {
|
||||
<Skeleton className="h-7 w-28 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<MetricCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
@@ -156,8 +172,8 @@ export default function MailArchiveDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.mailAccounts')}</CardTitle>
|
||||
@@ -212,6 +228,29 @@ export default function MailArchiveDashboard() {
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.tantivyIndex')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.systemVersion')}</CardTitle>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>
|
||||
{stats!.system_version ? (
|
||||
<a
|
||||
href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{stats!.system_version}
|
||||
</a>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats!.commit_hash ?? 'N/A'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="trend" className="space-y-4">
|
||||
@@ -230,12 +269,25 @@ export default function MailArchiveDashboard() {
|
||||
<CardContent className="h-80">
|
||||
{hasRecentActivity ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => formatNumber(v as number)}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const dataPoint = payload[0].payload;
|
||||
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
|
||||
return (
|
||||
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
|
||||
<p className="font-semibold text-sm mb-1">{fullDate}</p>
|
||||
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
@@ -406,6 +458,11 @@ export default function MailArchiveDashboard() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer / Copyright - New Addition */}
|
||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
||||
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
@@ -35,7 +36,7 @@ export function AccountSwitcher({
|
||||
}: AccountSwitcherProps) {
|
||||
const { accountsOptions, isLoading } = useMinimalAccountList();
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
@@ -47,7 +48,7 @@ export function AccountSwitcher({
|
||||
options={accountsOptions}
|
||||
defaultValue={`${defaultAccountId}`}
|
||||
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
|
||||
placeholder="Select an account"
|
||||
placeholder={t('oauth2.selectAnAccount')}
|
||||
noItemsComponent={<div className='space-y-2'>
|
||||
<p>No active email account.</p>
|
||||
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -27,6 +27,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -37,8 +38,9 @@ export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setDeleteIds(new Set([envelope.id]))
|
||||
@@ -60,7 +62,6 @@ export function MailList({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const hasSelected = (mailId: number) => {
|
||||
return selected.has(mailId);
|
||||
}
|
||||
@@ -77,15 +78,14 @@ export function MailList({
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1 max-w-xs" />
|
||||
<Skeleton className="h-3 w-16 ml-auto" />
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1 max-w-xs" />
|
||||
<Skeleton className="h-2.5 w-12 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ export function MailList({
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
selected.size === items.length && items.length > 0
|
||||
@@ -123,7 +123,7 @@ export function MailList({
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
|
||||
"group flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
@@ -140,12 +140,11 @@ export function MailList({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
|
||||
{/* LEFT AREA: From + Subject + Tags */}
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
@@ -156,7 +155,7 @@ export function MailList({
|
||||
</h3>
|
||||
|
||||
{/* TAGS BELOW SUBJECT */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
@@ -169,10 +168,10 @@ export function MailList({
|
||||
</div>
|
||||
|
||||
{/* RIGHT AREA – actions & meta */}
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{item.attachments?.length}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -180,7 +179,7 @@ export function MailList({
|
||||
<span className={cn(
|
||||
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@@ -190,7 +189,7 @@ export function MailList({
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,4 +199,4 @@ export function MailList({
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,9 @@ import {
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { AccountSwitcher } from "./account-switcher"
|
||||
import { TreeView } from "@/components/tree-view"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { buildTree } from "../../../lib/build-tree"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import MailboxProvider, { MailboxDialogType } from "../context"
|
||||
import useDialogState from "@/hooks/use-dialog-state"
|
||||
@@ -44,6 +42,14 @@ import { EnvelopeDeleteDialog } from "./delete-dialog"
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { EnvelopeListPagination } from "@/components/pagination"
|
||||
import { RichTreeView, TreeItemCheckbox, TreeItemContent, TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemIconContainer, TreeItemLabel, TreeItemProvider, TreeItemRoot, useTreeItem, useTreeItemModel, UseTreeItemParameters } from "@mui/x-tree-view"
|
||||
import { buildTree, ExtendedTreeItemProps } from "@/lib/build-tree"
|
||||
import { useTheme } from "@/context/theme-context"
|
||||
import { styled } from "@mui/material/styles"
|
||||
import { animated, useSpring } from "@react-spring/web"
|
||||
import { TransitionProps } from "@mui/material/transitions"
|
||||
import Collapse from "@mui/material/Collapse"
|
||||
import { FolderIcon } from "lucide-react"
|
||||
|
||||
|
||||
interface MailProps {
|
||||
@@ -73,6 +79,83 @@ const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessages
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2"/>
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
{/* <div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
|
||||
{attributes?.map((attr) => {
|
||||
const text =
|
||||
attr.attr === 'Extension'
|
||||
? attr.extension
|
||||
: attr.attr;
|
||||
|
||||
return (
|
||||
<span key={attr.attr} className="text-inherit">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
)} */}
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomCollapse = styled(Collapse)({
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
const AnimatedCollapse = animated(CustomCollapse);
|
||||
|
||||
function TransitionComponent(props: TransitionProps) {
|
||||
const style = useSpring({
|
||||
to: {
|
||||
opacity: props.in ? 1 : 0,
|
||||
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
||||
},
|
||||
});
|
||||
|
||||
return <AnimatedCollapse style={style} {...props} />;
|
||||
}
|
||||
|
||||
interface CustomTreeItemProps
|
||||
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
||||
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
||||
|
||||
|
||||
|
||||
export function Mail({
|
||||
defaultLayout = [20, 80],
|
||||
defaultCollapsed = false,
|
||||
@@ -88,6 +171,7 @@ export function Mail({
|
||||
const [pageSize, setPageSize] = React.useState(30);
|
||||
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
|
||||
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
queryKey: ['account-mailboxes', `${selectedAccountId}`],
|
||||
@@ -95,6 +179,9 @@ export function Mail({
|
||||
enabled: !!selectedAccountId,
|
||||
})
|
||||
|
||||
|
||||
const tree = buildTree(mailboxes ?? []);
|
||||
|
||||
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: selectedMailbox?.id,
|
||||
@@ -126,6 +213,65 @@ export function Mail({
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
const handleItemSelectionToggle = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
itemId: string,
|
||||
isSelected: boolean,
|
||||
) => {
|
||||
if (isSelected) {
|
||||
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
|
||||
const CustomTreeItem = React.useMemo(() => {
|
||||
return React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
ref: React.Ref<HTMLLIElement>,
|
||||
) {
|
||||
const { id, itemId, label, disabled, children, ...other } = props;
|
||||
|
||||
const {
|
||||
getContextProviderProps,
|
||||
getRootProps,
|
||||
getContentProps,
|
||||
getIconContainerProps,
|
||||
getCheckboxProps,
|
||||
getLabelProps,
|
||||
getGroupTransitionProps,
|
||||
getDragAndDropOverlayProps,
|
||||
status,
|
||||
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
||||
|
||||
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
||||
|
||||
return (
|
||||
<TreeItemProvider {...getContextProviderProps()}>
|
||||
<TreeItemRoot {...getRootProps(other)}>
|
||||
<TreeItemContent {...getContentProps()}>
|
||||
<TreeItemIconContainer {...getIconContainerProps()}>
|
||||
<TreeItemIcon status={status} />
|
||||
</TreeItemIconContainer>
|
||||
<TreeItemCheckbox {...getCheckboxProps()} />
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
/>
|
||||
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
||||
</TreeItemContent>
|
||||
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
||||
</TreeItemRoot>
|
||||
</TreeItemProvider>
|
||||
);
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<MailboxProvider value={{ open, setOpen, currentMailbox: selectedMailbox, selectedAccountId, setCurrentMailbox: setSelectedMailbox, currentEnvelope: selectedEvelope, setCurrentEnvelope: setSelectedEvelope, deleteIds, setDeleteIds, selected, setSelected }}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -184,17 +330,23 @@ export function Mail({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TreeView
|
||||
data={buildTree(mailboxes ?? [])}
|
||||
clickRowToSelect={true}
|
||||
onSelectChange={(item) => {
|
||||
if (item) {
|
||||
setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
|
||||
setPage(0);
|
||||
} else {
|
||||
setSelectedMailbox(undefined)
|
||||
}
|
||||
}}
|
||||
// <TreeView
|
||||
// data={buildTree(mailboxes ?? [])}
|
||||
// clickRowToSelect={true}
|
||||
// onSelectChange={(item) => {
|
||||
// if (item) {
|
||||
// setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
|
||||
// setPage(0);
|
||||
// } else {
|
||||
// setSelectedMailbox(undefined)
|
||||
// }
|
||||
// }}
|
||||
// />
|
||||
<RichTreeView
|
||||
//checkboxSelection
|
||||
items={tree}
|
||||
onItemSelectionToggle={handleItemSelectionToggle}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox" // shadcn Checkbox
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
@@ -29,6 +29,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -41,7 +42,9 @@ export function MailList({
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
|
||||
|
||||
const handleToggleAll = () => {
|
||||
@@ -62,6 +65,7 @@ export function MailList({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -115,11 +119,11 @@ export function MailList({
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<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>
|
||||
@@ -129,7 +133,7 @@ export function MailList({
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-muted/30">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
@@ -158,7 +162,7 @@ export function MailList({
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
|
||||
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelectedRow && "bg-accent"
|
||||
)}
|
||||
@@ -175,41 +179,35 @@ export function MailList({
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<MailIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
|
||||
{/* LEFT AREA: From + Subject + Tags */}
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
|
||||
|
||||
{/* from + subject (large screen side by side, small screen subject hidden) */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
|
||||
{/* subject on large screens */}
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* subject on small screens */}
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
{/* TAGS (always below on small screen, inline on large screen) */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{item.tags?.map((tag, index) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={index}>{tag}</Badge>
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT AREA – actions & meta */}
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{item.attachments?.length}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -217,7 +215,7 @@ export function MailList({
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
|
||||
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -225,14 +223,14 @@ export function MailList({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 p-0 hover:bg-muted rounded-md"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
@@ -241,7 +239,7 @@ export function MailList({
|
||||
setOpen("edit-tags");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-4 w-4" />
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('search.editTag')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -253,7 +251,7 @@ export function MailList({
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -263,9 +261,7 @@ export function MailList({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && (
|
||||
<MailBulkActions />
|
||||
)}
|
||||
{totalSelected > 0 && <MailBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,100 +18,90 @@
|
||||
|
||||
|
||||
import { MailboxData } from "@/api/mailbox/api";
|
||||
import { TreeDataItem } from "@/components/tree-view";
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { FolderClosed, FolderOpen } from "lucide-react";
|
||||
import React from 'react';
|
||||
import { TreeViewBaseItem } from '@mui/x-tree-view/models';
|
||||
|
||||
type BadgeContentFunction = (item: MailboxData) => React.ReactNode;
|
||||
|
||||
export const buildTree = (data: MailboxData[], badgeContent?: BadgeContentFunction, showAttributes?: boolean, showExists?: boolean): TreeDataItem[] => {
|
||||
const root: TreeDataItem = {
|
||||
id: 'root',
|
||||
name: 'Root',
|
||||
icon: FolderClosed,
|
||||
openIcon: FolderOpen,
|
||||
children: [], // Ensure children is initialized as an array
|
||||
};
|
||||
export type ExtendedTreeItemProps = {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const nodeMap: { [key: string]: TreeDataItem } = {};
|
||||
data.sort((a, b) => a.name.localeCompare(b.name));
|
||||
data.forEach((item) => {
|
||||
const { id, name, delimiter, exists, attributes } = item;
|
||||
const badge = showExists ? (badgeContent ? badgeContent(item) : React.createElement(Badge, {
|
||||
className: 'text-[12px]',
|
||||
variant: 'secondary',
|
||||
}, exists)) : null;
|
||||
|
||||
const attributesNode = showAttributes
|
||||
? attributes.map((item, index) =>
|
||||
React.createElement(
|
||||
Badge,
|
||||
{
|
||||
key: index,
|
||||
className: 'text-[12px] mr-1 last:mr-0',
|
||||
variant: 'secondary',
|
||||
},
|
||||
item.attr === 'Extension' ? item.extension || '' : item.attr
|
||||
)
|
||||
)
|
||||
: null;
|
||||
// const badge = badgeContent || React.createElement(Badge, {
|
||||
// className: 'text-xs',
|
||||
// }, exists);
|
||||
// If there is no delimiter, add the item directly as a child of the root
|
||||
if (!delimiter) {
|
||||
root.children!.push({
|
||||
id: id.toString(),
|
||||
name,
|
||||
icon: FolderClosed,
|
||||
openIcon: FolderOpen,
|
||||
badge,
|
||||
attributes: showAttributes ? attributesNode : null,
|
||||
children: undefined, // Leaf node, so children is undefined
|
||||
});
|
||||
return;
|
||||
export function buildTree(items: MailboxData[]): TreeViewBaseItem<ExtendedTreeItemProps>[] {
|
||||
const nodeByName = new Map<string, TreeViewBaseItem<ExtendedTreeItemProps>>();
|
||||
|
||||
for (const mb of items) {
|
||||
if (!mb.name) continue;
|
||||
|
||||
const delimiter = mb.delimiter ?? '/';
|
||||
const parts = mb.name.split(delimiter);
|
||||
|
||||
let currentFullName = '';
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
currentFullName = currentFullName ? `${currentFullName}${delimiter}${part}` : part;
|
||||
|
||||
if (!nodeByName.has(currentFullName)) {
|
||||
nodeByName.set(currentFullName, {
|
||||
id: currentFullName,
|
||||
label: part,
|
||||
exists: mb.exists,
|
||||
attributes: mb.attributes,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (i === parts.length - 1) {
|
||||
const node = nodeByName.get(currentFullName)!;
|
||||
node.id = String(mb.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roots: TreeViewBaseItem<ExtendedTreeItemProps>[] = [];
|
||||
for (const [fullName, node] of nodeByName.entries()) {
|
||||
const delim = mbDelimiterOrDefault(fullName, items);
|
||||
const lastDelimIndex = fullName.lastIndexOf(delim);
|
||||
|
||||
if (lastDelimIndex === -1) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split the name into parts based on the delimiter
|
||||
const parts = name.split(delimiter); // dir/sub1/sub2
|
||||
let currentParent = root;
|
||||
const parentFullName = fullName.substring(0, lastDelimIndex);
|
||||
const parentNode = nodeByName.get(parentFullName);
|
||||
|
||||
// Traverse or create nodes for each part of the path
|
||||
parts.forEach((part, index) => {
|
||||
const path = parts.slice(0, index + 1).join(delimiter);
|
||||
|
||||
// If the node already exists, set it as the current parent
|
||||
if (nodeMap[path]) {
|
||||
currentParent = nodeMap[path];
|
||||
} else {
|
||||
// Determine if this is a leaf node (last part of the path)
|
||||
const isLeaf = index === parts.length - 1;
|
||||
|
||||
// Create a new node
|
||||
const newNode: TreeDataItem = {
|
||||
id: isLeaf ? id.toString() : path, // Use item.id for leaf nodes, path for non-leaf nodes
|
||||
name: part,
|
||||
icon: FolderClosed,
|
||||
openIcon: FolderOpen,
|
||||
badge,
|
||||
attributes: showAttributes ? attributesNode : null,
|
||||
children: isLeaf ? undefined : [], // Ensure children is initialized as an array for non-leaf nodes
|
||||
};
|
||||
|
||||
// Ensure currentParent.children is initialized as an array
|
||||
if (!currentParent.children) {
|
||||
currentParent.children = [];
|
||||
}
|
||||
|
||||
// Add the new node to the current parent's children
|
||||
currentParent.children.push(newNode);
|
||||
currentParent = newNode; // Update the current parent to the new node
|
||||
nodeMap[path] = newNode; // Store the node in the map for quick lookup
|
||||
if (parentNode) {
|
||||
parentNode.children = parentNode.children ?? [];
|
||||
if (!parentNode.children.includes(node)) {
|
||||
parentNode.children.push(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the children of the root as the final tree structure
|
||||
return root.children!;
|
||||
};
|
||||
const sortNodes = (nodes: TreeViewBaseItem[]) => {
|
||||
nodes.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));
|
||||
for (const n of nodes) {
|
||||
if (n.children && n.children.length) sortNodes(n.children);
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueRoots = Array.from(new Set(roots));
|
||||
sortNodes(uniqueRoots);
|
||||
return uniqueRoots;
|
||||
}
|
||||
|
||||
function mbDelimiterOrDefault(fullName: string, items: MailboxData[]): string {
|
||||
const mb = items.find(it => it.name === fullName || fullName.startsWith(it.name + (it.delimiter ?? '/')));
|
||||
if (mb?.delimiter) return mb.delimiter;
|
||||
|
||||
const withDelim = items.find(it => it.delimiter);
|
||||
if (withDelim?.delimiter) return withDelim.delimiter;
|
||||
|
||||
return '.';
|
||||
}
|
||||
@@ -16,7 +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/>.
|
||||
|
||||
|
||||
import { enUS, zhCN, zhTW, arSA, de, es, fi, fr, it, ja, ko, nl, ptBR, ru, da, sv, nb, Locale } from 'date-fns/locale';
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -70,8 +70,12 @@ export function mapToRecordOfArrays(
|
||||
);
|
||||
}
|
||||
|
||||
export function formatNumber(num: number) {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
export function formatNumber(num: number): string {
|
||||
const userLocale = navigator.language;
|
||||
|
||||
return new Intl.NumberFormat(userLocale, {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,4 +130,45 @@ export function formatTimestamp(milliseconds: number): string {
|
||||
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
|
||||
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// i18n.language -> date-fns locale
|
||||
export const dateFnsLocaleMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'en-us': enUS,
|
||||
zh: zhCN,
|
||||
'zh-cn': zhCN,
|
||||
'zh-tw': zhTW,
|
||||
'zh_hk': zhTW,
|
||||
ar: arSA,
|
||||
'ar-sa': arSA,
|
||||
de: de,
|
||||
'de-de': de,
|
||||
es: es,
|
||||
'es-es': es,
|
||||
fi: fi,
|
||||
'fi-fi': fi,
|
||||
fr: fr,
|
||||
'fr-fr': fr,
|
||||
it: it,
|
||||
'it-it': it,
|
||||
jp: ja,
|
||||
ja: ja,
|
||||
'ja-jp': ja,
|
||||
ko: ko,
|
||||
'ko-kr': ko,
|
||||
nl: nl,
|
||||
'nl-nl': nl,
|
||||
pt: ptBR,
|
||||
'pt-br': ptBR,
|
||||
ru: ru,
|
||||
'ru-ru': ru,
|
||||
da: da,
|
||||
'da-dk': da,
|
||||
sv: sv,
|
||||
'sv-se': sv,
|
||||
no: nb,
|
||||
'no-no': nb,
|
||||
};
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "لا يوجد نشاط حديث",
|
||||
"noSendersData": "لا توجد بيانات للمرسلين",
|
||||
"noLargeEmails": "لا توجد رسائل بريد إلكتروني كبيرة",
|
||||
"noAccountData": "لا توجد بيانات للحساب"
|
||||
"noAccountData": "لا توجد بيانات للحساب",
|
||||
"systemVersion": "إصدار النظام"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "حسابات البريد الإلكتروني",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "لا توجد تكوينات للحساب",
|
||||
"noAccountConfigurationsDesc": "لم تقم بإضافة أي تكوينات للحساب بعد. أضف واحدة لبدء استخدام ميزات الحساب.",
|
||||
"addConfiguration": "إضافة تكوين",
|
||||
"name": "الاسم",
|
||||
"name": "اسم الدخول",
|
||||
"email": "البريد الإلكتروني",
|
||||
"status": "الحالة",
|
||||
"type": "النوع",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "مثال: john.doe@example.com",
|
||||
"namePlaceholder": "مثال: john.doe",
|
||||
"optional": "اختياري",
|
||||
"nameDescription": "اسم مستخدم اتصال IMAP. اترك هذا الحقل فارغًا إذا كنت تستخدم عنوان بريدك الإلكتروني الكامل كاسم مستخدم للاتصال.",
|
||||
"emailCannotBeModified": "لا يمكن تعديل عنوان حساب البريد الإلكتروني أثناء التحرير.",
|
||||
"addAccount": "إضافة حساب",
|
||||
"updateAccount": "تحديث الحساب",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "تحديث حساب البريد الإلكتروني هنا. ",
|
||||
"addNewEmailAccountHere": "إضافة حساب بريد إلكتروني جديد هنا. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "يستخدم هذا الحساب لأغراض التعريف فقط ولا يتطلب المزامنة مع خادم بريد إلكتروني. إنه يساعد في استيراد بيانات البريد الإلكتروني.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "يحدد ما إذا كان هذا الحساب نشطًا. إذا تم تعطيله، فلن يتمكن الحساب من استيراد البيانات أو إجراء استعلامات.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "يحدد ما إذا كان هذا الحساب نشطًا. إذا تم تعطيله، فلن يكون قادرًا على استيراد البيانات.",
|
||||
"folderSync": {
|
||||
"noData": "لا توجد بيانات",
|
||||
"noFolders": "لا توجد مجلدات للمزامنة",
|
||||
"batches": "دفعات"
|
||||
"batches": "دفعات",
|
||||
"autoSelectDescendants": "تحديد العناصر الفرعية تلقائيًا",
|
||||
"autoSelectParents": "تحديد العناصر الأصلية تلقائيًا",
|
||||
"expandAll": "توسيع الكل",
|
||||
"collapseAll": "طي الكل",
|
||||
"loadingMailboxFolders": "جارٍ تحميل مجلدات البريد…"
|
||||
},
|
||||
"viewDetails": "عرض التفاصيل",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Ingen nylig aktivitet",
|
||||
"noSendersData": "Ingen afsenderdata",
|
||||
"noLargeEmails": "Ingen store e-mails",
|
||||
"noAccountData": "Ingen kontodata"
|
||||
"noAccountData": "Ingen kontodata",
|
||||
"systemVersion": "Systemversion"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "E-mailkonti",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ingen kontokonfigurationer",
|
||||
"noAccountConfigurationsDesc": "Du har ikke tilføjet nogen kontokonfigurationer endnu. Tilføj en for at begynde at bruge kontofunktioner.",
|
||||
"addConfiguration": "Tilføj konfiguration",
|
||||
"name": "Navn",
|
||||
"name": "Logindnavn",
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "f.eks. hans.hansen@eksempel.dk",
|
||||
"namePlaceholder": "f.eks. Hans Hansen",
|
||||
"optional": "Valgfri",
|
||||
"nameDescription": "IMAP-forbindelsesbrugernavn. Lad dette felt være tomt, hvis du bruger din fulde e-mailadresse som forbindelsesbrugernavn.",
|
||||
"emailCannotBeModified": "Kontoens e-mailadresse kan ikke ændres under redigering.",
|
||||
"addAccount": "Tilføj konto",
|
||||
"updateAccount": "Opdater konto",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Opdater e-mailkontoen her. ",
|
||||
"addNewEmailAccountHere": "Tilføj ny e-mailkonto her. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Denne konto bruges kun til identifikationsformål og kræver ikke synkronisering med en e-mailserver. Den hjælper med import af e-mail-data.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer, om denne konto er aktiv. Hvis deaktiveret, vil kontoen ikke kunne importere data eller udføre søgninger.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer, om denne konto er aktiv. Hvis den er deaktiveret, kan data ikke importeres.",
|
||||
"folderSync": {
|
||||
"noData": "Ingen data",
|
||||
"noFolders": "Ingen mapper at synkronisere",
|
||||
"batches": "batcher"
|
||||
"batches": "batcher",
|
||||
"autoSelectDescendants": "Vælg efterkommere automatisk",
|
||||
"autoSelectParents": "Vælg forældre automatisk",
|
||||
"expandAll": "Udvid alle",
|
||||
"collapseAll": "Skjul alle",
|
||||
"loadingMailboxFolders": "Indlæser postkassemapper…"
|
||||
},
|
||||
"viewDetails": "vis detaljer",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Keine kürzliche Aktivität",
|
||||
"noSendersData": "Keine Absenderdaten",
|
||||
"noLargeEmails": "Keine großen E-Mails",
|
||||
"noAccountData": "Keine Kontodaten"
|
||||
"noAccountData": "Keine Kontodaten",
|
||||
"systemVersion": "Systemversion"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "E-Mail-Konten",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Keine Kontokonfigurationen",
|
||||
"noAccountConfigurationsDesc": "Sie haben noch keine Kontokonfigurationen hinzugefügt. Fügen Sie eine hinzu, um mit der Nutzung der Kontofunktionen zu beginnen.",
|
||||
"addConfiguration": "Konfiguration hinzufügen",
|
||||
"name": "Name",
|
||||
"name": "Anmeldename",
|
||||
"email": "E-Mail",
|
||||
"status": "Status",
|
||||
"type": "Typ",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "z.B. max.mustermann@beispiel.de",
|
||||
"namePlaceholder": "z.B. Max Mustermann",
|
||||
"optional": "Optional",
|
||||
"nameDescription": "IMAP-Verbindungsbenutzername. Lassen Sie dieses Feld leer, wenn Sie Ihre vollständige E-Mail-Adresse als Verbindungsbenutzernamen verwenden.",
|
||||
"emailCannotBeModified": "Die E-Mail-Adresse des Kontos kann während der Bearbeitung nicht geändert werden.",
|
||||
"addAccount": "Konto hinzufügen",
|
||||
"updateAccount": "Konto aktualisieren",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Aktualisieren Sie das E-Mail-Konto hier. ",
|
||||
"addNewEmailAccountHere": "Fügen Sie hier ein neues E-Mail-Konto hinzu. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Dieses Konto dient nur zur Identifizierung und erfordert keine Synchronisierung mit dem Mailserver. Es hilft beim Import von E-Mail-Daten.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bestimmt, ob dieses Konto aktiv ist. Wenn es deaktiviert ist, kann das Konto keine Daten importieren oder Abfragen ausführen.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Legt fest, ob dieses Konto aktiv ist. Wenn es deaktiviert ist, können keine Daten importiert werden.",
|
||||
"folderSync": {
|
||||
"noData": "Keine Daten",
|
||||
"noFolders": "Keine Ordner zum Synchronisieren",
|
||||
"batches": "Batches"
|
||||
"batches": "Batches",
|
||||
"autoSelectDescendants": "Unterelemente automatisch auswählen",
|
||||
"autoSelectParents": "Elternelemente automatisch auswählen",
|
||||
"expandAll": "Alle erweitern",
|
||||
"collapseAll": "Alle reduzieren",
|
||||
"loadingMailboxFolders": "Postfachordner werden geladen…"
|
||||
},
|
||||
"viewDetails": "Details anzeigen",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "No recent activity",
|
||||
"noSendersData": "No senders data",
|
||||
"noLargeEmails": "No large emails",
|
||||
"noAccountData": "No account data"
|
||||
"noAccountData": "No account data",
|
||||
"systemVersion": "System Version"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Email Accounts",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "No Account Configurations",
|
||||
"noAccountConfigurationsDesc": "You haven't added any Account configurations yet. Add one to start using Account features.",
|
||||
"addConfiguration": "Add Configuration",
|
||||
"name": "Name",
|
||||
"name": "Login Name",
|
||||
"email": "Email",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "e.g john.doe@example.com",
|
||||
"namePlaceholder": "e.g john.doe",
|
||||
"optional": "Optional",
|
||||
"nameDescription": "IMAP Connection Username. Leave this field blank if you use your full email address as the connection username.",
|
||||
"emailCannotBeModified": "The email account address cannot be modified when editing.",
|
||||
"addAccount": "Add Account",
|
||||
"updateAccount": "Update Account",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Update the email account here. ",
|
||||
"addNewEmailAccountHere": "Add new email account here. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determines whether this account is active. If disabled, the account will not be able to import data.",
|
||||
"folderSync": {
|
||||
"noData": "No Data",
|
||||
"noFolders": "No folders to sync",
|
||||
"batches": "batches"
|
||||
"batches": "batches",
|
||||
"autoSelectDescendants": "Auto select descendants",
|
||||
"autoSelectParents": "Auto select parents",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all",
|
||||
"loadingMailboxFolders": "Loading mailbox folders…"
|
||||
},
|
||||
"viewDetails": "view details",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Sin actividad reciente",
|
||||
"noSendersData": "Sin datos de remitentes",
|
||||
"noLargeEmails": "Sin correos grandes",
|
||||
"noAccountData": "Sin datos de cuenta"
|
||||
"noAccountData": "Sin datos de cuenta",
|
||||
"systemVersion": "Versión del sistema"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Cuentas de correo",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Sin configuraciones de cuenta",
|
||||
"noAccountConfigurationsDesc": "Aún no has añadido ninguna configuración de cuenta. Añade una para empezar a usar las funcionalidades de la cuenta.",
|
||||
"addConfiguration": "Añadir configuración",
|
||||
"name": "Nombre",
|
||||
"name": "Nombre de usuario",
|
||||
"email": "Correo electrónico",
|
||||
"status": "Estado",
|
||||
"type": "Tipo",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "ej. juan.perez@ejemplo.com",
|
||||
"namePlaceholder": "ej. Juan Pérez",
|
||||
"optional": "Opcional",
|
||||
"nameDescription": "Nombre de usuario de conexión IMAP. Deje este campo en blanco si utiliza su dirección de correo electrónico completa como nombre de usuario de conexión.",
|
||||
"emailCannotBeModified": "La dirección de correo electrónico de la cuenta no se puede modificar durante la edición.",
|
||||
"addAccount": "Añadir cuenta",
|
||||
"updateAccount": "Actualizar cuenta",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Actualiza la cuenta de correo electrónico aquí. ",
|
||||
"addNewEmailAccountHere": "Añade una nueva cuenta de correo electrónico aquí. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Esta cuenta se utiliza solo para fines de identificación y no requiere sincronización con el servidor de correo. Ayuda a importar datos de correo electrónico.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina si esta cuenta está activa. Si está deshabilitada, la cuenta no podrá importar datos ni ejecutar consultas.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina si esta cuenta está activa. Si está desactivada, no podrá importar datos.",
|
||||
"folderSync": {
|
||||
"noData": "Sin datos",
|
||||
"noFolders": "No hay carpetas para sincronizar",
|
||||
"batches": "lotes"
|
||||
"batches": "lotes",
|
||||
"autoSelectDescendants": "Seleccionar automáticamente los descendientes",
|
||||
"autoSelectParents": "Seleccionar automáticamente los padres",
|
||||
"expandAll": "Expandir todo",
|
||||
"collapseAll": "Contraer todo",
|
||||
"loadingMailboxFolders": "Cargando carpetas del buzón…"
|
||||
},
|
||||
"viewDetails": "ver detalles",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Ei viimeaikaisia tapahtumia",
|
||||
"noSendersData": "Ei lähettäjätietoja",
|
||||
"noLargeEmails": "Ei suuria sähköposteja",
|
||||
"noAccountData": "Ei tilitietoja"
|
||||
"noAccountData": "Ei tilitietoja",
|
||||
"systemVersion": "Järjestelmäversio"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Sähköpostitilit",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ei tilimäärityksiä",
|
||||
"noAccountConfigurationsDesc": "Et ole vielä lisännyt tilimäärityksiä. Lisää yksi aloittaaksesi tilitoimintojen käytön.",
|
||||
"addConfiguration": "Lisää määritys",
|
||||
"name": "Nimi",
|
||||
"name": "Kirjautumisnimi",
|
||||
"email": "Sähköposti",
|
||||
"status": "Tila",
|
||||
"type": "Tyyppi",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "esim. matti.meikäläinen@esimerkki.fi",
|
||||
"namePlaceholder": "esim. matti.meikäläinen",
|
||||
"optional": "Valinnainen",
|
||||
"nameDescription": "IMAP-yhteyden käyttäjänimi. Jätä tämä kenttä tyhjäksi, jos käytät koko sähköpostiosoitettasi yhteyden käyttäjänimenä.",
|
||||
"emailCannotBeModified": "Tilin sähköpostiosoitetta ei voi muokata muokkauksen aikana.",
|
||||
"addAccount": "Lisää tili",
|
||||
"updateAccount": "Päivitä tili",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Päivitä sähköpostitili täällä. ",
|
||||
"addNewEmailAccountHere": "Lisää uusi sähköpostitili täällä. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Tätä tiliä käytetään vain tunnistamistarkoituksiin, eikä se vaadi synkronointia sähköpostipalvelimen kanssa. Auttaa sähköpostidatan tuomisessa.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Määrittää, onko tämä tili aktiivinen. Jos poistettu käytöstä, tili ei voi tuoda tietoja tai suorittaa kyselyjä.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Määrittää, onko tämä tili aktiivinen. Jos se on poistettu käytöstä, tietoja ei voi tuoda.",
|
||||
"folderSync": {
|
||||
"noData": "Ei tietoja",
|
||||
"noFolders": "Ei synkronoitavia kansioita",
|
||||
"batches": "erät"
|
||||
"batches": "erät",
|
||||
"autoSelectDescendants": "Valitse alisolmut automaattisesti",
|
||||
"autoSelectParents": "Valitse yläsolmut automaattisesti",
|
||||
"expandAll": "Laajenna kaikki",
|
||||
"collapseAll": "Kutista kaikki",
|
||||
"loadingMailboxFolders": "Ladataan postilaatikon kansioita…"
|
||||
},
|
||||
"viewDetails": "katso tiedot",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Aucune activité récente",
|
||||
"noSendersData": "Aucune donnée d'expéditeurs",
|
||||
"noLargeEmails": "Aucun gros e-mail",
|
||||
"noAccountData": "Aucune donnée de compte"
|
||||
"noAccountData": "Aucune donnée de compte",
|
||||
"systemVersion": "Version du système"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Comptes de Messagerie",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Aucune Configuration de Compte",
|
||||
"noAccountConfigurationsDesc": "Vous n'avez pas encore ajouté de Configuration de Compte. Veuillez en ajouter une pour commencer à utiliser les fonctionnalités du Compte.",
|
||||
"addConfiguration": "Ajouter Configuration",
|
||||
"name": "Nom",
|
||||
"name": "Nom de connexion",
|
||||
"email": "E-mail",
|
||||
"status": "Statut",
|
||||
"type": "Type",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "ex. jean.dupont@exemple.com",
|
||||
"namePlaceholder": "ex. jean.dupont",
|
||||
"optional": "Facultatif",
|
||||
"nameDescription": "Nom d'utilisateur de connexion IMAP. Laissez ce champ vide si vous utilisez votre adresse e-mail complète comme nom d'utilisateur de connexion.",
|
||||
"emailCannotBeModified": "L'adresse e-mail du compte ne peut pas être modifiée lors de l'édition.",
|
||||
"addAccount": "Ajouter un Compte",
|
||||
"updateAccount": "Mettre à jour le Compte",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Mettez à jour le compte e-mail ici. ",
|
||||
"addNewEmailAccountHere": "Ajoutez un nouveau compte e-mail ici. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Ce compte est utilisé uniquement à des fins d'identification et ne nécessite pas de synchronisation avec un serveur de messagerie. Il est utile pour l'importation de données d'e-mails.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Détermine si ce compte est actif. S'il est désactivé, l'importation de données ou l'exécution de requêtes ne sera pas possible pour ce compte.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Détermine si ce compte est actif. S’il est désactivé, il ne pourra pas importer de données.",
|
||||
"folderSync": {
|
||||
"noData": "Aucune Donnée",
|
||||
"noFolders": "Aucun dossier à synchroniser",
|
||||
"batches": "lots"
|
||||
"batches": "lots",
|
||||
"autoSelectDescendants": "Sélectionner automatiquement les descendants",
|
||||
"autoSelectParents": "Sélectionner automatiquement les parents",
|
||||
"expandAll": "Tout développer",
|
||||
"collapseAll": "Tout réduire",
|
||||
"loadingMailboxFolders": "Chargement des dossiers de la boîte…"
|
||||
},
|
||||
"viewDetails": "voir les détails",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Nessuna attività recente",
|
||||
"noSendersData": "Nessun dato sui mittenti",
|
||||
"noLargeEmails": "Nessuna email di grandi dimensioni",
|
||||
"noAccountData": "Nessun dato sull'account"
|
||||
"noAccountData": "Nessun dato sull'account",
|
||||
"systemVersion": "Versione del sistema"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Account Email",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Nessuna Configurazione Account",
|
||||
"noAccountConfigurationsDesc": "Non hai ancora aggiunto alcuna Configurazione Account. Aggiungine una per iniziare a usare le funzionalità Account.",
|
||||
"addConfiguration": "Aggiungi Configurazione",
|
||||
"name": "Nome",
|
||||
"name": "Nome di accesso",
|
||||
"email": "Email",
|
||||
"status": "Stato",
|
||||
"type": "Tipo",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "es. john.doe@esempio.com",
|
||||
"namePlaceholder": "es. john.doe",
|
||||
"optional": "Opzionale",
|
||||
"nameDescription": "Nome utente di connessione IMAP. Lasciare vuoto questo campo se si utilizza l'indirizzo email completo come nome utente di connessione.",
|
||||
"emailCannotBeModified": "L'indirizzo email dell'account non può essere modificato durante la modifica.",
|
||||
"addAccount": "Aggiungi Account",
|
||||
"updateAccount": "Aggiorna Account",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Aggiorna l'account email qui. ",
|
||||
"addNewEmailAccountHere": "Aggiungi un nuovo account email qui. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Questo account viene utilizzato solo a scopo di identificazione e non richiede la sincronizzazione con un server email. Aiuta nell'importazione dei dati email.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina se questo account è attivo. Se disabilitato, l'account non potrà importare dati o eseguire query.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina se questo account è attivo. Se disattivato, non sarà possibile importare dati.",
|
||||
"folderSync": {
|
||||
"noData": "Nessun Dato",
|
||||
"noFolders": "Nessuna cartella da sincronizzare",
|
||||
"batches": "lotti"
|
||||
"batches": "lotti",
|
||||
"autoSelectDescendants": "Seleziona automaticamente i discendenti",
|
||||
"autoSelectParents": "Seleziona automaticamente i genitori",
|
||||
"expandAll": "Espandi tutto",
|
||||
"collapseAll": "Comprimi tutto",
|
||||
"loadingMailboxFolders": "Caricamento delle cartelle della casella…"
|
||||
},
|
||||
"viewDetails": "visualizza dettagli",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "最近のアクティビティなし",
|
||||
"noSendersData": "送信者データなし",
|
||||
"noLargeEmails": "サイズの大きいメールなし",
|
||||
"noAccountData": "アカウントデータなし"
|
||||
"noAccountData": "アカウントデータなし",
|
||||
"systemVersion": "システムバージョン"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "メールアカウント",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "アカウント設定がありません",
|
||||
"noAccountConfigurationsDesc": "まだアカウント設定を追加していません。機能を利用するには設定を追加してください。",
|
||||
"addConfiguration": "設定を追加",
|
||||
"name": "名前",
|
||||
"name": "ログイン名",
|
||||
"email": "メールアドレス",
|
||||
"status": "ステータス",
|
||||
"type": "タイプ",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "例: john.doe@example.com",
|
||||
"namePlaceholder": "例: john.doe",
|
||||
"optional": "オプション",
|
||||
"nameDescription": "IMAP接続のユーザー名。接続ユーザー名として完全なメールアドレスを使用する場合は、このフィールドを空欄にしてください。",
|
||||
"emailCannotBeModified": "編集時にはメールアカウントアドレスは変更できません。",
|
||||
"addAccount": "アカウントを追加",
|
||||
"updateAccount": "アカウントを更新",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "こちらでメールアカウントを更新してください。",
|
||||
"addNewEmailAccountHere": "こちらで新しいメールアカウントを追加してください。",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "このアカウントは識別目的でのみ使用され、メールサーバーとの同期は必要ありません。メールデータのインポートに役立ちます。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "このアカウントが有効かどうかを決定します。無効の場合、データのインポートやクエリの実行はできません。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "このアカウントが有効かどうかを判定します。無効の場合、データをインポートできません。",
|
||||
"folderSync": {
|
||||
"noData": "データなし",
|
||||
"noFolders": "同期するフォルダーがありません",
|
||||
"batches": "バッチ"
|
||||
"batches": "バッチ",
|
||||
"autoSelectDescendants": "子項目を自動選択",
|
||||
"autoSelectParents": "親項目を自動選択",
|
||||
"expandAll": "すべて展開",
|
||||
"collapseAll": "すべて折りたたむ",
|
||||
"loadingMailboxFolders": "メールボックスフォルダーを読み込み中…"
|
||||
},
|
||||
"viewDetails": "詳細を見る",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "최근 활동 없음",
|
||||
"noSendersData": "발신자 데이터 없음",
|
||||
"noLargeEmails": "크기가 큰 이메일 없음",
|
||||
"noAccountData": "계정 데이터 없음"
|
||||
"noAccountData": "계정 데이터 없음",
|
||||
"systemVersion": "시스템 버전"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "이메일 계정",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "계정 구성 없음",
|
||||
"noAccountConfigurationsDesc": "아직 계정 구성을 추가하지 않았습니다. 기능을 사용하려면 추가하십시오.",
|
||||
"addConfiguration": "구성 추가",
|
||||
"name": "이름",
|
||||
"name": "로그인 이름",
|
||||
"email": "이메일",
|
||||
"status": "상태",
|
||||
"type": "유형",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "예: john.doe@example.com",
|
||||
"namePlaceholder": "예: john.doe",
|
||||
"optional": "선택 사항",
|
||||
"nameDescription": "IMAP 연결 사용자 이름. 전체 이메일 주소를 연결 사용자 이름으로 사용하는 경우, 이 필드를 비워 두십시오.",
|
||||
"emailCannotBeModified": "편집 시 계정 이메일 주소는 수정할 수 없습니다.",
|
||||
"addAccount": "계정 추가",
|
||||
"updateAccount": "계정 업데이트",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "여기에서 이메일 계정을 업데이트하십시오.",
|
||||
"addNewEmailAccountHere": "여기에서 새 이메일 계정을 추가하십시오.",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "이 계정은 식별 목적으로만 사용되며 이메일 서버와의 동기화가 필요하지 않습니다. 이메일 데이터 가져오기에 도움이 됩니다.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "이 계정이 활성화되었는지 여부를 결정합니다. 비활성화된 경우 데이터 가져오기 또는 쿼리 실행이 불가능합니다.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "이 계정이 활성화되어 있는지 결정합니다. 비활성화되면 데이터를 가져올 수 없습니다.",
|
||||
"folderSync": {
|
||||
"noData": "데이터 없음",
|
||||
"noFolders": "동기화할 폴더가 없습니다",
|
||||
"batches": "배치"
|
||||
"batches": "배치",
|
||||
"autoSelectDescendants": "하위 항목 자동 선택",
|
||||
"autoSelectParents": "상위 항목 자동 선택",
|
||||
"expandAll": "모두 펼치기",
|
||||
"collapseAll": "모두 접기",
|
||||
"loadingMailboxFolders": "메일 폴더 로딩 중…"
|
||||
},
|
||||
"viewDetails": "세부 정보 보기",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Geen recente activiteit",
|
||||
"noSendersData": "Geen afzendersgegevens",
|
||||
"noLargeEmails": "Geen grote e-mails",
|
||||
"noAccountData": "Geen accountgegevens"
|
||||
"noAccountData": "Geen accountgegevens",
|
||||
"systemVersion": "Systeemversie"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "E-mailaccounts",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Geen Accountconfiguraties",
|
||||
"noAccountConfigurationsDesc": "U heeft nog geen Accountconfiguraties toegevoegd. Voeg er een toe om de Accountfuncties te gebruiken.",
|
||||
"addConfiguration": "Configuratie Toevoegen",
|
||||
"name": "Naam",
|
||||
"name": "Inlognaam",
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "bv. john.doe@voorbeeld.com",
|
||||
"namePlaceholder": "bv. john.doe",
|
||||
"optional": "Optioneel",
|
||||
"nameDescription": "IMAP-verbindingsgebruikersnaam. Laat dit veld leeg als u uw volledige e-mailadres als verbindingsgebruikersnaam gebruikt.",
|
||||
"emailCannotBeModified": "Het e-mailadres van het account kan niet worden gewijzigd tijdens het bewerken.",
|
||||
"addAccount": "Account Toevoegen",
|
||||
"updateAccount": "Account Bijwerken",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Werk het e-mailaccount hier bij. ",
|
||||
"addNewEmailAccountHere": "Voeg hier een nieuw e-mailaccount toe. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Dit account wordt alleen gebruikt voor identificatiedoeleinden en vereist geen synchronisatie met een e-mailserver. Het helpt bij het importeren van e-mailgegevens.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bepaalt of dit account actief is. Indien uitgeschakeld, kan het account geen gegevens importeren of zoekopdrachten uitvoeren.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bepaalt of dit account actief is. Als het is uitgeschakeld, kan er geen data worden geïmporteerd.",
|
||||
"folderSync": {
|
||||
"noData": "Geen Gegevens",
|
||||
"noFolders": "Geen mappen om te synchroniseren",
|
||||
"batches": "batches"
|
||||
"batches": "batches",
|
||||
"autoSelectDescendants": "Automatisch onderliggende items selecteren",
|
||||
"autoSelectParents": "Automatisch bovenliggende items selecteren",
|
||||
"expandAll": "Alles uitklappen",
|
||||
"collapseAll": "Alles inklappen",
|
||||
"loadingMailboxFolders": "Postvakken laden…"
|
||||
},
|
||||
"viewDetails": "details bekijken",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Ingen nylig aktivitet",
|
||||
"noSendersData": "Ingen avsenderdata",
|
||||
"noLargeEmails": "Ingen store e-poster",
|
||||
"noAccountData": "Ingen kontodata"
|
||||
"noAccountData": "Ingen kontodata",
|
||||
"systemVersion": "Systemversjon"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "E-postkontoer",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ingen kontokonfigurasjoner",
|
||||
"noAccountConfigurationsDesc": "Du har ikke lagt til noen kontokonfigurasjoner ennå. Legg til en for å begynne å bruke kontofunksjoner.",
|
||||
"addConfiguration": "Legg til konfigurasjon",
|
||||
"name": "Navn",
|
||||
"name": "Påloggingsnavn",
|
||||
"email": "E-post",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "f.eks. ola.nordmann@eksempel.no",
|
||||
"namePlaceholder": "f.eks. ola.nordmann",
|
||||
"optional": "Valgfritt",
|
||||
"nameDescription": "IMAP-tilkoblingsbrukernavn. La dette feltet stå tomt hvis du bruker hele e-postadressen din som tilkoblingsbrukernavn.",
|
||||
"emailCannotBeModified": "E-postadressen til kontoen kan ikke endres under redigering.",
|
||||
"addAccount": "Legg til konto",
|
||||
"updateAccount": "Oppdater konto",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Oppdater e-postkontoen her. ",
|
||||
"addNewEmailAccountHere": "Legg til ny e-postkonto her. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Denne kontoen brukes kun for identifikasjonsformål og krever ikke synkronisering med en e-postserver. Den hjelper med import av e-postdata.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer om denne kontoen er aktiv. Hvis deaktivert, vil ikke kontoen kunne importere data eller utføre spørringer.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Bestemmer om denne kontoen er aktiv. Hvis den er deaktivert, kan data ikke importeres.",
|
||||
"folderSync": {
|
||||
"noData": "Ingen data",
|
||||
"noFolders": "Ingen mapper å synkronisere",
|
||||
"batches": "partier"
|
||||
"batches": "partier",
|
||||
"autoSelectDescendants": "Velg etterkommere automatisk",
|
||||
"autoSelectParents": "Velg foreldre automatisk",
|
||||
"expandAll": "Utvid alle",
|
||||
"collapseAll": "Skjul alle",
|
||||
"loadingMailboxFolders": "Laster inn postkassemapper…"
|
||||
},
|
||||
"viewDetails": "se detaljer",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Nenhuma Atividade Recente",
|
||||
"noSendersData": "Sem Dados de Remetentes",
|
||||
"noLargeEmails": "Sem Emails Grandes",
|
||||
"noAccountData": "Sem Dados de Contas"
|
||||
"noAccountData": "Sem Dados de Contas",
|
||||
"systemVersion": "Versão do sistema"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Contas de Email",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Nenhuma Configuração de Conta",
|
||||
"noAccountConfigurationsDesc": "Você ainda não adicionou nenhuma configuração de conta. Adicione uma para utilizar a funcionalidade.",
|
||||
"addConfiguration": "Adicionar Configuração",
|
||||
"name": "Nome",
|
||||
"name": "Nome de login",
|
||||
"email": "Email",
|
||||
"status": "Status",
|
||||
"type": "Tipo",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "Ex: john.doe@example.com",
|
||||
"namePlaceholder": "Ex: john.doe",
|
||||
"optional": "Opcional",
|
||||
"nameDescription": "Nome de usuário de conexão IMAP. Deixe este campo em branco se você usar seu endereço de e-mail completo como nome de usuário de conexão.",
|
||||
"emailCannotBeModified": "O endereço de email da conta não pode ser modificado ao editar.",
|
||||
"addAccount": "Adicionar Conta",
|
||||
"updateAccount": "Atualizar Conta",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Atualize a conta de email aqui.",
|
||||
"addNewEmailAccountHere": "Adicione uma nova conta de email aqui.",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Esta conta é usada apenas para fins de identificação e não requer sincronização com um servidor de email. Ajuda na importação de dados de email.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina se esta conta está ativa. Se desativada, nenhuma importação de dados ou execução de consulta será possível.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Determina se esta conta está ativa. Se estiver desativada, não será possível importar dados.",
|
||||
"folderSync": {
|
||||
"noData": "Sem Dados",
|
||||
"noFolders": "Nenhuma pasta para sincronizar",
|
||||
"batches": "Lotes"
|
||||
"batches": "Lotes",
|
||||
"autoSelectDescendants": "Selecionar automaticamente os descendentes",
|
||||
"autoSelectParents": "Selecionar automaticamente os pais",
|
||||
"expandAll": "Expandir tudo",
|
||||
"collapseAll": "Recolher tudo",
|
||||
"loadingMailboxFolders": "Carregando pastas da caixa de correio…"
|
||||
},
|
||||
"viewDetails": "Ver Detalhes",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Нет недавней активности",
|
||||
"noSendersData": "Нет данных об отправителях",
|
||||
"noLargeEmails": "Нет больших писем",
|
||||
"noAccountData": "Нет данных об аккаунте"
|
||||
"noAccountData": "Нет данных об аккаунте",
|
||||
"systemVersion": "Версия системы"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Почтовые учетные записи",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Нет настроек учетных записей",
|
||||
"noAccountConfigurationsDesc": "Вы еще не добавили ни одной конфигурации учетной записи. Добавьте одну, чтобы начать использовать функции аккаунта.",
|
||||
"addConfiguration": "Добавить конфигурацию",
|
||||
"name": "Имя",
|
||||
"name": "Имя для входа",
|
||||
"email": "Email",
|
||||
"status": "Статус",
|
||||
"type": "Тип",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "например, john.doe@example.com",
|
||||
"namePlaceholder": "например, john.doe",
|
||||
"optional": "Необязательно",
|
||||
"nameDescription": "Имя пользователя для IMAP-подключения. Оставьте это поле пустым, если вы используете свой полный адрес электронной почты в качестве имени пользователя для подключения.",
|
||||
"emailCannotBeModified": "Адрес электронной почты нельзя изменить при редактировании.",
|
||||
"addAccount": "Добавить аккаунт",
|
||||
"updateAccount": "Обновить аккаунт",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Обновите почтовый аккаунт здесь. ",
|
||||
"addNewEmailAccountHere": "Добавьте новый почтовый аккаунт здесь. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Этот аккаунт используется только для идентификации и не требует синхронизации с почтовым сервером. Он помогает при импорте почтовых данных.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Определяет, активен ли этот аккаунт. Если отключено, аккаунт не сможет импортировать данные или выполнять запросы.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Определяет, активна ли учетная запись. Если она отключена, импорт данных будет невозможен.",
|
||||
"folderSync": {
|
||||
"noData": "Нет данных",
|
||||
"noFolders": "Нет папок для синхронизации",
|
||||
"batches": "пакетов"
|
||||
"batches": "пакетов",
|
||||
"autoSelectDescendants": "Автоматически выбирать дочерние элементы",
|
||||
"autoSelectParents": "Автоматически выбирать родительские элементы",
|
||||
"expandAll": "Развернуть все",
|
||||
"collapseAll": "Свернуть все",
|
||||
"loadingMailboxFolders": "Загрузка папок почтового ящика…"
|
||||
},
|
||||
"viewDetails": "смотреть детали",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "Ingen nylig aktivitet",
|
||||
"noSendersData": "Inga avsändardata",
|
||||
"noLargeEmails": "Inga stora e-postmeddelanden",
|
||||
"noAccountData": "Inga kontodata"
|
||||
"noAccountData": "Inga kontodata",
|
||||
"systemVersion": "Systemversion"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "E-postkonton",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "Inga kontokonfigurationer",
|
||||
"noAccountConfigurationsDesc": "Du har inte lagt till några kontokonfigurationer ännu. Lägg till en för att börja använda kontofunktioner.",
|
||||
"addConfiguration": "Lägg till konfiguration",
|
||||
"name": "Namn",
|
||||
"name": "Inloggningsnamn",
|
||||
"email": "E-post",
|
||||
"status": "Status",
|
||||
"type": "Typ",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "t.ex. sven.svensson@exempel.se",
|
||||
"namePlaceholder": "t.ex. sven.svensson",
|
||||
"optional": "Valfritt",
|
||||
"nameDescription": "IMAP-anslutningsanvändarnamn. Lämna detta fält tomt om du använder din fullständiga e-postadress som anslutningsanvändarnamn.",
|
||||
"emailCannotBeModified": "Kontots e-postadress kan inte ändras vid redigering.",
|
||||
"addAccount": "Lägg till konto",
|
||||
"updateAccount": "Uppdatera konto",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "Uppdatera e-postkontot här. ",
|
||||
"addNewEmailAccountHere": "Lägg till nytt e-postkonto här. ",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "Detta konto används endast för identifieringssyften och kräver inte synkronisering med en e-postserver. Det hjälper till med import av e-postdata.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Avgör om detta konto är aktivt. Om inaktiverat kommer kontot inte kunna importera data eller utföra sökningar.",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "Avgör om kontot är aktivt. Om det är inaktiverat kan ingen data importeras.",
|
||||
"folderSync": {
|
||||
"noData": "Inga data",
|
||||
"noFolders": "Inga mappar att synkronisera",
|
||||
"batches": "satser"
|
||||
"batches": "satser",
|
||||
"autoSelectDescendants": "Välj underordnade automatiskt",
|
||||
"autoSelectParents": "Välj överordnade automatiskt",
|
||||
"expandAll": "Expandera alla",
|
||||
"collapseAll": "Komprimera alla",
|
||||
"loadingMailboxFolders": "Laddar brevlådemappar…"
|
||||
},
|
||||
"viewDetails": "visa detaljer",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "無近期活動",
|
||||
"noSendersData": "無寄件人資料",
|
||||
"noLargeEmails": "無大容量郵件",
|
||||
"noAccountData": "無帳號資料"
|
||||
"noAccountData": "無帳號資料",
|
||||
"systemVersion": "系統版本"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "郵件帳號",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "沒有帳號設定",
|
||||
"noAccountConfigurationsDesc": "您尚未新增任何帳號設定。請新增設定以使用功能。",
|
||||
"addConfiguration": "新增設定",
|
||||
"name": "名稱",
|
||||
"name": "登入名稱",
|
||||
"email": "電子郵件",
|
||||
"status": "狀態",
|
||||
"type": "類型",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "例如:john.doe@example.com",
|
||||
"namePlaceholder": "例如:john.doe",
|
||||
"optional": "選填",
|
||||
"nameDescription": "IMAP 連線使用者名稱。如果您使用完整的電子郵件地址作為連線使用者名稱,請將此欄位留空。",
|
||||
"emailCannotBeModified": "編輯時無法修改電子郵件帳號地址。",
|
||||
"addAccount": "新增帳號",
|
||||
"updateAccount": "更新帳號",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "在此更新電子郵件帳號。",
|
||||
"addNewEmailAccountHere": "在此新增電子郵件帳號。",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "此帳號僅用於識別目的,不需要與郵件伺服器同步。有助於匯入郵件資料。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "決定此帳號是否為啟用狀態。如果停用,則無法匯入資料或執行查詢。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "用來判斷帳戶是否啟用。若停用,將無法匯入資料。",
|
||||
"folderSync": {
|
||||
"noData": "無資料",
|
||||
"noFolders": "沒有可同步的資料夾",
|
||||
"batches": "批次"
|
||||
"batches": "批次",
|
||||
"autoSelectDescendants": "自動選取子項",
|
||||
"autoSelectParents": "自動選取父項",
|
||||
"expandAll": "全部展開",
|
||||
"collapseAll": "全部收合",
|
||||
"loadingMailboxFolders": "正在載入郵件資料夾…"
|
||||
},
|
||||
"viewDetails": "檢視詳細資訊",
|
||||
"runningState": {
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"noRecentActivity": "无最近活动",
|
||||
"noSendersData": "无发件人数据",
|
||||
"noLargeEmails": "无大邮件",
|
||||
"noAccountData": "无账户数据"
|
||||
"noAccountData": "无账户数据",
|
||||
"systemVersion": "系统版本"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "邮件账户",
|
||||
@@ -137,7 +138,7 @@
|
||||
"noAccountConfigurations": "无账户配置",
|
||||
"noAccountConfigurationsDesc": "您尚未添加任何账户配置。添加一个以开始使用账户功能。",
|
||||
"addConfiguration": "添加配置",
|
||||
"name": "名称",
|
||||
"name": "登录名",
|
||||
"email": "邮箱",
|
||||
"status": "状态",
|
||||
"type": "类型",
|
||||
@@ -162,6 +163,7 @@
|
||||
"emailPlaceholder": "例如:john.doe@example.com",
|
||||
"namePlaceholder": "例如:john.doe",
|
||||
"optional": "可选",
|
||||
"nameDescription": "IMAP 连接用户名。如果您使用完整的电子邮件地址作为连接用户名,请将此字段留空。",
|
||||
"emailCannotBeModified": "编辑时无法修改邮箱账户地址。",
|
||||
"addAccount": "添加账户",
|
||||
"updateAccount": "更新账户",
|
||||
@@ -250,11 +252,16 @@
|
||||
"updateTheEmailAccountHere": "在此更新邮件账户。",
|
||||
"addNewEmailAccountHere": "在此添加新邮件账户。",
|
||||
"thisAccountIsUsedForIdentificationPurposesOnly": "此账户仅用于识别目的,不需要与邮件服务器同步。它有助于导入邮件数据。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "确定此账户是否处于活动状态。如果禁用,账户将无法导入数据或执行查询。",
|
||||
"determinesWhetherThisAccountIsActiveNoSync": "用于判断账户是否处于启用状态。如果被禁用,将无法导入数据。",
|
||||
"folderSync": {
|
||||
"noData": "无数据",
|
||||
"noFolders": "没有需要同步的文件夹",
|
||||
"batches": "批次"
|
||||
"batches": "批次",
|
||||
"autoSelectDescendants": "自动选择子项",
|
||||
"autoSelectParents": "自动选择父项",
|
||||
"expandAll": "全部展开",
|
||||
"collapseAll": "全部收起",
|
||||
"loadingMailboxFolders": "正在加载邮箱文件夹…"
|
||||
},
|
||||
"viewDetails": "查看详情",
|
||||
"runningState": {
|
||||
|
||||
Reference in New Issue
Block a user