feat: make the mail filter panel simpler with on/off toggles, date and size ranges and every field in view

This commit is contained in:
Linus Rath
2026-10-28 19:38:46 +01:00
parent 8916a4ef75
commit 720e146225
32 changed files with 768 additions and 206 deletions
+27 -177
View File
@@ -90,6 +90,7 @@ import { jmapErrorType } from "@/lib/jmap/jmap-error";
import { isFilterEmpty, activeFilterCount, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import { FilterPanelHost } from "@/components/search/filter-panel-host";
import { SearchFilterPanel, SELECT_CLASS } from "@/components/search/search-filter-panel";
import type { ContactSuggestion } from "@/lib/search-suggestions";
import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
@@ -110,7 +111,6 @@ import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
@@ -120,7 +120,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -259,7 +259,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [moveDialogEmail, setMoveDialogEmail] = useState<Email | null>(null);
// The scheduled message Reschedule was picked for in the list's menu.
const [rescheduleTarget, setRescheduleTarget] = useState<Email | null>(null);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
// Column resize state (disable transitions during drag)
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(0);
@@ -3781,10 +3780,20 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
clearTimeout(advancedSearchDebounceRef.current);
}
advancedSearchDebounceRef.current = setTimeout(() => {
advancedSearchDebounceRef.current = null;
if (client) advancedSearch(client);
}, 300);
}, [client, advancedSearch]);
// Runs the search at once, dropping a run still waiting on the debounce.
const runAdvancedSearchNow = async () => {
if (advancedSearchDebounceRef.current) {
clearTimeout(advancedSearchDebounceRef.current);
advancedSearchDebounceRef.current = null;
}
await handleAdvancedSearch();
};
useEffect(() => {
return () => {
if (advancedSearchDebounceRef.current) {
@@ -4580,22 +4589,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
};
const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
)}
>
{icon}
{label}
</button>
);
// The folder the advanced search runs in. Scopes the search only - it does
// not navigate the mail list. `rerun` re-queries straight away when a search
// is running; Mountain View's form waits for its Search button instead.
@@ -5118,165 +5111,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
false,
) : undefined}
/>
) : (<>
{/* Quick toggle filters + clear */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<ToggleChip
icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("advanced_search.has_attachment")}
value={searchFilters.hasAttachment}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={<Star className="w-3.5 h-3.5" />}
label={t("advanced_search.starred")}
value={searchFilters.isStarred}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
value={searchFilters.isUnread}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
/>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
<RotateCcw className="w-3 h-3 me-1" />
{t("advanced_search.clear")}
</Button>
</div>
</div>
{/* "More" expand for advanced fields */}
<button
type="button"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown className={cn("w-3.5 h-3.5 transition-transform", showAdvancedFields && "rotate-180")} />
<span>{t("advanced_search.title")}</span>
</button>
{/* Advanced fields */}
{showAdvancedFields && (
<div className="space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.from")}</label>
<Input
value={searchFilters.from}
onChange={(e) => { setSearchFilters({ from: e.target.value }); handleAdvancedSearchDebounced(); }}
placeholder={t("advanced_search.from_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.to")}</label>
<Input
value={searchFilters.to}
onChange={(e) => { setSearchFilters({ to: e.target.value }); handleAdvancedSearchDebounced(); }}
placeholder={t("advanced_search.to_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.subject")}</label>
<Input
value={searchFilters.subject}
onChange={(e) => { setSearchFilters({ subject: e.target.value }); handleAdvancedSearchDebounced(); }}
placeholder={t("advanced_search.subject_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.body")}</label>
<Input
value={searchFilters.body}
onChange={(e) => { setSearchFilters({ body: e.target.value }); handleAdvancedSearchDebounced(); }}
placeholder={t("advanced_search.body_placeholder")}
className="h-8 text-sm"
/>
</div>
{/* Folder selector. Scopes the search only - it does not
navigate the mail list. It defaults to "All folders
except Spam and Trash", or to Spam/Trash itself while
that folder is open (defaultSearchScopeFor), and keeps
whatever the user picked. Both folder-less scopes span
the own AND the group/shared accounts' folders (#1082);
"All folders" also searches Trash and Junk. The shared
folders are listed under their owner, and subfolders
are indented under their parent as in the sidebar
(a native select has no tree, so the indent is
non-breaking spaces, as in the filter rule modal).
The unified views already search across every
account's folders, so it is hidden there. */}
{!isUnifiedView && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
{renderSearchFolderSelect(
"w-full h-8 text-sm rounded-md border border-input bg-background px-3 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
true,
)}
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_after")}</label>
<Input
type="date"
value={searchFilters.dateAfter}
onChange={(e) => { setSearchFilters({ dateAfter: e.target.value }); handleAdvancedSearch(); }}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_before")}</label>
<Input
type="date"
value={searchFilters.dateBefore}
onChange={(e) => { setSearchFilters({ dateBefore: e.target.value }); handleAdvancedSearch(); }}
className="h-8 text-sm"
/>
</div>
</div>
{/* Message size (Email/query minSize / maxSize, RFC 8621 §4.4.1) */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.size_min")}</label>
<Input
type="number"
min={1}
inputMode="numeric"
value={searchFilters.minSizeKb}
onChange={(e) => { setSearchFilters({ minSizeKb: e.target.value }); handleAdvancedSearch(); }}
className="h-8 text-sm"
data-testid="advanced-search-min-size"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.size_max")}</label>
<Input
type="number"
min={1}
inputMode="numeric"
value={searchFilters.maxSizeKb}
onChange={(e) => { setSearchFilters({ maxSizeKb: e.target.value }); handleAdvancedSearch(); }}
className="h-8 text-sm"
data-testid="advanced-search-max-size"
/>
</div>
</div>
</div>
) : (
<SearchFilterPanel
filters={searchFilters}
onChange={(patch, mode) => {
setSearchFilters(patch);
if (mode === "debounced") handleAdvancedSearchDebounced();
else void runAdvancedSearchNow();
}}
onClearAll={() => { clearSearchFilters(); void runAdvancedSearchNow(); }}
onDone={() => {
if (advancedSearchDebounceRef.current) void runAdvancedSearchNow();
toggleAdvancedSearch();
}}
folderSelect={!isUnifiedView ? renderSearchFolderSelect(SELECT_CLASS, true) : undefined}
/>
)}
</>)}
</FilterPanelHost>
)}
</div>
@@ -0,0 +1,90 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { DEFAULT_SEARCH_FILTERS, type SearchFilters } from '@/lib/jmap/search-utils';
import { SearchFilterPanel, daysAgo } from '../search-filter-panel';
function renderPanel(filters: Partial<SearchFilters> = {}) {
const onChange = vi.fn();
const onClearAll = vi.fn();
const onDone = vi.fn();
render(
<SearchFilterPanel
filters={{ ...DEFAULT_SEARCH_FILTERS, ...filters }}
onChange={onChange}
onClearAll={onClearAll}
onDone={onDone}
/>,
);
return { onChange, onClearAll, onDone };
}
describe('SearchFilterPanel', () => {
it('turns Unread and Read on and off, without a hidden third state', () => {
const { onChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: 'unread' }));
expect(onChange).toHaveBeenLastCalledWith({ isUnread: true }, 'now');
fireEvent.click(screen.getByRole('button', { name: 'read' }));
expect(onChange).toHaveBeenLastCalledWith({ isUnread: false }, 'now');
});
it('marks an active chip as pressed and turns it off on the next click', () => {
const { onChange } = renderPanel({ isStarred: true });
const starred = screen.getByRole('button', { name: 'starred' });
expect(starred.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(starred);
expect(onChange).toHaveBeenLastCalledWith({ isStarred: null }, 'now');
});
it('shows every field at once, and debounces the text ones', () => {
const { onChange } = renderPanel();
fireEvent.change(screen.getByLabelText('from'), { target: { value: 'alice' } });
expect(onChange).toHaveBeenLastCalledWith({ from: 'alice' }, 'debounced');
expect(screen.getByLabelText('has_words')).toBeTruthy();
expect(screen.getByLabelText('date_within')).toBeTruthy();
});
it('turns a date range pick into an after date', () => {
const { onChange } = renderPanel();
fireEvent.change(screen.getByLabelText('date_within'), { target: { value: 'past_week' } });
expect(onChange).toHaveBeenLastCalledWith({ dateAfter: daysAgo(7), dateBefore: '' }, 'now');
});
it('recognises its own range and offers the two dates for anything else', () => {
renderPanel({ dateAfter: daysAgo(30) });
expect((screen.getByLabelText('date_within') as HTMLSelectElement).value).toBe('past_month');
expect(screen.queryByLabelText('date_after')).toBeNull();
});
it('opens the custom range with both dates', () => {
renderPanel({ dateAfter: '2026-01-01', dateBefore: '2026-02-01' });
expect((screen.getByLabelText('date_within') as HTMLSelectElement).value).toBe('custom');
expect((screen.getByLabelText('date_after') as HTMLInputElement).value).toBe('2026-01-01');
});
it('shows the custom fields as soon as Custom is picked, before any date is set', () => {
const { onChange } = renderPanel();
fireEvent.change(screen.getByLabelText('date_within'), { target: { value: 'custom' } });
expect(screen.getByLabelText('date_after')).toBeTruthy();
expect(onChange).not.toHaveBeenCalled();
});
it('turns a size pick into a lower bound in KB', () => {
const { onChange } = renderPanel();
fireEvent.change(screen.getByLabelText('size'), { target: { value: 'mb5' } });
expect(onChange).toHaveBeenLastCalledWith({ minSizeKb: '5120', maxSizeKb: '' }, 'now');
});
it('offers Clear all only when a filter is set', () => {
renderPanel();
expect(screen.queryByRole('button', { name: /clear_all/ })).toBeNull();
});
it('clears everything and closes on Done or Enter', () => {
const { onClearAll, onDone } = renderPanel({ subject: 'invoice' });
fireEvent.click(screen.getByRole('button', { name: /clear_all/ }));
expect(onClearAll).toHaveBeenCalledTimes(1);
fireEvent.submit(screen.getByLabelText('subject').closest('form')!);
fireEvent.click(screen.getByRole('button', { name: 'done' }));
expect(onDone).toHaveBeenCalledTimes(2);
});
});
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useId, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { Check, Mail, MailOpen, Paperclip, RotateCcw, Star } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { activeFilterCount, type SearchFilters } from "@/lib/jmap/search-utils";
import { cn } from "@/lib/utils";
/** How a change runs the search: straight away, or after typing pauses. */
export type FilterChangeMode = "now" | "debounced";
interface SearchFilterPanelProps {
filters: SearchFilters;
onChange: (patch: Partial<SearchFilters>, mode: FilterChangeMode) => void;
onClearAll: () => void;
/** Runs any pending search and closes the panel (Done, or Enter in a field). */
onDone: () => void;
/** The folder picker, shared with Mountain View's form. */
folderSelect?: ReactNode;
}
const DATE_PRESETS = [
{ key: "past_week", days: 7 },
{ key: "past_month", days: 30 },
{ key: "past_year", days: 365 },
] as const;
const SIZE_PRESETS_MB = [1, 5, 10, 25] as const;
export const SELECT_CLASS =
"w-full h-8 text-sm rounded-md border border-input bg-background px-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1";
/** `days` before today as the yyyy-mm-dd a date input holds, in local time. */
export function daysAgo(days: number, now = new Date()): string {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function datePresetOf(filters: SearchFilters): string {
if (!filters.dateAfter && !filters.dateBefore) return "any";
if (!filters.dateBefore) {
const preset = DATE_PRESETS.find((p) => daysAgo(p.days) === filters.dateAfter);
if (preset) return preset.key;
}
return "custom";
}
function sizePresetOf(filters: SearchFilters): string {
if (!filters.minSizeKb && !filters.maxSizeKb) return "any";
if (!filters.maxSizeKb) {
const mb = SIZE_PRESETS_MB.find((m) => String(m * 1024) === filters.minSizeKb);
if (mb) return `mb${mb}`;
}
return "custom";
}
/**
* The default layout's filter panel, under the list header's search field.
* Every criterion is in view at once, labelled on the left; the yes/no ones
* are on/off chips, the date and size offer the usual ranges with a custom
* one behind them. Changes run the search as they are made, so the list
* behind already shows the result when Done closes the panel.
*/
export function SearchFilterPanel({ filters, onChange, onClearAll, onDone, folderSelect }: SearchFilterPanelProps) {
const t = useTranslations("advanced_search");
const id = useId();
// "Custom" with nothing typed yet has no value to tell it from "Any".
const [customDate, setCustomDate] = useState(false);
const [customSize, setCustomSize] = useState(false);
const datePreset = customDate ? "custom" : datePresetOf(filters);
const sizePreset = customSize ? "custom" : sizePresetOf(filters);
const count = activeFilterCount(filters);
const row = (key: string, label: string, field: ReactNode) => (
<>
<label htmlFor={`${id}-${key}`} className="text-xs text-muted-foreground max-w-[7rem] leading-tight">
{label}
</label>
<div className="min-w-0">{field}</div>
</>
);
const text = (key: "from" | "to" | "subject" | "body", placeholder?: string) => (
<Input
id={`${id}-${key}`}
value={filters[key]}
placeholder={placeholder}
onChange={(e) => onChange({ [key]: e.target.value }, "debounced")}
className="h-8 text-sm"
/>
);
return (
<form
data-search-filter-panel=""
onSubmit={(e) => {
e.preventDefault();
onDone();
}}
className="space-y-3"
>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("toggle_filters")}>
<Chip
icon={<Mail className="w-3.5 h-3.5" />}
label={t("unread")}
on={filters.isUnread === true}
onClick={() => onChange({ isUnread: filters.isUnread === true ? null : true }, "now")}
/>
<Chip
icon={<MailOpen className="w-3.5 h-3.5" />}
label={t("read")}
on={filters.isUnread === false}
onClick={() => onChange({ isUnread: filters.isUnread === false ? null : false }, "now")}
/>
<Chip
icon={<Star className="w-3.5 h-3.5" />}
// "Not starred" only comes from elsewhere; shown so it can be undone.
label={filters.isStarred === false ? t("not_starred") : t("starred")}
on={filters.isStarred !== null}
onClick={() => onChange({ isStarred: filters.isStarred === null ? true : null }, "now")}
/>
<Chip
icon={<Paperclip className="w-3.5 h-3.5" />}
label={filters.hasAttachment === false ? t("no_attachment") : t("has_attachment")}
on={filters.hasAttachment !== null}
onClick={() => onChange({ hasAttachment: filters.hasAttachment === null ? true : null }, "now")}
/>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2">
{row("from", t("from"), text("from", t("name_or_address")))}
{row("to", t("to"), text("to", t("name_or_address")))}
{row("subject", t("subject"), text("subject"))}
{row("body", t("has_words"), text("body"))}
{row("date", t("date_within"), (
<select
id={`${id}-date`}
value={datePreset}
onChange={(e) => {
const key = e.target.value;
setCustomDate(key === "custom");
if (key === "custom") return;
const preset = DATE_PRESETS.find((p) => p.key === key);
onChange({ dateAfter: preset ? daysAgo(preset.days) : "", dateBefore: "" }, "now");
}}
className={SELECT_CLASS}
>
<option value="any">{t("any_time")}</option>
{DATE_PRESETS.map((p) => (
<option key={p.key} value={p.key}>{t(p.key)}</option>
))}
<option value="custom">{t("custom_range")}</option>
</select>
))}
{datePreset === "custom" && (
<div className="col-start-2 grid grid-cols-2 gap-2">
<Input
type="date"
aria-label={t("date_after")}
title={t("date_after")}
value={filters.dateAfter}
onChange={(e) => onChange({ dateAfter: e.target.value }, "now")}
className="h-8 text-sm"
/>
<Input
type="date"
aria-label={t("date_before")}
title={t("date_before")}
value={filters.dateBefore}
onChange={(e) => onChange({ dateBefore: e.target.value }, "now")}
className="h-8 text-sm"
/>
</div>
)}
{row("size", t("size"), (
<select
id={`${id}-size`}
value={sizePreset}
onChange={(e) => {
const key = e.target.value;
setCustomSize(key === "custom");
if (key === "custom") return;
const mb = SIZE_PRESETS_MB.find((m) => `mb${m}` === key);
onChange({ minSizeKb: mb ? String(mb * 1024) : "", maxSizeKb: "" }, "now");
}}
className={SELECT_CLASS}
>
<option value="any">{t("any_size")}</option>
{SIZE_PRESETS_MB.map((mb) => (
<option key={mb} value={`mb${mb}`}>{t("larger_than_mb", { size: mb })}</option>
))}
<option value="custom">{t("custom_size")}</option>
</select>
))}
{sizePreset === "custom" && (
<div className="col-start-2 grid grid-cols-2 gap-2">
<Input
type="number"
min={1}
inputMode="numeric"
aria-label={t("size_min")}
placeholder={t("size_min")}
title={t("size_min")}
value={filters.minSizeKb}
onChange={(e) => onChange({ minSizeKb: e.target.value }, "debounced")}
className="h-8 text-sm"
data-testid="advanced-search-min-size"
/>
<Input
type="number"
min={1}
inputMode="numeric"
aria-label={t("size_max")}
placeholder={t("size_max")}
title={t("size_max")}
value={filters.maxSizeKb}
onChange={(e) => onChange({ maxSizeKb: e.target.value }, "debounced")}
className="h-8 text-sm"
data-testid="advanced-search-max-size"
/>
</div>
)}
{folderSelect && row("folder", t("search_in"), folderSelect)}
</div>
<div className="flex items-center justify-end gap-2">
{count > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCustomDate(false);
setCustomSize(false);
onClearAll();
}}
className="h-8 px-2.5 text-xs text-muted-foreground"
>
<RotateCcw className="w-3.5 h-3.5 me-1" />
{t("clear_all")}
</Button>
)}
<Button type="submit" size="sm" className="h-8 px-4 text-xs">
{t("done")}
</Button>
</div>
</form>
);
}
function Chip({ icon, label, on, onClick }: { icon: ReactNode; label: string; on: boolean; onClick: () => void }) {
return (
<button
type="button"
aria-pressed={on}
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs border transition-colors",
on
? "bg-primary/10 border-primary/40 text-primary"
: "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
)}
>
{on ? <Check className="w-3.5 h-3.5" /> : icon}
{label}
</button>
);
}
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "التاريخ",
"search_in": "البحث في",
"has_attachment_check": "يحتوي على مرفق",
"search": "بحث"
"search": "بحث",
"not_starred": "بدون نجمة",
"no_attachment": "بدون مرفقات",
"any_time": "أي وقت",
"past_week": "الأسبوع الماضي",
"past_month": "الشهر الماضي",
"past_year": "السنة الماضية",
"custom_range": "نطاق مخصص",
"any_size": "أي حجم",
"larger_than_mb": "أكبر من {size} ميغابايت",
"custom_size": "حجم مخصص",
"done": "تم",
"name_or_address": "الاسم أو العنوان"
},
"welcome": {
"title": "مرحبًا بك في صندوق بريدك",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Data",
"search_in": "Cerca a",
"has_attachment_check": "Té fitxers adjunts",
"search": "Cerca"
"search": "Cerca",
"not_starred": "Sense destacar",
"no_attachment": "Sense adjunts",
"any_time": "Qualsevol data",
"past_week": "Darrera setmana",
"past_month": "Darrer mes",
"past_year": "Darrer any",
"custom_range": "Interval personalitzat",
"any_size": "Qualsevol mida",
"larger_than_mb": "Més de {size} MB",
"custom_size": "Mida personalitzada",
"done": "Fet",
"name_or_address": "Nom o adreça"
},
"welcome": {
"title": "Us donem la benvinguda a la bústia",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Datum",
"search_in": "Hledat v",
"has_attachment_check": "Má přílohu",
"search": "Hledat"
"search": "Hledat",
"not_starred": "Bez hvězdičky",
"no_attachment": "Bez příloh",
"any_time": "Kdykoli",
"past_week": "Poslední týden",
"past_month": "Poslední měsíc",
"past_year": "Poslední rok",
"custom_range": "Vlastní období",
"any_size": "Libovolná velikost",
"larger_than_mb": "Větší než {size} MB",
"custom_size": "Vlastní velikost",
"done": "Hotovo",
"name_or_address": "Jméno nebo adresa"
},
"welcome": {
"title": "Vítejte ve své poštovní schránce",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Dato",
"search_in": "Søg i",
"has_attachment_check": "Har vedhæftet fil",
"search": "Søg"
"search": "Søg",
"not_starred": "Uden stjerne",
"no_attachment": "Uden vedhæftninger",
"any_time": "Når som helst",
"past_week": "Seneste uge",
"past_month": "Seneste måned",
"past_year": "Seneste år",
"custom_range": "Tilpasset periode",
"any_size": "Alle størrelser",
"larger_than_mb": "Større end {size} MB",
"custom_size": "Tilpasset størrelse",
"done": "Færdig",
"name_or_address": "Navn eller adresse"
},
"welcome": {
"title": "Velkommen til din postkasse",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Datum",
"search_in": "Suchen in",
"has_attachment_check": "Mit Anhang",
"search": "Suchen"
"search": "Suchen",
"not_starred": "Nicht markiert",
"no_attachment": "Ohne Anhänge",
"any_time": "Beliebig",
"past_week": "Letzte Woche",
"past_month": "Letzter Monat",
"past_year": "Letztes Jahr",
"custom_range": "Eigener Zeitraum",
"any_size": "Beliebig",
"larger_than_mb": "Größer als {size} MB",
"custom_size": "Eigene Größe",
"done": "Fertig",
"name_or_address": "Name oder Adresse"
},
"welcome": {
"title": "Willkommen in Ihrem Postfach",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Date",
"search_in": "Search in",
"has_attachment_check": "Has attachment",
"search": "Search"
"search": "Search",
"not_starred": "Not starred",
"no_attachment": "No attachments",
"any_time": "Any time",
"past_week": "Past week",
"past_month": "Past month",
"past_year": "Past year",
"custom_range": "Custom range",
"any_size": "Any size",
"larger_than_mb": "Larger than {size} MB",
"custom_size": "Custom size",
"done": "Done",
"name_or_address": "Name or address"
},
"welcome": {
"title": "Welcome to your mailbox",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Fecha",
"search_in": "Buscar en",
"has_attachment_check": "Tiene archivos adjuntos",
"search": "Buscar"
"search": "Buscar",
"not_starred": "Sin destacar",
"no_attachment": "Sin adjuntos",
"any_time": "Cualquier fecha",
"past_week": "Última semana",
"past_month": "Último mes",
"past_year": "Último año",
"custom_range": "Intervalo personalizado",
"any_size": "Cualquier tamaño",
"larger_than_mb": "Más de {size} MB",
"custom_size": "Tamaño personalizado",
"done": "Listo",
"name_or_address": "Nombre o dirección"
},
"welcome": {
"title": "Bienvenido a tu buzón",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Kuupäev",
"search_in": "Otsi kaustast",
"has_attachment_check": "Sisaldab manust",
"search": "Otsi"
"search": "Otsi",
"not_starred": "Tähistamata",
"no_attachment": "Manusteta",
"any_time": "Igal ajal",
"past_week": "Viimane nädal",
"past_month": "Viimane kuu",
"past_year": "Viimane aasta",
"custom_range": "Kohandatud vahemik",
"any_size": "Iga suurus",
"larger_than_mb": "Suurem kui {size} MB",
"custom_size": "Kohandatud suurus",
"done": "Valmis",
"name_or_address": "Nimi või aadress"
},
"welcome": {
"title": "Tere tulemast sinu postkasti",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "تاریخ",
"search_in": "جستجو در",
"has_attachment_check": "دارای پیوست",
"search": "جستجو"
"search": "جستجو",
"not_starred": "بدون ستاره",
"no_attachment": "بدون پیوست",
"any_time": "هر زمان",
"past_week": "هفته گذشته",
"past_month": "ماه گذشته",
"past_year": "سال گذشته",
"custom_range": "بازه دلخواه",
"any_size": "هر اندازه",
"larger_than_mb": "بزرگ‌تر از {size} مگابایت",
"custom_size": "اندازه دلخواه",
"done": "انجام شد",
"name_or_address": "نام یا نشانی"
},
"welcome": {
"title": "به صندوق پستی خود خوش آمدید",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Date",
"search_in": "Rechercher dans",
"has_attachment_check": "Contient une pièce jointe",
"search": "Rechercher"
"search": "Rechercher",
"not_starred": "Non suivis",
"no_attachment": "Sans pièces jointes",
"any_time": "N'importe quand",
"past_week": "Semaine dernière",
"past_month": "Mois dernier",
"past_year": "Année dernière",
"custom_range": "Période personnalisée",
"any_size": "Toutes tailles",
"larger_than_mb": "Plus de {size} Mo",
"custom_size": "Taille personnalisée",
"done": "Terminé",
"name_or_address": "Nom ou adresse"
},
"welcome": {
"title": "Bienvenue dans votre boite mail",
+13 -1
View File
@@ -4241,7 +4241,19 @@
"date_within": "תאריך",
"search_in": "חיפוש ב",
"has_attachment_check": "יש קובץ מצורף",
"search": "חיפוש"
"search": "חיפוש",
"not_starred": "ללא כוכב",
"no_attachment": "ללא קבצים מצורפים",
"any_time": "בכל זמן",
"past_week": "השבוע האחרון",
"past_month": "החודש האחרון",
"past_year": "השנה האחרונה",
"custom_range": "טווח מותאם",
"any_size": "כל גודל",
"larger_than_mb": "גדול מ-{size} MB",
"custom_size": "גודל מותאם",
"done": "סיום",
"name_or_address": "שם או כתובת"
},
"welcome": {
"title": "ברוך הבא לתיבת הדואר שלך",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Dátum",
"search_in": "Keresés itt",
"has_attachment_check": "Van melléklete",
"search": "Keresés"
"search": "Keresés",
"not_starred": "Csillag nélkül",
"no_attachment": "Csatolmány nélkül",
"any_time": "Bármikor",
"past_week": "Elmúlt hét",
"past_month": "Elmúlt hónap",
"past_year": "Elmúlt év",
"custom_range": "Egyéni időszak",
"any_size": "Bármilyen méret",
"larger_than_mb": "Nagyobb mint {size} MB",
"custom_size": "Egyéni méret",
"done": "Kész",
"name_or_address": "Név vagy cím"
},
"welcome": {
"title": "Üdvözlünk a postafiókodban",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Data",
"search_in": "Cerca in",
"has_attachment_check": "Con allegati",
"search": "Cerca"
"search": "Cerca",
"not_starred": "Non preferiti",
"no_attachment": "Senza allegati",
"any_time": "Qualsiasi data",
"past_week": "Ultima settimana",
"past_month": "Ultimo mese",
"past_year": "Ultimo anno",
"custom_range": "Intervallo personalizzato",
"any_size": "Qualsiasi dimensione",
"larger_than_mb": "Più di {size} MB",
"custom_size": "Dimensione personalizzata",
"done": "Fatto",
"name_or_address": "Nome o indirizzo"
},
"welcome": {
"title": "Benvenuto nella tua casella di posta",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "日付",
"search_in": "検索対象",
"has_attachment_check": "添付ファイルあり",
"search": "検索"
"search": "検索",
"not_starred": "スターなし",
"no_attachment": "添付ファイルなし",
"any_time": "期間指定なし",
"past_week": "過去 1 週間",
"past_month": "過去 1 か月",
"past_year": "過去 1 年",
"custom_range": "期間を指定",
"any_size": "サイズ指定なし",
"larger_than_mb": "{size} MB より大きい",
"custom_size": "サイズを指定",
"done": "完了",
"name_or_address": "名前またはアドレス"
},
"welcome": {
"title": "メールボックスへようこそ",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "날짜",
"search_in": "검색 위치",
"has_attachment_check": "첨부파일 있음",
"search": "검색"
"search": "검색",
"not_starred": "별표 없음",
"no_attachment": "첨부파일 없음",
"any_time": "모든 기간",
"past_week": "지난 1주",
"past_month": "지난 1개월",
"past_year": "지난 1년",
"custom_range": "기간 직접 지정",
"any_size": "모든 크기",
"larger_than_mb": "{size}MB 초과",
"custom_size": "크기 직접 지정",
"done": "완료",
"name_or_address": "이름 또는 주소"
},
"welcome": {
"title": "웹메일에 오신 것을 환영해요!",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Datums",
"search_in": "Meklēt",
"has_attachment_check": "Ar pielikumu",
"search": "Meklēt"
"search": "Meklēt",
"not_starred": "Bez zvaigznītes",
"no_attachment": "Bez pielikumiem",
"any_time": "Jebkurā laikā",
"past_week": "Pēdējā nedēļa",
"past_month": "Pēdējais mēnesis",
"past_year": "Pēdējais gads",
"custom_range": "Pielāgots periods",
"any_size": "Jebkurš izmērs",
"larger_than_mb": "Lielāks par {size} MB",
"custom_size": "Pielāgots izmērs",
"done": "Gatavs",
"name_or_address": "Vārds vai adrese"
},
"welcome": {
"title": "Laipni lūdzam jūsu pastkastē",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Огноо",
"search_in": "Хайх газар",
"has_attachment_check": "Хавсралттай",
"search": "Хайх"
"search": "Хайх",
"not_starred": "Одгүй",
"no_attachment": "Хавсралтгүй",
"any_time": "Хэзээ ч",
"past_week": "Өнгөрсөн долоо хоног",
"past_month": "Өнгөрсөн сар",
"past_year": "Өнгөрсөн жил",
"custom_range": "Өөрийн хугацаа",
"any_size": "Ямар ч хэмжээ",
"larger_than_mb": "{size} MB-аас их",
"custom_size": "Өөрийн хэмжээ",
"done": "Болсон",
"name_or_address": "Нэр эсвэл хаяг"
},
"welcome": {
"title": "Таны шуудангийн хайрцагт тавтай морил",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Dato",
"search_in": "Søk i",
"has_attachment_check": "Har vedlegg",
"search": "Søk"
"search": "Søk",
"not_starred": "Uten stjerne",
"no_attachment": "Uten vedlegg",
"any_time": "Når som helst",
"past_week": "Siste uke",
"past_month": "Siste måned",
"past_year": "Siste år",
"custom_range": "Egendefinert periode",
"any_size": "Alle størrelser",
"larger_than_mb": "Større enn {size} MB",
"custom_size": "Egendefinert størrelse",
"done": "Ferdig",
"name_or_address": "Navn eller adresse"
},
"welcome": {
"title": "Velkommen til postkassen din",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Datum",
"search_in": "Zoeken in",
"has_attachment_check": "Met bijlage",
"search": "Zoeken"
"search": "Zoeken",
"not_starred": "Zonder ster",
"no_attachment": "Zonder bijlagen",
"any_time": "Altijd",
"past_week": "Afgelopen week",
"past_month": "Afgelopen maand",
"past_year": "Afgelopen jaar",
"custom_range": "Aangepaste periode",
"any_size": "Elke grootte",
"larger_than_mb": "Groter dan {size} MB",
"custom_size": "Aangepaste grootte",
"done": "Klaar",
"name_or_address": "Naam of adres"
},
"welcome": {
"title": "Welkom bij uw mailbox",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Data",
"search_in": "Szukaj w",
"has_attachment_check": "Z załącznikiem",
"search": "Szukaj"
"search": "Szukaj",
"not_starred": "Bez gwiazdki",
"no_attachment": "Bez załączników",
"any_time": "Dowolny czas",
"past_week": "Ostatni tydzień",
"past_month": "Ostatni miesiąc",
"past_year": "Ostatni rok",
"custom_range": "Własny zakres",
"any_size": "Dowolny rozmiar",
"larger_than_mb": "Większe niż {size} MB",
"custom_size": "Własny rozmiar",
"done": "Gotowe",
"name_or_address": "Imię lub adres"
},
"welcome": {
"title": "Witaj w swojej skrzynce pocztowej",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Data",
"search_in": "Pesquisar em",
"has_attachment_check": "Com anexo",
"search": "Pesquisar"
"search": "Pesquisar",
"not_starred": "Não favoritos",
"no_attachment": "Sem anexos",
"any_time": "Qualquer data",
"past_week": "Última semana",
"past_month": "Último mês",
"past_year": "Último ano",
"custom_range": "Período personalizado",
"any_size": "Qualquer tamanho",
"larger_than_mb": "Maior que {size} MB",
"custom_size": "Tamanho personalizado",
"done": "Concluído",
"name_or_address": "Nome ou endereço"
},
"welcome": {
"title": "Bem-vindo à sua caixa de entrada",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Dată",
"search_in": "Caută în",
"has_attachment_check": "Are atașament",
"search": "Caută"
"search": "Caută",
"not_starred": "Fără stea",
"no_attachment": "Fără anexe",
"any_time": "Oricând",
"past_week": "Ultima săptămână",
"past_month": "Ultima lună",
"past_year": "Ultimul an",
"custom_range": "Interval personalizat",
"any_size": "Orice dimensiune",
"larger_than_mb": "Mai mare de {size} MB",
"custom_size": "Dimensiune personalizată",
"done": "Gata",
"name_or_address": "Nume sau adresă"
},
"welcome": {
"title": "Bine ați venit în căsuța dvs. poștală",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Дата",
"search_in": "Искать в",
"has_attachment_check": "Есть вложения",
"search": "Найти"
"search": "Найти",
"not_starred": "Без звёздочки",
"no_attachment": "Без вложений",
"any_time": "За всё время",
"past_week": "За неделю",
"past_month": "За месяц",
"past_year": "За год",
"custom_range": "Свой период",
"any_size": "Любой размер",
"larger_than_mb": "Больше {size} МБ",
"custom_size": "Свой размер",
"done": "Готово",
"name_or_address": "Имя или адрес"
},
"welcome": {
"title": "Добро пожаловать в ваш почтовый ящик",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Dátum",
"search_in": "Hľadať v",
"has_attachment_check": "Má prílohu",
"search": "Hľadať"
"search": "Hľadať",
"not_starred": "Bez hviezdičky",
"no_attachment": "Bez príloh",
"any_time": "Kedykoľvek",
"past_week": "Posledný týždeň",
"past_month": "Posledný mesiac",
"past_year": "Posledný rok",
"custom_range": "Vlastné obdobie",
"any_size": "Ľubovoľná veľkosť",
"larger_than_mb": "Väčšie ako {size} MB",
"custom_size": "Vlastná veľkosť",
"done": "Hotovo",
"name_or_address": "Meno alebo adresa"
},
"welcome": {
"title": "Vitajte vo svojej poštovej schránke",
+13 -1
View File
@@ -953,7 +953,19 @@
"date_within": "วันที่",
"search_in": "ค้นหาใน",
"has_attachment_check": "มีไฟล์แนบ",
"search": "ค้นหา"
"search": "ค้นหา",
"not_starred": "ไม่ติดดาว",
"no_attachment": "ไม่มีไฟล์แนบ",
"any_time": "ทุกเวลา",
"past_week": "สัปดาห์ที่แล้ว",
"past_month": "เดือนที่แล้ว",
"past_year": "ปีที่แล้ว",
"custom_range": "กำหนดช่วงเอง",
"any_size": "ทุกขนาด",
"larger_than_mb": "ใหญ่กว่า {size} MB",
"custom_size": "กำหนดขนาดเอง",
"done": "เสร็จสิ้น",
"name_or_address": "ชื่อหรือที่อยู่"
},
"global_search": {
"title": "ค้นหาทุกรายการ",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "Tarih",
"search_in": "Şurada ara",
"has_attachment_check": "Eki var",
"search": "Ara"
"search": "Ara",
"not_starred": "Yıldızsız",
"no_attachment": "Eki olmayan",
"any_time": "Herhangi bir zaman",
"past_week": "Geçen hafta",
"past_month": "Geçen ay",
"past_year": "Geçen yıl",
"custom_range": "Özel aralık",
"any_size": "Herhangi bir boyut",
"larger_than_mb": "{size} MB'tan büyük",
"custom_size": "Özel boyut",
"done": "Bitti",
"name_or_address": "Ad veya adres"
},
"welcome": {
"title": "Posta kutunuza hoş geldiniz",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "Дата",
"search_in": "Шукати в",
"has_attachment_check": "Є вкладення",
"search": "Шукати"
"search": "Шукати",
"not_starred": "Без зірочки",
"no_attachment": "Без додатків",
"any_time": "За весь час",
"past_week": "За тиждень",
"past_month": "За місяць",
"past_year": "За рік",
"custom_range": "Власний період",
"any_size": "Будь-який розмір",
"larger_than_mb": "Більше {size} МБ",
"custom_size": "Власний розмір",
"done": "Готово",
"name_or_address": "Ім'я або адреса"
},
"welcome": {
"title": "Ласкаво просимо до вашої поштової скриньки",
+13 -1
View File
@@ -4452,7 +4452,19 @@
"date_within": "日期",
"search_in": "搜尋範圍",
"has_attachment_check": "包含附件",
"search": "搜尋"
"search": "搜尋",
"not_starred": "未加星號",
"no_attachment": "無附件",
"any_time": "任何時間",
"past_week": "過去一週",
"past_month": "過去一個月",
"past_year": "過去一年",
"custom_range": "自訂範圍",
"any_size": "任意大小",
"larger_than_mb": "大於 {size} MB",
"custom_size": "自訂大小",
"done": "完成",
"name_or_address": "姓名或地址"
},
"welcome": {
"title": "歡迎使用您的信件匣",
+13 -1
View File
@@ -4323,7 +4323,19 @@
"date_within": "日期",
"search_in": "搜索范围",
"has_attachment_check": "包含附件",
"search": "搜索"
"search": "搜索",
"not_starred": "未加星标",
"no_attachment": "无附件",
"any_time": "任何时间",
"past_week": "过去一周",
"past_month": "过去一个月",
"past_year": "过去一年",
"custom_range": "自定义范围",
"any_size": "任意大小",
"larger_than_mb": "大于 {size} MB",
"custom_size": "自定义大小",
"done": "完成",
"name_or_address": "姓名或地址"
},
"welcome": {
"title": "欢迎使用邮箱",