feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Paperclip,
|
||||
Star,
|
||||
Mail,
|
||||
MailOpen,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchFilters } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface AdvancedSearchPanelProps {
|
||||
filters: SearchFilters;
|
||||
isOpen: boolean;
|
||||
onFiltersChange: (filters: Partial<SearchFilters>) => void;
|
||||
onClear: () => void;
|
||||
onSearch: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSearchPanel({
|
||||
filters,
|
||||
isOpen,
|
||||
onFiltersChange,
|
||||
onClear,
|
||||
onSearch,
|
||||
onClose,
|
||||
}: AdvancedSearchPanelProps) {
|
||||
const t = useTranslations("advanced_search");
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debouncedSearch = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onSearch();
|
||||
}, 300);
|
||||
}, [onSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTextChange = (field: keyof SearchFilters, value: string) => {
|
||||
onFiltersChange({ [field]: value });
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const handleToggle = (field: "hasAttachment" | "isUnread" | "isStarred", current: boolean | null) => {
|
||||
const next = current === null ? true : current === true ? false : null;
|
||||
onFiltersChange({ [field]: next });
|
||||
onSearch();
|
||||
};
|
||||
|
||||
const handleDateChange = (field: "dateAfter" | "dateBefore", value: string) => {
|
||||
onFiltersChange({ [field]: value });
|
||||
onSearch();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
onClear();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{t("title")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="h-7 px-2 text-xs">
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("from")}</label>
|
||||
<Input
|
||||
value={filters.from}
|
||||
onChange={(e) => handleTextChange("from", e.target.value)}
|
||||
placeholder={t("from_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("to")}</label>
|
||||
<Input
|
||||
value={filters.to}
|
||||
onChange={(e) => handleTextChange("to", e.target.value)}
|
||||
placeholder={t("to_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("subject")}</label>
|
||||
<Input
|
||||
value={filters.subject}
|
||||
onChange={(e) => handleTextChange("subject", e.target.value)}
|
||||
placeholder={t("subject_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("date_after")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateAfter}
|
||||
onChange={(e) => handleDateChange("dateAfter", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("date_before")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateBefore}
|
||||
onChange={(e) => handleDateChange("dateBefore", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleFilterButton
|
||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||
label={t("has_attachment")}
|
||||
value={filters.hasAttachment}
|
||||
onClick={() => handleToggle("hasAttachment", filters.hasAttachment)}
|
||||
/>
|
||||
<ToggleFilterButton
|
||||
icon={<Star className="w-3.5 h-3.5" />}
|
||||
label={t("starred")}
|
||||
value={filters.isStarred}
|
||||
onClick={() => handleToggle("isStarred", filters.isStarred)}
|
||||
/>
|
||||
<ToggleFilterButton
|
||||
icon={filters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||
label={filters.isUnread === false ? t("read") : t("unread")}
|
||||
value={filters.isUnread}
|
||||
onClick={() => handleToggle("isUnread", filters.isUnread)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleFilterButton({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchFilters } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface SearchChipsProps {
|
||||
filters: SearchFilters;
|
||||
onRemoveFilter: (key: keyof SearchFilters) => void;
|
||||
onClearAll: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchChips({
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
onClearAll,
|
||||
className,
|
||||
}: SearchChipsProps) {
|
||||
const t = useTranslations("advanced_search");
|
||||
|
||||
const chips: { key: keyof SearchFilters; label: string; value: string }[] = [];
|
||||
|
||||
if (filters.from) {
|
||||
chips.push({ key: "from", label: t("from"), value: filters.from });
|
||||
}
|
||||
if (filters.to) {
|
||||
chips.push({ key: "to", label: t("to"), value: filters.to });
|
||||
}
|
||||
if (filters.subject) {
|
||||
chips.push({ key: "subject", label: t("subject"), value: filters.subject });
|
||||
}
|
||||
if (filters.body) {
|
||||
chips.push({ key: "body", label: t("body"), value: filters.body });
|
||||
}
|
||||
if (filters.hasAttachment !== null) {
|
||||
chips.push({
|
||||
key: "hasAttachment",
|
||||
label: t("has_attachment"),
|
||||
value: filters.hasAttachment ? t("yes") : t("no"),
|
||||
});
|
||||
}
|
||||
if (filters.dateAfter) {
|
||||
chips.push({ key: "dateAfter", label: t("date_after"), value: filters.dateAfter });
|
||||
}
|
||||
if (filters.dateBefore) {
|
||||
chips.push({ key: "dateBefore", label: t("date_before"), value: filters.dateBefore });
|
||||
}
|
||||
if (filters.isUnread !== null) {
|
||||
chips.push({
|
||||
key: "isUnread",
|
||||
label: filters.isUnread ? t("unread") : t("read"),
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
if (filters.isStarred !== null) {
|
||||
chips.push({
|
||||
key: "isStarred",
|
||||
label: t("starred"),
|
||||
value: filters.isStarred ? t("yes") : t("no"),
|
||||
});
|
||||
}
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2 border-b border-border bg-muted/20 flex items-center gap-2 flex-wrap", className)}>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
<span className="font-medium">{chip.label}</span>
|
||||
{chip.value && (
|
||||
<>
|
||||
<span className="text-primary/60">:</span>
|
||||
<span className="max-w-24 truncate">{chip.value}</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFilter(chip.key)}
|
||||
className="ml-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{chips.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user