Aktueller Stand
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Simple Mail Cleaner</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 B |
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 B |
2005
frontend/src/App.tsx
2005
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
googleClientId: "",
|
||||
googleClientSecret: "",
|
||||
googleRedirectUri: "",
|
||||
cleanupScanLimit: ""
|
||||
cleanupScanLimit: "",
|
||||
newsletterThreshold: "",
|
||||
newsletterSubjectTokens: "",
|
||||
newsletterFromTokens: "",
|
||||
newsletterHeaderKeys: "",
|
||||
newsletterWeightHeader: "",
|
||||
newsletterWeightPrecedence: "",
|
||||
newsletterWeightSubject: "",
|
||||
newsletterWeightFrom: "",
|
||||
unsubscribeHistoryTtlDays: "",
|
||||
unsubscribeMethodPreference: "auto"
|
||||
});
|
||||
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
|
||||
@@ -162,7 +172,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
googleClientId: next["google.client_id"]?.value ?? "",
|
||||
googleClientSecret: next["google.client_secret"]?.value ?? "",
|
||||
googleRedirectUri: next["google.redirect_uri"]?.value ?? "",
|
||||
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? ""
|
||||
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? "",
|
||||
newsletterThreshold: next["newsletter.threshold"]?.value ?? "",
|
||||
newsletterSubjectTokens: next["newsletter.subject_tokens"]?.value ?? "",
|
||||
newsletterFromTokens: next["newsletter.from_tokens"]?.value ?? "",
|
||||
newsletterHeaderKeys: next["newsletter.header_keys"]?.value ?? "",
|
||||
newsletterWeightHeader: next["newsletter.weight_header"]?.value ?? "",
|
||||
newsletterWeightPrecedence: next["newsletter.weight_precedence"]?.value ?? "",
|
||||
newsletterWeightSubject: next["newsletter.weight_subject"]?.value ?? "",
|
||||
newsletterWeightFrom: next["newsletter.weight_from"]?.value ?? "",
|
||||
unsubscribeHistoryTtlDays: next["unsubscribe.history_ttl_days"]?.value ?? "",
|
||||
unsubscribeMethodPreference: next["unsubscribe.method_preference"]?.value ?? "auto"
|
||||
});
|
||||
};
|
||||
|
||||
@@ -452,7 +472,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
"google.client_id": settingsDraft.googleClientId,
|
||||
"google.client_secret": settingsDraft.googleClientSecret,
|
||||
"google.redirect_uri": settingsDraft.googleRedirectUri,
|
||||
"cleanup.scan_limit": settingsDraft.cleanupScanLimit
|
||||
"cleanup.scan_limit": settingsDraft.cleanupScanLimit,
|
||||
"newsletter.threshold": settingsDraft.newsletterThreshold,
|
||||
"newsletter.subject_tokens": settingsDraft.newsletterSubjectTokens,
|
||||
"newsletter.from_tokens": settingsDraft.newsletterFromTokens,
|
||||
"newsletter.header_keys": settingsDraft.newsletterHeaderKeys,
|
||||
"newsletter.weight_header": settingsDraft.newsletterWeightHeader,
|
||||
"newsletter.weight_precedence": settingsDraft.newsletterWeightPrecedence,
|
||||
"newsletter.weight_subject": settingsDraft.newsletterWeightSubject,
|
||||
"newsletter.weight_from": settingsDraft.newsletterWeightFrom,
|
||||
"unsubscribe.history_ttl_days": settingsDraft.unsubscribeHistoryTtlDays,
|
||||
"unsubscribe.method_preference": settingsDraft.unsubscribeMethodPreference
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -532,12 +562,31 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
{t("countJobs", { count: tenant._count?.jobs ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => exportTenant(tenant)}>{t("adminExportStart")}</button>
|
||||
<button className="ghost" onClick={() => toggleTenant(tenant)}>
|
||||
{tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => exportTenant(tenant)}
|
||||
title={t("adminExportStart")}
|
||||
aria-label={t("adminExportStart")}
|
||||
>
|
||||
⤓
|
||||
</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => toggleTenant(tenant)}
|
||||
title={tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
aria-label={tenant.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => deleteTenant(tenant)}
|
||||
title={t("adminDelete")}
|
||||
aria-label={t("adminDelete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
<button className="ghost" onClick={() => deleteTenant(tenant)}>{t("adminDelete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -567,7 +616,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/admin/exports/purge", { method: "POST" }, token);
|
||||
@@ -577,8 +626,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}}
|
||||
title={t("adminExportPurge")}
|
||||
aria-label={t("adminExportPurge")}
|
||||
>
|
||||
{t("adminExportPurge")}
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
{exportsFiltered.map((item) => (
|
||||
@@ -600,13 +651,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<p>{t("exportProgress", { progress: item.progress })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<div className="inline-actions icon-actions">
|
||||
<span>
|
||||
{item.createdAt ? new Date(item.createdAt).toLocaleString() : "-"} ·{" "}
|
||||
{t("exportExpires")}: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : "-"}
|
||||
</span>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
disabled={item.status !== "DONE" || (item.expiresAt ? new Date(item.expiresAt) < new Date() : false)}
|
||||
onClick={async () => {
|
||||
const response = await downloadExport(token, item.id);
|
||||
@@ -615,11 +666,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
downloadFile(blob, `export-${item.id}.zip`);
|
||||
}
|
||||
}}
|
||||
title={t("exportDownload")}
|
||||
aria-label={t("exportDownload")}
|
||||
>
|
||||
{t("exportDownload")}
|
||||
⤓
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
className="ghost icon-only"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch(`/admin/exports/${item.id}`, { method: "DELETE" }, token);
|
||||
@@ -629,8 +682,10 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}}
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
{t("delete")}
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -664,18 +719,38 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</div>
|
||||
<p>{user.role} · {user.tenant?.name ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}>
|
||||
{user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => setRole(user, user.role === "ADMIN" ? "USER" : "ADMIN")}
|
||||
title={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
||||
aria-label={user.role === "ADMIN" ? t("adminMakeUser") : t("adminMakeAdmin")}
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
<button className="ghost" onClick={() => toggleUser(user)}>
|
||||
{user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => toggleUser(user)}
|
||||
title={user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
aria-label={user.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
<button className="ghost" onClick={() => impersonate(user)}>
|
||||
{t("adminImpersonate")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => impersonate(user)}
|
||||
title={t("adminImpersonate")}
|
||||
aria-label={t("adminImpersonate")}
|
||||
>
|
||||
👤
|
||||
</button>
|
||||
<button className="ghost" onClick={() => setResetUserId(user.id)}>
|
||||
{t("adminResetPassword")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => setResetUserId(user.id)}
|
||||
title={t("adminResetPassword")}
|
||||
aria-label={t("adminResetPassword")}
|
||||
>
|
||||
🔑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -740,8 +815,13 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={() => toggleAccount(account)}>
|
||||
{account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => toggleAccount(account)}
|
||||
title={account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
aria-label={account.isActive ? t("adminDisable") : t("adminEnable")}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -770,12 +850,26 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
/>
|
||||
{t("selectAll")}
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminCancelSelected")}
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={cancelSelectedJobs}
|
||||
disabled={selectedJobIds.length === 0}
|
||||
title={t("adminCancelSelected")}
|
||||
aria-label={t("adminCancelSelected")}
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminDeleteSelected")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={deleteSelectedJobs}
|
||||
disabled={selectedJobIds.length === 0}
|
||||
title={t("adminDeleteSelected")}
|
||||
aria-label={t("adminDeleteSelected")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -785,16 +879,37 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<strong>{mapJobStatus(job.status)}</strong>
|
||||
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<div className="inline-actions icon-actions">
|
||||
<input
|
||||
className="checkbox-input"
|
||||
type="checkbox"
|
||||
checked={selectedJobIds.includes(job.id)}
|
||||
onChange={() => toggleJobSelection(job.id)}
|
||||
/>
|
||||
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
|
||||
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
|
||||
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => retryJob(job)}
|
||||
title={t("adminRetry")}
|
||||
aria-label={t("adminRetry")}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => cancelJob(job)}
|
||||
title={t("adminCancelJob")}
|
||||
aria-label={t("adminCancelJob")}
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => deleteJob(job)}
|
||||
title={t("adminDelete")}
|
||||
aria-label={t("adminDelete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
<span>{new Date(job.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,9 +957,109 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
/>
|
||||
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
|
||||
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
|
||||
<div className="panel-divider" />
|
||||
<h4>{t("adminNewsletterSettings")}</h4>
|
||||
<p className="hint-text">{t("adminNewsletterSettingsHint")}</p>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterThreshold")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={settingsDraft.newsletterThreshold}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterThreshold: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterHeaderKeys")}</span>
|
||||
<input
|
||||
value={settingsDraft.newsletterHeaderKeys}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterHeaderKeys: event.target.value }))}
|
||||
/>
|
||||
<small className="hint-text">{t("adminNewsletterHeaderKeysHint")}</small>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterWeightHeader")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={settingsDraft.newsletterWeightHeader}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightHeader: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterWeightPrecedence")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={settingsDraft.newsletterWeightPrecedence}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightPrecedence: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterSubjectTokens")}</span>
|
||||
<input
|
||||
value={settingsDraft.newsletterSubjectTokens}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterSubjectTokens: event.target.value }))}
|
||||
/>
|
||||
<small className="hint-text">{t("adminNewsletterSubjectTokensHint")}</small>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterWeightSubject")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={settingsDraft.newsletterWeightSubject}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightSubject: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterFromTokens")}</span>
|
||||
<input
|
||||
value={settingsDraft.newsletterFromTokens}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterFromTokens: event.target.value }))}
|
||||
/>
|
||||
<small className="hint-text">{t("adminNewsletterFromTokensHint")}</small>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminNewsletterWeightFrom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={settingsDraft.newsletterWeightFrom}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, newsletterWeightFrom: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminUnsubscribeHistoryTtl")}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={settingsDraft.unsubscribeHistoryTtlDays}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeHistoryTtlDays: event.target.value }))}
|
||||
/>
|
||||
<small className="hint-text">{t("adminUnsubscribeHistoryTtlHint")}</small>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminUnsubscribeMethod")}</span>
|
||||
<select
|
||||
value={settingsDraft.unsubscribeMethodPreference}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, unsubscribeMethodPreference: event.target.value }))}
|
||||
>
|
||||
<option value="auto">{t("adminUnsubscribeMethodAuto")}</option>
|
||||
<option value="http">{t("adminUnsubscribeMethodHttp")}</option>
|
||||
<option value="mailto">{t("adminUnsubscribeMethodMailto")}</option>
|
||||
</select>
|
||||
<small className="hint-text">{t("adminUnsubscribeMethodHint")}</small>
|
||||
</label>
|
||||
<div className="inline-actions icon-actions">
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
onClick={() => setShowGoogleSecret((prev) => !prev)}
|
||||
title={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
|
||||
aria-label={showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
<button className="primary" onClick={saveSettings} disabled={settingsStatus === "saving"}>
|
||||
{settingsStatus === "saving" ? t("adminSaving") : t("adminSaveSettings")}
|
||||
@@ -859,6 +1074,20 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
redirect: settings["google.redirect_uri"]?.source ?? "unset"
|
||||
})}
|
||||
</div>
|
||||
<div className="status-note">
|
||||
{t("adminSettingsSourceNewsletter", {
|
||||
threshold: settings["newsletter.threshold"]?.source ?? "unset",
|
||||
headers: settings["newsletter.header_keys"]?.source ?? "unset",
|
||||
subject: settings["newsletter.subject_tokens"]?.source ?? "unset",
|
||||
from: settings["newsletter.from_tokens"]?.source ?? "unset",
|
||||
weightHeader: settings["newsletter.weight_header"]?.source ?? "unset",
|
||||
weightPrecedence: settings["newsletter.weight_precedence"]?.source ?? "unset",
|
||||
weightSubject: settings["newsletter.weight_subject"]?.source ?? "unset",
|
||||
weightFrom: settings["newsletter.weight_from"]?.source ?? "unset",
|
||||
history: settings["unsubscribe.history_ttl_days"]?.source ?? "unset",
|
||||
method: settings["unsubscribe.method_preference"]?.source ?? "unset"
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -867,8 +1096,14 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{t("confirmTitle")}</h3>
|
||||
<button className="ghost" type="button" onClick={closeConfirmDialog}>
|
||||
{t("close")}
|
||||
<button
|
||||
className="ghost icon-only"
|
||||
type="button"
|
||||
onClick={closeConfirmDialog}
|
||||
title={t("close")}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
"adminGoogleRedirectUri": "Redirect-URL",
|
||||
"adminCleanupScanLimit": "Max. Mails pro Bereinigung",
|
||||
"adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.",
|
||||
"adminNewsletterSettings": "Newsletter-Erkennung",
|
||||
"adminNewsletterSettingsHint": "Signale und Schwellen für die Newsletter-Erkennung. Kommagetrennte Listen. Gewichte bestimmen, wie stark ein Signal den Score erhöht.",
|
||||
"adminNewsletterThreshold": "Mindest-Signale",
|
||||
"adminNewsletterHeaderKeys": "Header-Keys",
|
||||
"adminNewsletterHeaderKeysHint": "Kommagetrennte List-Header (z.B. list-unsubscribe,list-id,...).",
|
||||
"adminNewsletterWeightHeader": "Gewicht: Header-Treffer",
|
||||
"adminNewsletterWeightPrecedence": "Gewicht: Bulk/List-Precedence",
|
||||
"adminNewsletterSubjectTokens": "Betreff-Keywords",
|
||||
"adminNewsletterSubjectTokensHint": "Kommagetrennte Tokens, die im Betreff gesucht werden.",
|
||||
"adminNewsletterWeightSubject": "Gewicht: Betreff-Treffer",
|
||||
"adminNewsletterFromTokens": "Absender-Keywords",
|
||||
"adminNewsletterFromTokensHint": "Kommagetrennte Tokens, die im Absender gesucht werden.",
|
||||
"adminNewsletterWeightFrom": "Gewicht: Absender-Treffer",
|
||||
"adminUnsubscribeHistoryTtl": "Abmelde-Dedupe-Zeitfenster (Tage)",
|
||||
"adminUnsubscribeHistoryTtlHint": "Verhindert erneutes Abmelden innerhalb dieses Zeitfensters. 0 deaktiviert.",
|
||||
"adminUnsubscribeMethod": "Abmelde-Methode bevorzugen",
|
||||
"adminUnsubscribeMethodHint": "Auto nutzt HTTP und fällt bei Fehler auf mailto zurück.",
|
||||
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto Fallback)",
|
||||
"adminUnsubscribeMethodHttp": "HTTP bevorzugen",
|
||||
"adminUnsubscribeMethodMailto": "mailto bevorzugen",
|
||||
"adminSaveSettings": "Einstellungen speichern",
|
||||
"adminSaving": "Speichert...",
|
||||
"adminSettingsSaved": "Gespeichert",
|
||||
@@ -74,6 +94,7 @@
|
||||
"adminShowSecret": "Secret anzeigen",
|
||||
"adminHideSecret": "Secret verbergen",
|
||||
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
|
||||
"adminSettingsSourceNewsletter": "Quellen - Schwelle: {{threshold}}, Header: {{headers}}, Betreff-Tokens: {{subject}}, Absender-Tokens: {{from}}, Gewichte (Header/Precedence/Betreff/Absender): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Abmelde-History: {{history}}, Methode: {{method}}",
|
||||
"selectAll": "Alle auswählen",
|
||||
"adminCancelSelected": "Auswahl abbrechen",
|
||||
"adminDeleteSelected": "Auswahl löschen",
|
||||
@@ -94,30 +115,175 @@
|
||||
"mailboxCancelEdit": "Abbrechen",
|
||||
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
|
||||
"cleanupStart": "Bereinigung starten",
|
||||
"cleanupDryRun": "Dry run (keine Änderungen)",
|
||||
"cleanupDryRun": "Nur simulieren (keine Änderungen)",
|
||||
"cleanupUnsubscribe": "Unsubscribe aktiv",
|
||||
"cleanupRouting": "Routing aktiv",
|
||||
"cleanupDisabled": "Bereinigung ist noch nicht verfügbar.",
|
||||
"cleanupSelectMailbox": "Bitte ein Postfach auswählen.",
|
||||
"cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.",
|
||||
"cleanupDryRunHint": "Dry run simuliert Routing und Unsubscribe. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
|
||||
"cleanupDryRunHint": "Nur simulieren: Routing und Abmelden werden simuliert. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
|
||||
"cleanupUnsubscribeHint": "Versucht Newsletter per List-Unsubscribe abzumelden. Im Nur-simulieren-Modus werden nur die Checks geloggt.",
|
||||
"cleanupRoutingHint": "Wendet deine Regeln an (Verschieben/Löschen/Label). Im Nur-simulieren-Modus nur Simulation.",
|
||||
"rulesTitle": "Regeln",
|
||||
"rulesAdd": "Regel hinzufügen",
|
||||
"rulesReorder": "Ziehen zum Sortieren",
|
||||
"rulesAddTitle": "Regel erstellen",
|
||||
"rulesEditTitle": "Regel bearbeiten",
|
||||
"rulesName": "Rule Name",
|
||||
"rulesEnabled": "Rule aktiv",
|
||||
"rulesMatchMode": "Regel-Logik",
|
||||
"rulesMatchAll": "Alle Bedingungen (UND)",
|
||||
"rulesMatchAny": "Mindestens eine (ODER)",
|
||||
"rulesMatchAnyLabel": "ODER",
|
||||
"rulesStopOnMatch": "Nach Treffer stoppen (erste Regel gewinnt)",
|
||||
"rulesStopOnMatchBadge": "ERSTE",
|
||||
"rulesConditions": "Bedingungen",
|
||||
"rulesActions": "Aktionen",
|
||||
"rulesAddCondition": "+ Bedingung",
|
||||
"rulesAddAction": "+ Aktion",
|
||||
"rulesSave": "Regel speichern",
|
||||
"rulesExampleHint": "Beispiel: Abmelde-Status = OK + List-Unsubscribe = * → Aktion: In \"Newsletter-Abgemeldet\" verschieben.",
|
||||
"remove": "Entfernen",
|
||||
"ruleConditionUnsubscribeStatus": "Abmelde-Status",
|
||||
"ruleConditionScore": "Newsletter-Score",
|
||||
"ruleConditionScorePlaceholder": "z.B. >=2",
|
||||
"ruleConditionHeaderMissing": "Header fehlt",
|
||||
"ruleConditionHeaderMissingPlaceholder": "Header-Name (z.B. List-Unsubscribe)",
|
||||
"ruleUnsubStatusAny": "Beliebig",
|
||||
"ruleUnsubStatusOk": "OK",
|
||||
"ruleUnsubStatusDryRun": "Nur simuliert",
|
||||
"ruleUnsubStatusFailed": "Fehlgeschlagen",
|
||||
"ruleUnsubStatusSkipped": "Übersprungen",
|
||||
"ruleUnsubStatusDuplicate": "Übersprungen (Duplikat)",
|
||||
"ruleUnsubStatusDisabled": "Deaktiviert",
|
||||
"ruleActionMarkRead": "Als gelesen markieren",
|
||||
"ruleActionMarkUnread": "Als ungelesen markieren",
|
||||
"rulesOverview": "Regeln Übersicht",
|
||||
"rulesEmpty": "Noch keine Regeln.",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobsEmpty": "Noch keine Jobs.",
|
||||
"jobCandidatesTitle": "Ergebnisdetails",
|
||||
"jobCandidatesHint": "Zeigt jeden Newsletter-Kandidaten inkl. Aktionen, Abmelde-Status und Metadaten.",
|
||||
"jobCandidatesGroupBy": "Gruppieren nach",
|
||||
"jobCandidatesGroupDomain": "Absender-Domain",
|
||||
"jobCandidatesGroupFrom": "Absender",
|
||||
"jobCandidatesGroupListId": "List-ID",
|
||||
"jobCandidatesGroupNone": "Keine Gruppierung",
|
||||
"jobCandidatesGroupsEmpty": "Noch keine Newsletter-Kandidaten.",
|
||||
"jobCandidatesShowSignals": "Details anzeigen",
|
||||
"jobCandidatesCount": "{{count}} Kandidaten",
|
||||
"jobCandidatesLoadMore": "Mehr laden",
|
||||
"jobCandidatesBack": "Zurück zu Gruppen",
|
||||
"jobCandidatesRefresh": "Aktualisieren",
|
||||
"jobCandidatesUnknown": "Unbekannt",
|
||||
"jobCandidatesSubject": "Betreff",
|
||||
"jobCandidatesFrom": "Von",
|
||||
"jobCandidatesListId": "List-ID",
|
||||
"jobCandidatesDate": "Datum",
|
||||
"jobCandidatesActions": "Aktionen",
|
||||
"jobCandidatesUnsubscribe": "Abmelden",
|
||||
"jobCandidatesUnsubscribeNone": "Kein List-Unsubscribe Header",
|
||||
"jobCandidatesUnsubscribeDisabled": "Deaktiviert",
|
||||
"jobCandidatesUnsubscribeDryRun": "Nur simuliert",
|
||||
"jobCandidatesUnsubscribeOk": "OK",
|
||||
"jobCandidatesUnsubscribeFailed": "Fehlgeschlagen",
|
||||
"jobCandidatesUnsubscribeDuplicate": "Übersprungen (Duplikat)",
|
||||
"resultsTitle": "Ergebnisdetails",
|
||||
"resultsHint": "Öffnet eine detaillierte Liste aller erkannten E-Mails mit Vorschau.",
|
||||
"resultsOpen": "Ergebnisse öffnen",
|
||||
"resultsGroups": "Gruppen",
|
||||
"resultsGroupsDisabled": "Gruppierung deaktiviert. Bitte eine Gruppierung wählen.",
|
||||
"resultsItems": "Nachrichten",
|
||||
"resultsPreview": "Vorschau",
|
||||
"resultsSelectGroup": "Bitte eine Gruppe auswählen, um Nachrichten zu laden.",
|
||||
"resultsSelectItem": "Bitte eine Nachricht auswählen, um sie anzuzeigen.",
|
||||
"resultsPreviewLoading": "Vorschau wird geladen...",
|
||||
"resultsPreviewEmpty": "Keine Vorschau verfügbar.",
|
||||
"resultsSearch": "Suche Betreff/Absender/List-ID",
|
||||
"resultsFilterAll": "Alle Status",
|
||||
"resultsReviewedAll": "Alle",
|
||||
"resultsReviewed": "Geprüft",
|
||||
"resultsUnreviewed": "Offen",
|
||||
"resultsExportCsv": "CSV",
|
||||
"resultsExportGroupCsv": "Gruppen-CSV",
|
||||
"resultsSelectAll": "Alle",
|
||||
"resultsMarkSelectedReviewed": "Ausgewählt geprüft",
|
||||
"resultsMarkSelectedUnreviewed": "Ausgewählt offen",
|
||||
"resultsBulkSelect": "Nur offen",
|
||||
"resultsBulkMarkReviewed": "Alle geprüft",
|
||||
"resultsDeleteSelected": "Ausgewählte löschen",
|
||||
"resultsAttachments": "Anhänge",
|
||||
"resultsDownloadAttachment": "Download",
|
||||
"resultsHistory": "Abmelde-Historie",
|
||||
"resultsUnsubscribeDetails": "Abmelde-Details",
|
||||
"resultsUnsubscribeStatus": "Status",
|
||||
"resultsUnsubscribeMethod": "Methode",
|
||||
"resultsUnsubscribeTarget": "Ziel",
|
||||
"resultsUnsubscribeMessage": "Ergebnis",
|
||||
"resultsListId": "List-ID",
|
||||
"resultsListUnsubscribe": "List-Unsubscribe",
|
||||
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
|
||||
"resultsMailtoSubject": "Mailto-Betreff",
|
||||
"resultsMailtoBody": "Mailto-Text",
|
||||
"resultsRequestMethod": "Request-Methode",
|
||||
"resultsRequestUrl": "Request-URL",
|
||||
"resultsResponseStatus": "Response-Status",
|
||||
"resultsResponseRedirect": "Redirect",
|
||||
"resultsMailtoVia": "Versendet via",
|
||||
"resultsMailtoTo": "Mailto",
|
||||
"resultsMailtoReplyTo": "Reply-To",
|
||||
"resultsMailtoListUnsubscribe": "List-Unsubscribe-Header",
|
||||
"resultsUnsubscribeReason": "Grund",
|
||||
"resultsUnsubscribeError": "Fehler",
|
||||
"resultsLive": "Live",
|
||||
"resultsStatic": "Statisch",
|
||||
"resultsBackToJob": "Zurück zum Job",
|
||||
"jobCandidatesActionApplied": "Ausgeführt",
|
||||
"jobCandidatesActionDryRun": "Nur simuliert",
|
||||
"jobCandidatesActionFailed": "Fehlgeschlagen",
|
||||
"jobCandidatesActionSkipped": "Übersprungen",
|
||||
"jobCandidatesSignals": "Signale",
|
||||
"jobCandidatesScore": "Score",
|
||||
"jobCandidatesScoreHint": "Der Score basiert u. a. auf Betreff, Absender, Header-Signalen und dem Newsletter-Erkennungsmodell.",
|
||||
"jobCandidatesHeaderSignals": "Header",
|
||||
"jobCandidatesSubjectSignals": "Betreff-Treffer",
|
||||
"jobCandidatesFromSignals": "Absender-Treffer",
|
||||
"jobCandidatesPrecedenceSignal": "Bulk/List-Precedence",
|
||||
"jobEventCleanupStarted": "Bereinigung gestartet",
|
||||
"jobEventCleanupFinished": "Bereinigung abgeschlossen",
|
||||
"jobEventConnecting": "Verbinde mit {{email}}",
|
||||
"jobEventListingGmail": "Gmail-Nachrichten werden aufgelistet",
|
||||
"jobEventListedGmail": "{{count}} Gmail-Nachrichten bisher gefunden",
|
||||
"jobEventPreparedGmail": "{{count}} Gmail-Nachrichten vorbereitet",
|
||||
"jobEventResumeGmail": "Gmail-Bereinigung fortgesetzt bei {{current}}/{{total}}",
|
||||
"jobEventProcessingGmail": "{{count}} Gmail-Nachrichten werden verarbeitet",
|
||||
"jobEventNoGmail": "Keine Gmail-Nachrichten zu verarbeiten",
|
||||
"jobEventFoundMailboxes": "{{count}} Postfächer gefunden",
|
||||
"jobEventScanningMailbox": "Scanne {{mailbox}}",
|
||||
"jobEventPreparedImap": "{{count}} IMAP-Nachrichten vorbereitet",
|
||||
"jobEventResumeImap": "IMAP-Bereinigung fortgesetzt bei {{current}}/{{total}}",
|
||||
"jobEventProcessingImap": "{{count}} IMAP-Nachrichten werden verarbeitet",
|
||||
"jobEventNoImap": "Keine IMAP-Nachrichten zu verarbeiten",
|
||||
"jobEventDetectedCandidates": "{{count}} Newsletter-Kandidaten erkannt",
|
||||
"jobEventProcessed": "Verarbeitet {{current}}/{{total}}",
|
||||
"jobEventProcessedCount": "Verarbeitet {{current}}",
|
||||
"jobEventGmailActionAppliedList": "Gmail‑Aktion angewendet: {{actions}}",
|
||||
"jobEventGmailActionApplied": "Gmail‑Aktion angewendet: {{action}}",
|
||||
"jobEventGmailActionSkippedNoChanges": "Gmail‑Aktion übersprungen: keine Label‑Änderungen",
|
||||
"jobEventGmailActionFailedSimple": "Gmail‑Aktion fehlgeschlagen: {{error}}",
|
||||
"jobEventGmailActionFailed": "Gmail‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
||||
"jobEventImapActionFailed": "IMAP‑Aktion fehlgeschlagen ({{action}}): {{error}}",
|
||||
"jobEventDryRunAction": "Nur simulieren: {{action}}",
|
||||
"jobEventCanceledByAdmin": "Job vom Admin abgebrochen",
|
||||
"jobEventCanceledBeforeStart": "Job vor Start abgebrochen",
|
||||
"jobEventFailed": "Job fehlgeschlagen: {{error}}",
|
||||
"loadingCandidates": "Kandidaten werden geladen...",
|
||||
"jobsProgress": "Fortschritt",
|
||||
"jobsEta": "Restzeit",
|
||||
"jobsEtaDone": "Fertig",
|
||||
"jobsEtaQueued": "Wartet",
|
||||
"jobsEtaRecalculating": "Restzeit wird neu berechnet…",
|
||||
"jobsEtaCalculating": "Berechne…",
|
||||
"jobDetailsTitle": "Job-Details",
|
||||
"jobNoEvents": "Noch keine Events.",
|
||||
"jobEvents": "Job Events",
|
||||
@@ -128,7 +294,13 @@
|
||||
"phaseProcessingPending": "Warte auf Verarbeitung.",
|
||||
"phaseUnsubscribePending": "Warte auf Abmeldung.",
|
||||
"phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.",
|
||||
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Dry-Run {{dryRun}} ({{total}} gesamt).",
|
||||
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} gesamt).",
|
||||
"phaseUnsubscribeSummaryWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Nur simuliert {{dryRun}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
|
||||
"phaseUnsubscribeSummaryNoDryRun": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} gesamt).",
|
||||
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
|
||||
"phaseUnsubscribeDryRunPending": "Nur simulieren: warte auf Abmelde-Checks.",
|
||||
"phaseUnsubscribeDryRunSummary": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} gesamt).",
|
||||
"phaseUnsubscribeDryRunSummaryWithProcessed": "Nur simulieren: {{dryRun}} Abmelde-Checks ({{total}} Kandidaten, {{processed}}/{{overall}} verarbeitet).",
|
||||
"phaseStatusActive": "Aktiv",
|
||||
"phaseStatusDone": "Fertig",
|
||||
"phaseStatusPending": "Ausstehend",
|
||||
@@ -173,6 +345,7 @@
|
||||
"statusRunning": "Laufend",
|
||||
"statusQueued": "In Warteschlange",
|
||||
"statusSucceeded": "Erfolgreich",
|
||||
"statusFinished": "Bereinigung abgeschlossen",
|
||||
"statusFailed": "Fehlgeschlagen",
|
||||
"statusCanceled": "Abgebrochen",
|
||||
"oauthStatusLabel": "OAuth Status"
|
||||
@@ -204,10 +377,10 @@
|
||||
"ruleConditionFrom": "From",
|
||||
"ruleConditionListUnsub": "List-Unsubscribe",
|
||||
"ruleConditionListId": "List-Id",
|
||||
"ruleActionMove": "Move",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionArchive": "Archive",
|
||||
"ruleActionLabel": "Label",
|
||||
"ruleActionMove": "Verschieben",
|
||||
"ruleActionDelete": "Löschen",
|
||||
"ruleActionArchive": "Archivieren",
|
||||
"ruleActionLabel": "Label setzen",
|
||||
"adminExportFormat": "Format",
|
||||
"exportFormatJson": "JSON",
|
||||
"exportFormatCsv": "CSV",
|
||||
@@ -250,11 +423,14 @@
|
||||
"toastMailboxDeleted": "Mailbox gelöscht.",
|
||||
"toastRuleSaved": "Regel gespeichert.",
|
||||
"toastRuleDeleted": "Regel gelöscht.",
|
||||
"toastRuleOrderSaved": "Reihenfolge aktualisiert.",
|
||||
"toastDeleteSelected": "{{deleted}} gelöscht · {{missing}} fehlend · {{failed}} fehlgeschlagen",
|
||||
"toastCleanupStarted": "Bereinigung gestartet.",
|
||||
"toastLoggedOut": "Ausgeloggt.",
|
||||
"toastExportQueued": "Export in Warteschlange.",
|
||||
"toastExportReady": "Export bereit.",
|
||||
"toastExportFailed": "Export fehlgeschlagen.",
|
||||
"toastDownloadFailed": "Download fehlgeschlagen.",
|
||||
"toastExportPurged": "Abgelaufene Exporte entfernt.",
|
||||
"toastExportDeleted": "Export gelöscht.",
|
||||
"toastTenantUpdated": "Tenant aktualisiert.",
|
||||
@@ -270,5 +446,6 @@
|
||||
"toastSettingsSaved": "Einstellungen gespeichert.",
|
||||
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.",
|
||||
"confirmDeleteSelected": "{{count}} ausgewählte Nachrichten löschen? Nachrichten können bereits gelöscht sein.",
|
||||
"adminDeleteJobConfirm": "Diesen Job samt Events löschen?"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
"adminGoogleRedirectUri": "Redirect URL",
|
||||
"adminCleanupScanLimit": "Max emails per cleanup",
|
||||
"adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.",
|
||||
"adminNewsletterSettings": "Newsletter detection",
|
||||
"adminNewsletterSettingsHint": "Signals and thresholds used to detect newsletters. Comma-separated lists. Weights define how much each signal adds to the score.",
|
||||
"adminNewsletterThreshold": "Minimum signals",
|
||||
"adminNewsletterHeaderKeys": "Header keys",
|
||||
"adminNewsletterHeaderKeysHint": "Comma-separated list headers (e.g. list-unsubscribe,list-id,...).",
|
||||
"adminNewsletterWeightHeader": "Weight: header matches",
|
||||
"adminNewsletterWeightPrecedence": "Weight: bulk/list precedence",
|
||||
"adminNewsletterSubjectTokens": "Subject keywords",
|
||||
"adminNewsletterSubjectTokensHint": "Comma-separated tokens matched against the subject.",
|
||||
"adminNewsletterWeightSubject": "Weight: subject match",
|
||||
"adminNewsletterFromTokens": "From keywords",
|
||||
"adminNewsletterFromTokensHint": "Comma-separated tokens matched against the sender.",
|
||||
"adminNewsletterWeightFrom": "Weight: from match",
|
||||
"adminUnsubscribeHistoryTtl": "Unsubscribe dedupe window (days)",
|
||||
"adminUnsubscribeHistoryTtlHint": "Prevents running unsubscribe again within this time window. Set to 0 to disable.",
|
||||
"adminUnsubscribeMethod": "Unsubscribe method preference",
|
||||
"adminUnsubscribeMethodHint": "Auto uses HTTP when available and falls back to mailto if HTTP fails.",
|
||||
"adminUnsubscribeMethodAuto": "Auto (HTTP → mailto fallback)",
|
||||
"adminUnsubscribeMethodHttp": "Prefer HTTP",
|
||||
"adminUnsubscribeMethodMailto": "Prefer mailto",
|
||||
"adminSaveSettings": "Save settings",
|
||||
"adminSaving": "Saving...",
|
||||
"adminSettingsSaved": "Saved",
|
||||
@@ -74,6 +94,7 @@
|
||||
"adminShowSecret": "Show secret",
|
||||
"adminHideSecret": "Hide secret",
|
||||
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
|
||||
"adminSettingsSourceNewsletter": "Sources - Threshold: {{threshold}}, Headers: {{headers}}, Subject tokens: {{subject}}, From tokens: {{from}}, Weights (header/precedence/subject/from): {{weightHeader}}/{{weightPrecedence}}/{{weightSubject}}/{{weightFrom}}, Unsubscribe history: {{history}}, Method: {{method}}",
|
||||
"selectAll": "Select all",
|
||||
"adminCancelSelected": "Cancel selected",
|
||||
"adminDeleteSelected": "Delete selected",
|
||||
@@ -94,30 +115,175 @@
|
||||
"mailboxCancelEdit": "Cancel",
|
||||
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
|
||||
"cleanupStart": "Start cleanup",
|
||||
"cleanupDryRun": "Dry run (no changes)",
|
||||
"cleanupDryRun": "Simulate only (no changes)",
|
||||
"cleanupUnsubscribe": "Unsubscribe enabled",
|
||||
"cleanupRouting": "Routing enabled",
|
||||
"cleanupDisabled": "Cleanup is not available yet.",
|
||||
"cleanupSelectMailbox": "Select a mailbox to start cleanup.",
|
||||
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
|
||||
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.",
|
||||
"cleanupDryRunHint": "Simulation only: routing and unsubscribe are simulated. No changes or emails are sent.",
|
||||
"cleanupUnsubscribeHint": "Tries to unsubscribe newsletters via List-Unsubscribe. In simulation mode, it only logs the checks.",
|
||||
"cleanupRoutingHint": "Applies your rules (move/delete/label). In simulation mode, it only simulates the actions.",
|
||||
"rulesTitle": "Rules",
|
||||
"rulesAdd": "Add rule",
|
||||
"rulesReorder": "Drag to reorder",
|
||||
"rulesAddTitle": "Create rule",
|
||||
"rulesEditTitle": "Edit rule",
|
||||
"rulesName": "Rule name",
|
||||
"rulesEnabled": "Rule enabled",
|
||||
"rulesMatchMode": "Match mode",
|
||||
"rulesMatchAll": "All conditions (AND)",
|
||||
"rulesMatchAny": "Any condition (OR)",
|
||||
"rulesMatchAnyLabel": "OR",
|
||||
"rulesStopOnMatch": "Stop after match (first match wins)",
|
||||
"rulesStopOnMatchBadge": "FIRST",
|
||||
"rulesConditions": "Conditions",
|
||||
"rulesActions": "Actions",
|
||||
"rulesAddCondition": "+ Add condition",
|
||||
"rulesAddAction": "+ Add action",
|
||||
"rulesSave": "Save rule",
|
||||
"rulesExampleHint": "Example: Unsubscribe status = OK + List-Unsubscribe = * → Action: Move to \"Newsletter-Abgemeldet\".",
|
||||
"remove": "Remove",
|
||||
"ruleConditionUnsubscribeStatus": "Unsubscribe status",
|
||||
"ruleConditionScore": "Newsletter score",
|
||||
"ruleConditionScorePlaceholder": "e.g. >=2",
|
||||
"ruleConditionHeaderMissing": "Header missing",
|
||||
"ruleConditionHeaderMissingPlaceholder": "Header name (e.g. List-Unsubscribe)",
|
||||
"ruleUnsubStatusAny": "Any",
|
||||
"ruleUnsubStatusOk": "OK",
|
||||
"ruleUnsubStatusDryRun": "Simulated",
|
||||
"ruleUnsubStatusFailed": "Failed",
|
||||
"ruleUnsubStatusSkipped": "Skipped",
|
||||
"ruleUnsubStatusDuplicate": "Skipped (duplicate)",
|
||||
"ruleUnsubStatusDisabled": "Disabled",
|
||||
"ruleActionMarkRead": "Mark as read",
|
||||
"ruleActionMarkUnread": "Mark as unread",
|
||||
"rulesOverview": "Rules overview",
|
||||
"rulesEmpty": "No rules yet.",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobsEmpty": "No jobs yet.",
|
||||
"jobCandidatesTitle": "Result details",
|
||||
"jobCandidatesHint": "Shows each newsletter candidate with actions, unsubscribe outcome, and metadata.",
|
||||
"jobCandidatesGroupBy": "Group by",
|
||||
"jobCandidatesGroupDomain": "Sender domain",
|
||||
"jobCandidatesGroupFrom": "From address",
|
||||
"jobCandidatesGroupListId": "List-ID",
|
||||
"jobCandidatesGroupNone": "No grouping",
|
||||
"jobCandidatesGroupsEmpty": "No newsletter candidates yet.",
|
||||
"jobCandidatesShowSignals": "Show details",
|
||||
"jobCandidatesCount": "{{count}} candidates",
|
||||
"jobCandidatesLoadMore": "Load more",
|
||||
"jobCandidatesBack": "Back to groups",
|
||||
"jobCandidatesRefresh": "Refresh",
|
||||
"jobCandidatesUnknown": "Unknown",
|
||||
"jobCandidatesSubject": "Subject",
|
||||
"jobCandidatesFrom": "From",
|
||||
"jobCandidatesListId": "List-ID",
|
||||
"jobCandidatesDate": "Date",
|
||||
"jobCandidatesActions": "Actions",
|
||||
"jobCandidatesUnsubscribe": "Unsubscribe",
|
||||
"jobCandidatesUnsubscribeNone": "No List-Unsubscribe header",
|
||||
"jobCandidatesUnsubscribeDisabled": "Disabled",
|
||||
"jobCandidatesUnsubscribeDryRun": "Simulated",
|
||||
"jobCandidatesUnsubscribeOk": "OK",
|
||||
"jobCandidatesUnsubscribeFailed": "Failed",
|
||||
"jobCandidatesUnsubscribeDuplicate": "Skipped (duplicate)",
|
||||
"resultsTitle": "Result details",
|
||||
"resultsHint": "Open a detailed list of all detected emails with a preview of each message.",
|
||||
"resultsOpen": "Open results",
|
||||
"resultsGroups": "Groups",
|
||||
"resultsGroupsDisabled": "Grouping disabled. Switch to a grouping option to see categories.",
|
||||
"resultsItems": "Messages",
|
||||
"resultsPreview": "Preview",
|
||||
"resultsSelectGroup": "Select a group to load its messages.",
|
||||
"resultsSelectItem": "Select a message to preview it.",
|
||||
"resultsPreviewLoading": "Loading preview...",
|
||||
"resultsPreviewEmpty": "No preview text available.",
|
||||
"resultsSearch": "Search subject/from/list",
|
||||
"resultsFilterAll": "All statuses",
|
||||
"resultsReviewedAll": "All",
|
||||
"resultsReviewed": "Reviewed",
|
||||
"resultsUnreviewed": "Unreviewed",
|
||||
"resultsExportCsv": "CSV",
|
||||
"resultsExportGroupCsv": "Group CSV",
|
||||
"resultsSelectAll": "All",
|
||||
"resultsMarkSelectedReviewed": "Selected reviewed",
|
||||
"resultsMarkSelectedUnreviewed": "Selected unreviewed",
|
||||
"resultsBulkSelect": "Unreviewed",
|
||||
"resultsBulkMarkReviewed": "All reviewed",
|
||||
"resultsDeleteSelected": "Delete selected",
|
||||
"resultsAttachments": "Attachments",
|
||||
"resultsDownloadAttachment": "Download",
|
||||
"resultsHistory": "Unsubscribe history",
|
||||
"resultsUnsubscribeDetails": "Unsubscribe details",
|
||||
"resultsUnsubscribeStatus": "Status",
|
||||
"resultsUnsubscribeMethod": "Method",
|
||||
"resultsUnsubscribeTarget": "Target",
|
||||
"resultsUnsubscribeMessage": "Result",
|
||||
"resultsListId": "List-ID",
|
||||
"resultsListUnsubscribe": "List-Unsubscribe",
|
||||
"resultsListUnsubscribePost": "List-Unsubscribe-Post",
|
||||
"resultsMailtoSubject": "Mailto subject",
|
||||
"resultsMailtoBody": "Mailto body",
|
||||
"resultsRequestMethod": "Request method",
|
||||
"resultsRequestUrl": "Request URL",
|
||||
"resultsResponseStatus": "Response status",
|
||||
"resultsResponseRedirect": "Redirect",
|
||||
"resultsMailtoVia": "Mail sent via",
|
||||
"resultsMailtoTo": "Mailto",
|
||||
"resultsMailtoReplyTo": "Reply-To",
|
||||
"resultsMailtoListUnsubscribe": "List-Unsubscribe header",
|
||||
"resultsUnsubscribeReason": "Reason",
|
||||
"resultsUnsubscribeError": "Error",
|
||||
"resultsLive": "Live",
|
||||
"resultsStatic": "Static",
|
||||
"resultsBackToJob": "Back to job",
|
||||
"jobCandidatesActionApplied": "Applied",
|
||||
"jobCandidatesActionDryRun": "Simulated",
|
||||
"jobCandidatesActionFailed": "Failed",
|
||||
"jobCandidatesActionSkipped": "Skipped",
|
||||
"jobCandidatesSignals": "Signals",
|
||||
"jobCandidatesScore": "Score",
|
||||
"jobCandidatesScoreHint": "Score is derived from subject, sender, header signals, and the newsletter detection model.",
|
||||
"jobCandidatesHeaderSignals": "Headers",
|
||||
"jobCandidatesSubjectSignals": "Subject matches",
|
||||
"jobCandidatesFromSignals": "From matches",
|
||||
"jobCandidatesPrecedenceSignal": "Bulk/List precedence",
|
||||
"jobEventCleanupStarted": "Cleanup started",
|
||||
"jobEventCleanupFinished": "Cleanup finished",
|
||||
"jobEventConnecting": "Connecting to {{email}}",
|
||||
"jobEventListingGmail": "Listing Gmail messages",
|
||||
"jobEventListedGmail": "Listed {{count}} Gmail messages so far",
|
||||
"jobEventPreparedGmail": "Prepared {{count}} Gmail messages",
|
||||
"jobEventResumeGmail": "Resuming Gmail cleanup at {{current}}/{{total}}",
|
||||
"jobEventProcessingGmail": "Processing {{count}} Gmail messages",
|
||||
"jobEventNoGmail": "No Gmail messages to process",
|
||||
"jobEventFoundMailboxes": "Found {{count}} mailboxes",
|
||||
"jobEventScanningMailbox": "Scanning {{mailbox}}",
|
||||
"jobEventPreparedImap": "Prepared {{count}} IMAP messages",
|
||||
"jobEventResumeImap": "Resuming IMAP cleanup at {{current}}/{{total}}",
|
||||
"jobEventProcessingImap": "Processing {{count}} IMAP messages",
|
||||
"jobEventNoImap": "No IMAP messages to process",
|
||||
"jobEventDetectedCandidates": "Detected {{count}} newsletter candidates",
|
||||
"jobEventProcessed": "Processed {{current}}/{{total}}",
|
||||
"jobEventProcessedCount": "Processed {{current}}",
|
||||
"jobEventGmailActionAppliedList": "Gmail action applied: {{actions}}",
|
||||
"jobEventGmailActionApplied": "Gmail action applied: {{action}}",
|
||||
"jobEventGmailActionSkippedNoChanges": "Gmail action skipped: no label changes",
|
||||
"jobEventGmailActionFailedSimple": "Gmail action failed: {{error}}",
|
||||
"jobEventGmailActionFailed": "Gmail action failed ({{action}}): {{error}}",
|
||||
"jobEventImapActionFailed": "IMAP action failed ({{action}}): {{error}}",
|
||||
"jobEventDryRunAction": "Simulate only: {{action}}",
|
||||
"jobEventCanceledByAdmin": "Job canceled by admin",
|
||||
"jobEventCanceledBeforeStart": "Job canceled before start",
|
||||
"jobEventFailed": "Job failed: {{error}}",
|
||||
"loadingCandidates": "Loading candidates...",
|
||||
"jobsProgress": "Progress",
|
||||
"jobsEta": "ETA",
|
||||
"jobsEtaDone": "Done",
|
||||
"jobsEtaQueued": "Queued",
|
||||
"jobsEtaRecalculating": "Recalculating ETA…",
|
||||
"jobsEtaCalculating": "Calculating…",
|
||||
"jobDetailsTitle": "Job details",
|
||||
"jobNoEvents": "No events yet.",
|
||||
"jobEvents": "Job events",
|
||||
@@ -128,7 +294,13 @@
|
||||
"phaseProcessingPending": "Waiting for processing.",
|
||||
"phaseUnsubscribePending": "Waiting for unsubscribe.",
|
||||
"phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.",
|
||||
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Dry run {{dryRun}} ({{total}} total).",
|
||||
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} total).",
|
||||
"phaseUnsubscribeSummaryWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} · Simulated {{dryRun}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
|
||||
"phaseUnsubscribeSummaryNoDryRun": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} total).",
|
||||
"phaseUnsubscribeSummaryNoDryRunWithProcessed": "Unsubscribed {{ok}} · Failed {{failed}} ({{total}} candidates, {{processed}}/{{overall}} processed).",
|
||||
"phaseUnsubscribeDryRunPending": "Simulation: waiting for unsubscribe checks.",
|
||||
"phaseUnsubscribeDryRunSummary": "Simulation: {{dryRun}} unsubscribe checks ({{total}} total).",
|
||||
"phaseUnsubscribeDryRunSummaryWithProcessed": "Simulation: {{dryRun}} unsubscribe checks ({{total}} candidates, {{processed}}/{{overall}} processed).",
|
||||
"phaseStatusActive": "Active",
|
||||
"phaseStatusDone": "Done",
|
||||
"phaseStatusPending": "Pending",
|
||||
@@ -173,6 +345,7 @@
|
||||
"statusRunning": "Running",
|
||||
"statusQueued": "Queued",
|
||||
"statusSucceeded": "Succeeded",
|
||||
"statusFinished": "Cleanup finished",
|
||||
"statusFailed": "Failed",
|
||||
"statusCanceled": "Canceled",
|
||||
"oauthStatusLabel": "OAuth status"
|
||||
@@ -250,11 +423,14 @@
|
||||
"toastMailboxDeleted": "Mailbox deleted.",
|
||||
"toastRuleSaved": "Rule saved.",
|
||||
"toastRuleDeleted": "Rule deleted.",
|
||||
"toastRuleOrderSaved": "Rule order updated.",
|
||||
"toastDeleteSelected": "{{deleted}} deleted · {{missing}} missing · {{failed}} failed",
|
||||
"toastCleanupStarted": "Cleanup job started.",
|
||||
"toastLoggedOut": "Logged out.",
|
||||
"toastExportQueued": "Export queued.",
|
||||
"toastExportReady": "Export ready.",
|
||||
"toastExportFailed": "Export failed.",
|
||||
"toastDownloadFailed": "Download failed.",
|
||||
"toastExportPurged": "Expired exports purged.",
|
||||
"toastExportDeleted": "Export deleted.",
|
||||
"toastTenantUpdated": "Tenant updated.",
|
||||
@@ -270,5 +446,6 @@
|
||||
"toastSettingsSaved": "Settings saved.",
|
||||
"toastSettingsFailed": "Settings save failed.",
|
||||
"confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.",
|
||||
"confirmDeleteSelected": "Delete {{count}} selected messages? Messages might already be deleted.",
|
||||
"adminDeleteJobConfirm": "Delete this job and all its events?"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -370,6 +374,13 @@ button.ghost {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
border-radius: 999px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin: 16px 0 18px;
|
||||
padding: 12px 14px;
|
||||
@@ -490,6 +501,48 @@ select {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-with-action {
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.icon-only {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-button.icon-only {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rule-block {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
@@ -521,19 +574,197 @@ select {
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.rule-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 8px;
|
||||
border-radius: 14px;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.rule-item .rule-details {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.rule-item.drag-over {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.rule-item.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.rule-order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-order .order-badge {
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--primary-strong);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-item .drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rule-item .drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.rule-item .rule-details {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rule-tail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rule-flags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rule-badge.rule-badge-strong {
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
opacity: 0.85;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.list-item > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-item p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.list-item strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list-item .badge {
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.list-item .badge strong {
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
justify-items: end;
|
||||
min-width: 140px;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.list-actions .ghost {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.list-actions .ghost {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.list-actions.icon-actions {
|
||||
min-width: auto;
|
||||
justify-items: end;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min-content;
|
||||
}
|
||||
|
||||
.list-actions.icon-actions .ghost {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-item .list-actions {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.job-item .job-action {
|
||||
min-width: 32px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.job-row:hover {
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.job-meta span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.list-item .rule-badge {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.rule-item .list-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.events {
|
||||
@@ -690,6 +921,578 @@ select {
|
||||
height: min(92vh, 1000px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results-modal {
|
||||
width: min(1400px, 98vw);
|
||||
height: min(92vh, 1100px);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.results-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.results-toolbar {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.02);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.results-toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-toolbar-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-toolbar input,
|
||||
.results-toolbar select {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.results-toolbar .search-input {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.results-toolbar .field-row {
|
||||
grid-template-columns: auto auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.results-toolbar .field-row span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.results-toolbar .toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.results-toolbar .toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: none;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.results-toolbar .toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
max-width: 14px;
|
||||
max-height: 14px;
|
||||
line-height: 14px;
|
||||
appearance: checkbox;
|
||||
accent-color: #0f172a;
|
||||
}
|
||||
|
||||
/* fallback for global checkbox scaling */
|
||||
.results-toolbar input[type="checkbox"] {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.results-toolbar-actions button {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.results-toolbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.results-group-list li button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.results-group-list li.group-new {
|
||||
animation: groupIn 0.35s ease-out;
|
||||
}
|
||||
|
||||
.results-group-list li.group-updated button {
|
||||
animation: groupPulse 0.55s ease-out;
|
||||
}
|
||||
|
||||
.results-group-list li.group-moved button {
|
||||
animation: groupMove 0.35s ease-out;
|
||||
}
|
||||
|
||||
@keyframes groupIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes groupPulse {
|
||||
0% {
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
60% {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
100% {
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes groupMove {
|
||||
0% {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
100% {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.results-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 0.7fr) minmax(420px, 1.6fr) minmax(360px, 1.7fr);
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.results-layout {
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(380px, 1.4fr) minmax(320px, 1.3fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.results-layout {
|
||||
grid-template-columns: minmax(180px, 0.65fr) minmax(320px, 1.2fr) minmax(280px, 1.1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.results-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.results-panel h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.results-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.results-list li {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.results-group-list li {
|
||||
grid-template-columns: 1fr;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.results-message-row {
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-list li button {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results-message-list li button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.results-message-list li button > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.results-message-content {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.results-message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.results-message-history {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.results-message-status {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.results-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.results-row-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px 0 2px;
|
||||
}
|
||||
|
||||
.results-row-history {
|
||||
grid-column: 2 / span 2;
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.results-row-select input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.results-message-list .toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toggle.compact {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
color: var(--muted);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.live-indicator.live {
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
color: #15803d;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.rule-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-strong);
|
||||
border: 1px solid rgba(37, 99, 235, 0.3);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.results-list li button span {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.results-list li button strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results-list li button div span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results-list li button.active {
|
||||
border-color: rgba(37, 99, 235, 0.5);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.results-preview {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.preview-subject strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview-badge-action {
|
||||
cursor: pointer;
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
.preview-details {
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.preview-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.preview-detail-grid span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-detail-grid strong {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
white-space: pre-wrap;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-attachments {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-attachments ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-attachments li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-attachments .att-meta {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.preview-attachments .ghost.small {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.results-cta {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.results-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.results-modal {
|
||||
height: auto;
|
||||
}
|
||||
.preview-frame,
|
||||
.preview-text {
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.job-hero {
|
||||
@@ -880,6 +1683,102 @@ select {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.job-candidates {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.candidate-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.candidate-toolbar .inline-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.candidate-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.candidate-group {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.candidate-group:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.candidate-group span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-item {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.candidate-item h5 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.candidate-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.candidate-signal-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.candidate-action-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.candidate-chip {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user