feat: add expanded visual view for filter rules
- Add VisualRuleSummary component showing conditions and actions as labeled inline pills with IF/THEN flow layout - Add expandedFilterView toggle to settings store (persisted) - Fix RuleSummary to allow multi-line wrapping instead of truncating - Use items-start on rule cards so drag handle and toggle align to top - Add translations for expanded view keys in all 8 locales
This commit is contained in:
@@ -9,6 +9,7 @@ import { SieveEditorModal } from "@/components/filters/sieve-editor-modal";
|
||||
import { useFilterStore } from "@/stores/filter-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||
import {
|
||||
@@ -25,31 +26,98 @@ import {
|
||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
const conditionSummary = rule.conditions
|
||||
.slice(0, 2)
|
||||
.map((c) => {
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return `${field} ${comparator} "${c.value}"`;
|
||||
})
|
||||
.join(rule.matchType === "all" ? ` ${t("and")} ` : ` ${t("or")} `);
|
||||
});
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
|
||||
const extra = rule.conditions.length > 2
|
||||
? ` (+${rule.conditions.length - 2})`
|
||||
: "";
|
||||
|
||||
const actionSummary = rule.actions
|
||||
.slice(0, 2)
|
||||
.map((a) => {
|
||||
const actions = rule.actions.slice(0, 2).map((a) => {
|
||||
const action = t(`action_types.${a.type}`);
|
||||
return a.value ? `${action} "${a.value}"` : action;
|
||||
})
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{conditionSummary}{extra} → {actionSummary}
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
<span className="inline">
|
||||
{conditions.map((cond, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="italic opacity-70"> {joiner} </span>}
|
||||
{cond}
|
||||
</span>
|
||||
))}
|
||||
{extra}
|
||||
</span>
|
||||
<span className="mx-1 opacity-50">→</span>
|
||||
<span className="inline">
|
||||
{actions.map((act, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ", "}
|
||||
{act}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition");
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1 text-xs">
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">
|
||||
{t("if")}
|
||||
</span>
|
||||
{rule.conditions.map((c, i) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/70 italic">{joiner}</span>
|
||||
)}
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="text-muted-foreground">{comparator}</span>
|
||||
<span className="text-foreground">“{c.value}”</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">({matchLabel})</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||||
{t("then")}
|
||||
</span>
|
||||
{rule.actions.map((a, i) => {
|
||||
const action = t(`action_types.${a.type}`);
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground/50">›</span>
|
||||
)}
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
|
||||
{a.value && <span className="text-muted-foreground">“{a.value}”</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +126,8 @@ export function FilterSettings() {
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const { client } = useAuthStore();
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const {
|
||||
rules,
|
||||
@@ -337,23 +407,25 @@ export function FilterSettings() {
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-center gap-3 p-3 rounded-md border transition-colors ${
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
@@ -374,7 +446,11 @@ export function FilterSettings() {
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
@@ -435,12 +511,23 @@ export function FilterSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("saving")}
|
||||
</div>
|
||||
)}
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
|
||||
<ToggleSwitch
|
||||
checked={expandedFilterView}
|
||||
onChange={(v) => updateSetting("expandedFilterView", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRuleModal && (
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Dieses Skript wurde außerhalb des visuellen Builders bearbeitet. Nur die Sieve-Skriptbearbeitung ist verfügbar.",
|
||||
"open_sieve_editor": "Sieve-Skript-Editor öffnen",
|
||||
"fetch_error": "Filter konnten nicht geladen werden",
|
||||
"expanded_view": "Erweiterte Ansicht",
|
||||
"expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen",
|
||||
"if": "Wenn",
|
||||
"then": "Dann",
|
||||
"match_all_conditions": "alle zutreffen",
|
||||
"match_any_condition": "eine zutrifft",
|
||||
"and": "und",
|
||||
"or": "oder",
|
||||
"cancel": "Abbrechen",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
|
||||
"open_sieve_editor": "Open raw Sieve editor",
|
||||
"fetch_error": "Failed to load filters",
|
||||
"expanded_view": "Expanded view",
|
||||
"expanded_view_description": "Show filter rules with detailed condition and action blocks",
|
||||
"if": "If",
|
||||
"then": "Then",
|
||||
"match_all_conditions": "all match",
|
||||
"match_any_condition": "any matches",
|
||||
"and": "and",
|
||||
"or": "or",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Este script fue editado fuera del constructor visual. Solo está disponible la edición Sieve sin formato.",
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Error al cargar los filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones",
|
||||
"if": "Si",
|
||||
"then": "Entonces",
|
||||
"match_all_conditions": "todas coinciden",
|
||||
"match_any_condition": "alguna coincide",
|
||||
"and": "y",
|
||||
"or": "o",
|
||||
"cancel": "Cancelar",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Ce script a été modifié en dehors du constructeur visuel. Seule l'édition Sieve brute est disponible.",
|
||||
"open_sieve_editor": "Ouvrir l'éditeur Sieve brut",
|
||||
"fetch_error": "Échec du chargement des filtres",
|
||||
"expanded_view": "Vue étendue",
|
||||
"expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés",
|
||||
"if": "Si",
|
||||
"then": "Alors",
|
||||
"match_all_conditions": "toutes correspondent",
|
||||
"match_any_condition": "une correspond",
|
||||
"and": "et",
|
||||
"or": "ou",
|
||||
"cancel": "Annuler",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Questo script è stato modificato al di fuori del costruttore visuale. È disponibile solo la modifica Sieve grezza.",
|
||||
"open_sieve_editor": "Apri editor Sieve",
|
||||
"fetch_error": "Impossibile caricare i filtri",
|
||||
"expanded_view": "Vista espansa",
|
||||
"expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni",
|
||||
"if": "Se",
|
||||
"then": "Allora",
|
||||
"match_all_conditions": "tutte corrispondono",
|
||||
"match_any_condition": "una corrisponde",
|
||||
"and": "e",
|
||||
"or": "o",
|
||||
"cancel": "Annulla",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "このスクリプトはビジュアルビルダーの外部で編集されました。Sieveスクリプトの直接編集のみ可能です。",
|
||||
"open_sieve_editor": "Sieveスクリプトエディタを開く",
|
||||
"fetch_error": "フィルターの読み込みに失敗しました",
|
||||
"expanded_view": "詳細表示",
|
||||
"expanded_view_description": "フィルタールールを条件とアクションのブロックで表示",
|
||||
"if": "条件",
|
||||
"then": "実行",
|
||||
"match_all_conditions": "すべて一致",
|
||||
"match_any_condition": "いずれか一致",
|
||||
"and": "かつ",
|
||||
"or": "または",
|
||||
"cancel": "キャンセル",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Dit script is buiten de visuele builder bewerkt. Alleen Sieve-scriptbewerking is beschikbaar.",
|
||||
"open_sieve_editor": "Sieve-scripteditor openen",
|
||||
"fetch_error": "Filters konden niet worden geladen",
|
||||
"expanded_view": "Uitgebreide weergave",
|
||||
"expanded_view_description": "Filterregels weergeven met gedetailleerde voorwaarde- en actieblokken",
|
||||
"if": "Als",
|
||||
"then": "Dan",
|
||||
"match_all_conditions": "alle overeenkomen",
|
||||
"match_any_condition": "een overeenkomt",
|
||||
"and": "en",
|
||||
"or": "of",
|
||||
"cancel": "Annuleren",
|
||||
|
||||
@@ -1183,6 +1183,12 @@
|
||||
"opaque_warning": "Este script foi editado fora do construtor visual. Apenas a edição Sieve bruta está disponível.",
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Falha ao carregar filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"expanded_view_description": "Mostrar regras de filtro com blocos detalhados de condições e ações",
|
||||
"if": "Se",
|
||||
"then": "Então",
|
||||
"match_all_conditions": "todas correspondem",
|
||||
"match_any_condition": "uma corresponde",
|
||||
"and": "e",
|
||||
"or": "ou",
|
||||
"cancel": "Cancelar",
|
||||
|
||||
@@ -119,6 +119,9 @@ interface SettingsState {
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
trustedSenders: string[]; // Email addresses that can load external content
|
||||
|
||||
// Filters
|
||||
expandedFilterView: boolean;
|
||||
|
||||
// Calendar
|
||||
showTimeInMonthView: boolean;
|
||||
showWeekNumbers: boolean;
|
||||
@@ -220,6 +223,9 @@ const DEFAULT_SETTINGS = {
|
||||
sessionTimeout: 0, // Never
|
||||
trustedSenders: [] as string[],
|
||||
|
||||
// Filters
|
||||
expandedFilterView: false,
|
||||
|
||||
// Calendar
|
||||
showTimeInMonthView: false,
|
||||
showWeekNumbers: false,
|
||||
@@ -308,6 +314,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
calendarNotificationSound: state.calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||
expandedFilterView: state.expandedFilterView,
|
||||
showTimeInMonthView: state.showTimeInMonthView,
|
||||
showWeekNumbers: state.showWeekNumbers,
|
||||
toolbarPosition: state.toolbarPosition,
|
||||
|
||||
Reference in New Issue
Block a user