mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
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:
+27
-177
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Ласкаво просимо до вашої поштової скриньки",
|
||||
|
||||
@@ -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
@@ -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": "欢迎使用邮箱",
|
||||
|
||||
Reference in New Issue
Block a user