feat: revamp contact detail, filters, and add photo/print/duplicate
This commit is contained in:
@@ -98,10 +98,10 @@ export default function ContactsPage() {
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||
});
|
||||
const [isListResizing, setIsListResizing] = useState(false);
|
||||
const listDragStartWidth = useRef(320);
|
||||
const listDragStartWidth = useRef(384);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
@@ -172,21 +172,6 @@ export default function ContactsPage() {
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
|
||||
// Label for the current category
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (activeCategory === "all") return t("tabs.all");
|
||||
if (activeCategory === "uncategorized") return t("no_category");
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return activeCategory.keyword;
|
||||
}
|
||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||
}, [activeCategory, contacts, addressBooks, t]);
|
||||
|
||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||
setActiveCategory(category);
|
||||
clearSelection();
|
||||
@@ -306,6 +291,24 @@ export default function ContactsPage() {
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
@@ -607,6 +610,16 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
@@ -702,7 +715,6 @@ export default function ContactsPage() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
categoryLabel={categoryLabel}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
@@ -726,7 +738,7 @@ export default function ContactsPage() {
|
||||
setIsListResizing(false);
|
||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
||||
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
}, 200);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}, [query, addParticipant]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
key={`${p.email}-${i}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||
>
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove(p.email);
|
||||
setQuery(p.email);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||
>
|
||||
{p.name || p.email}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||
const deleteButton = screen.getAllByRole('button').find(
|
||||
btn => btn.className.includes('text-red')
|
||||
);
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(screen.getByLabelText('detail.more_actions'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ const defaultProps = {
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
categoryLabel: 'All Contacts',
|
||||
selectedContactIds: new Set<string>(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectRangeContacts: vi.fn(),
|
||||
@@ -79,8 +78,4 @@ describe('ContactList', () => {
|
||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category label with count', () => {
|
||||
render(<ContactList {...defaultProps} />);
|
||||
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Mail, CalendarDays, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Section } from "./contact-detail";
|
||||
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -43,14 +44,15 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||
if (!event.participants) return false;
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
function getEmailSender(email: Email): { name: string; address: string } {
|
||||
const from = email.from?.[0];
|
||||
return {
|
||||
name: from?.name?.trim() || from?.email || "",
|
||||
address: from?.email || "",
|
||||
};
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const groups = new Map<string, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const d = new Date(e.start);
|
||||
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
|
||||
const arr = groups.get(key) || [];
|
||||
arr.push(e);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
const t = useTranslations("contacts.activity");
|
||||
const format = useFormatter();
|
||||
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
: { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatEventDate = (event: CalendarEvent) => {
|
||||
const formatEventTime = (event: CalendarEvent) => {
|
||||
if (event.showWithoutTime) return t("all_day");
|
||||
const d = new Date(event.start);
|
||||
if (isNaN(d.getTime())) return event.start;
|
||||
if (event.showWithoutTime) {
|
||||
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
return format.dateTime(d, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const formatEventDateHeader = (isoDate: string) => {
|
||||
const d = new Date(isoDate);
|
||||
if (isNaN(d.getTime())) return isoDate;
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return format.dateTime(d, sameYear
|
||||
? { weekday: "long", month: "long", day: "numeric" }
|
||||
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
};
|
||||
|
||||
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivitySection icon={Mail} title={t("recent_emails")}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
|
||||
<Section title={t("recent_emails")}>
|
||||
{emailsLoading ? (
|
||||
<LoadingRow />
|
||||
) : emailsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
<div className="-mx-2">
|
||||
{emails.map((email) => {
|
||||
const sender = getEmailSender(email);
|
||||
return (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{sender.name || t("unknown_sender")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ActivitySection>
|
||||
</Section>
|
||||
|
||||
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
|
||||
<Section title={t("upcoming_events")}>
|
||||
{eventsLoading ? (
|
||||
<LoadingRow />
|
||||
) : eventsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEventDate(event)}
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
{eventGroups.map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{formatEventDateHeader(dateKey)}
|
||||
</div>
|
||||
<div className="-mx-2">
|
||||
{group.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||
{formatEventTime(event)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1 min-w-0">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ActivitySection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">{children}</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,16 +11,25 @@ import {
|
||||
Eye,
|
||||
Pencil,
|
||||
Mail,
|
||||
Phone,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
Users,
|
||||
Trash2,
|
||||
Copy,
|
||||
Printer,
|
||||
} from "lucide-react";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
function getContactPrimaryPhone(contact: ContactCard): string {
|
||||
if (!contact.phones) return "";
|
||||
return Object.values(contact.phones)[0]?.number || "";
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -55,12 +65,14 @@ export function ContactContextMenu({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onDuplicate,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const phone = getContactPrimaryPhone(contact);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
@@ -73,10 +85,15 @@ export function ContactContextMenu({
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCopyEmail = async () => {
|
||||
if (!email) return;
|
||||
const handleCall = () => {
|
||||
if (!phone) return;
|
||||
window.location.href = `tel:${phone}`;
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t("detail.copied"));
|
||||
} catch {
|
||||
toast.error(t("detail.copy_failed"));
|
||||
@@ -88,6 +105,10 @@ export function ContactContextMenu({
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printContact(contact);
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
@@ -122,20 +143,34 @@ export function ContactContextMenu({
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{(email || phone) && <ContextMenuSeparator />}
|
||||
{email && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(handleCopyEmail)}
|
||||
/>
|
||||
</>
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
<ContextMenuItem
|
||||
icon={Phone}
|
||||
label={t("context_menu.call")}
|
||||
onClick={handle(handleCall)}
|
||||
/>
|
||||
)}
|
||||
{email && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(() => handleCopy(email))}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_phone")}
|
||||
onClick={handle(() => handleCopy(phone))}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -143,11 +178,23 @@ export function ContactContextMenu({
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
onClick={handle(handleExport)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Printer}
|
||||
label={t("context_menu.print")}
|
||||
onClick={handle(handlePrint)}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
|
||||
type MoreItem =
|
||||
| {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
separator?: false;
|
||||
}
|
||||
| { separator: true };
|
||||
|
||||
interface ContactDetailProps {
|
||||
contact: ContactCard | null;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||
}
|
||||
|
||||
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === "object" && dateInput !== null) {
|
||||
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
|
||||
const d = new Date(dateInput.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith("--")) {
|
||||
const parts = s.substring(2).split("-");
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
const m = month ?? 1;
|
||||
const d = day ?? 1;
|
||||
const nowM = now.getMonth() + 1;
|
||||
const nowD = now.getDate();
|
||||
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||
if (years < 0) return null;
|
||||
return years;
|
||||
}
|
||||
|
||||
function formatDate(dateInput: AnniversaryDate): string {
|
||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||
try {
|
||||
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return String(dateInput.utc);
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
const year = pd.year;
|
||||
const month = pd.month;
|
||||
const day = pd.day;
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const parts: string[] = [];
|
||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
||||
if (day) parts.push(String(day));
|
||||
if (year) parts.push(String(year));
|
||||
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
|
||||
if (pd.day) parts.push(String(pd.day));
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
return parts.join(' ') || String(dateInput);
|
||||
}
|
||||
const dateStr = String(dateInput);
|
||||
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
|
||||
if (dateStr.startsWith("--")) {
|
||||
// Partial date without year
|
||||
const parts = dateStr.substring(2).split("-");
|
||||
const month = parseInt(parts[0], 10);
|
||||
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
// data URI - extract base64 content
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
@@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
// PEM-encoded certificate inline
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const photoUri = getContactPhotoUri(contact);
|
||||
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||
|
||||
const handleExport = () => {
|
||||
exportContact(contact);
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printContact(contact, name);
|
||||
};
|
||||
|
||||
const moreItems: MoreItem[] = [];
|
||||
if (onAddToGroup) {
|
||||
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||
}
|
||||
if (onDuplicate) {
|
||||
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||
}
|
||||
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
|
||||
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||
moreItems.push({ separator: true });
|
||||
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
@@ -171,257 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
const hasNickname = nicknames.length > 0;
|
||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
{titleLine && (
|
||||
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
||||
)}
|
||||
{orgs.length > 0 && orgs[0].name && (
|
||||
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
||||
{subtitleParts.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t("detail.compose_email")}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
<a
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-1" />
|
||||
{t("context_menu.call")}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{/* Contact info */}
|
||||
{emails.length > 0 && (
|
||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
||||
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
|
||||
{hasContactDetails && (
|
||||
<Section title={t("detail.section_contact")}>
|
||||
<div className="space-y-3">
|
||||
{emails.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
||||
{e.address}
|
||||
</a>
|
||||
{e.contexts && <ContextBadge contexts={e.contexts} />}
|
||||
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
|
||||
<div className={cn(
|
||||
"flex items-center gap-0.5 transition-opacity",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}>
|
||||
<a
|
||||
href={`mailto:${e.address}`}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||
title={t("detail.compose_email")}
|
||||
aria-label={t("detail.compose_email")}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
|
||||
<div className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
|
||||
{e.address}
|
||||
</a>
|
||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
<RowActions>
|
||||
<a
|
||||
href={`mailto:${e.address}`}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||
title={t("detail.compose_email")}
|
||||
aria-label={t("detail.compose_email")}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{phones.length > 0 && (
|
||||
<Section icon={Phone} title={t("detail.phones")} category="contact">
|
||||
{phones.map((p, i) => {
|
||||
const featureStr = formatPhoneFeatures(p.features);
|
||||
const features = formatPhoneFeatures(p.features);
|
||||
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
{p.number}
|
||||
</a>
|
||||
{p.contexts && <ContextBadge contexts={p.contexts} />}
|
||||
{featureStr && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
|
||||
)}
|
||||
<CopyButton
|
||||
value={p.number}
|
||||
label={t("detail.copy_phone")}
|
||||
successMsg={t("detail.copied")}
|
||||
failMsg={t("detail.copy_failed")}
|
||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
|
||||
<div className="flex items-center gap-2 group">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
{p.number}
|
||||
</a>
|
||||
<RowActions>
|
||||
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(roles.length > 0 || jobTitles.length > 1) && (
|
||||
<Section icon={Briefcase} title={t("detail.titles")} category="work">
|
||||
{titles.map((tl, i) => (
|
||||
<div key={i} className="text-sm flex items-center gap-2">
|
||||
<span>{tl.name}</span>
|
||||
{tl.kind && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{orgs.length > 0 && (
|
||||
<Section icon={Building} title={t("detail.organizations")} category="work">
|
||||
{orgs.map((o, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Addresses span full width */}
|
||||
{addresses.length > 0 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<Section icon={MapPin} title={t("detail.addresses")} category="location">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||
<div>
|
||||
{a.full || a.fullAddress
|
||||
? (a.full || a.fullAddress)
|
||||
: a.components && a.components.length > 0
|
||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||
</div>
|
||||
{addresses.map((a, i) => {
|
||||
const lines: string[] = [];
|
||||
if (a.full || a.fullAddress) {
|
||||
lines.push((a.full || a.fullAddress) as string);
|
||||
} else if (a.components && a.components.length > 0) {
|
||||
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
|
||||
if (joined) lines.push(joined);
|
||||
} else {
|
||||
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
|
||||
.map(s => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter(Boolean) as string[];
|
||||
lines.push(...parts);
|
||||
}
|
||||
return (
|
||||
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
|
||||
<div className="text-sm space-y-0.5">
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
{a.timeZone && (
|
||||
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{onlineServices.length > 0 && (
|
||||
<Section icon={Globe} title={t("detail.online_services")} category="digital">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{svc.user || svc.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||
)}
|
||||
{svc.service && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
|
||||
)}
|
||||
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
|
||||
<CopyButton
|
||||
value={svc.user || svc.uri}
|
||||
label={t("detail.copy_url")}
|
||||
successMsg={t("detail.copied")}
|
||||
failMsg={t("detail.copy_failed")}
|
||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
<FieldRow
|
||||
key={`os${i}`}
|
||||
icon={Globe}
|
||||
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
|
||||
>
|
||||
<div className="flex items-center gap-2 group">
|
||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{svc.user || svc.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||
)}
|
||||
<RowActions>
|
||||
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{anniversaries.length > 0 && (
|
||||
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
|
||||
{anniversaries.map((ann, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{formatDate(ann.date)}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{t(`detail.anniversary_${ann.kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
{hasWork && (
|
||||
<Section title={t("detail.section_work")}>
|
||||
<div className="space-y-3">
|
||||
{orgs.map((o, i) => (
|
||||
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
|
||||
<div className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{personalInfo.length > 0 && (
|
||||
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
|
||||
{personalInfo.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{pi.value}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
|
||||
{pi.level && (
|
||||
<span className="text-xs text-muted-foreground">({pi.level})</span>
|
||||
)}
|
||||
</div>
|
||||
{titles.map((tl, i) => (
|
||||
<FieldRow
|
||||
key={`tl${i}`}
|
||||
icon={Briefcase}
|
||||
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
|
||||
>
|
||||
<div className="text-sm">{tl.name}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
||||
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
||||
<div className="text-sm">
|
||||
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
||||
{contact.speakToAs.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{preferredLanguages.length > 0 && (
|
||||
<Section icon={Languages} title={t("detail.languages")} category="personal">
|
||||
{hasPersonal && (
|
||||
<Section title={t("detail.section_personal")}>
|
||||
<div className="space-y-3">
|
||||
{anniversaries.map((ann, i) => {
|
||||
const years = getCompletedYears(ann.date);
|
||||
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
|
||||
return (
|
||||
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
|
||||
<div className="text-sm">
|
||||
{formatDate(ann.date)}
|
||||
{years !== null && (
|
||||
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
|
||||
)}
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
{hasGender && (
|
||||
<FieldRow icon={UserCircle} label={t("detail.gender")}>
|
||||
<div className="text-sm">
|
||||
{contact.speakToAs?.grammaticalGender && (
|
||||
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
|
||||
)}
|
||||
{contact.speakToAs?.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FieldRow>
|
||||
)}
|
||||
{preferredLanguages.map((lang, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{lang.language}</span>
|
||||
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
|
||||
</div>
|
||||
<FieldRow
|
||||
key={`lg${i}`}
|
||||
icon={Languages}
|
||||
label={formatContexts(lang.contexts) || t("detail.language_label")}
|
||||
>
|
||||
<div className="text-sm">{lang.language}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{personalInfo.map((pi, i) => (
|
||||
<FieldRow
|
||||
key={`pi${i}`}
|
||||
icon={Heart}
|
||||
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
|
||||
>
|
||||
<div className="text-sm">{pi.value}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<Section icon={Tag} title={t("detail.categories")} category="digital">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<Section title={t("detail.categories")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{relatedTo.length > 0 && (
|
||||
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
|
||||
{relatedTo.length > 0 && (
|
||||
<Section title={t("detail.related_contacts")}>
|
||||
<div className="space-y-2">
|
||||
{relatedTo.map(([uri, rel], i) => {
|
||||
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{uri}</span>
|
||||
{relType && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
|
||||
)}
|
||||
</div>
|
||||
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
|
||||
<div className="text-sm break-all">{uri}</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-4 mt-1"
|
||||
onClick={() => handleImportContactCert(i)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
@@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm break-all">
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
@@ -479,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
|
||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||
<Section title={t("detail.calendar")}>
|
||||
<div className="space-y-3">
|
||||
{contact.calendarUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
|
||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
|
||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.calendarUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
{contact.schedulingUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
|
||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
|
||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.schedulingUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
{contact.freeBusyUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
|
||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
|
||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.freeBusyUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Notes span full width */}
|
||||
{notes.length > 0 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
|
||||
{notes.length > 0 && (
|
||||
<Section title={t("detail.notes")}>
|
||||
<div className="flex items-start gap-3">
|
||||
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||
<div className="text-sm space-y-2 flex-1 min-w-0">
|
||||
{notes.map((n, i) => (
|
||||
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
||||
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Timestamps span full width */}
|
||||
{(contact.created || contact.updated) && (
|
||||
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
|
||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||
</div>
|
||||
)}
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{(contact.created || contact.updated) && (
|
||||
<div className="py-4 text-xs text-muted-foreground space-y-1">
|
||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatContexts(contexts?: Record<string, boolean>): string {
|
||||
if (!contexts) return "";
|
||||
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
|
||||
}
|
||||
|
||||
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn("py-6", className)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.separator) {
|
||||
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
||||
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
||||
|
||||
const categoryStyles: Record<SectionCategory, string> = {
|
||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
||||
};
|
||||
|
||||
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
||||
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
|
||||
{labels.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||
return (
|
||||
<button
|
||||
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
|
||||
toast.error(failMsg);
|
||||
}
|
||||
}}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
address: string;
|
||||
@@ -53,55 +54,73 @@ interface ContactFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
||||
|
||||
const formCategoryStyles: Record<FormCategory, string> = {
|
||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
||||
};
|
||||
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
category?: FormCategory;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen || !collapsible);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
|
||||
<section className="py-5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
|
||||
collapsible && "hover:text-muted-foreground cursor-pointer",
|
||||
!collapsible && "cursor-default"
|
||||
"flex items-center gap-2 w-full text-left",
|
||||
collapsible ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
onClick={() => collapsible && setOpen(!open)}
|
||||
tabIndex={collapsible ? 0 : -1}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{collapsible && (
|
||||
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-3 pt-3 pb-1">
|
||||
{(open || !collapsible) && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_PHOTO_DIM = 512;
|
||||
const PHOTO_QUALITY = 0.85;
|
||||
|
||||
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * ratio));
|
||||
const h = Math.max(1, Math.round(img.height * ratio));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("canvas-unsupported"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
|
||||
resolve({ uri, mediaType: "image/jpeg" });
|
||||
};
|
||||
img.onerror = () => reject(new Error("invalid-image"));
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.onerror = () => reject(new Error("read-failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function Select({ value, onChange, children, className }: {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}, [contact]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
if (!contact?.media) return null;
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
||||
}
|
||||
return null;
|
||||
}, [contact]);
|
||||
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
|
||||
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
|
||||
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||
const [photoUploading, setPhotoUploading] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setPhotoError(t("photo_too_large"));
|
||||
return;
|
||||
}
|
||||
setPhotoError(null);
|
||||
setPhotoUploading(true);
|
||||
try {
|
||||
const { uri, mediaType } = await processImageFile(file);
|
||||
setPhotoUri(uri);
|
||||
setPhotoMediaType(mediaType);
|
||||
} catch {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
} finally {
|
||||
setPhotoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoRemove = () => {
|
||||
setPhotoUri(undefined);
|
||||
setPhotoMediaType(undefined);
|
||||
setPhotoError(null);
|
||||
};
|
||||
|
||||
const validateEmail = (address: string): boolean => {
|
||||
if (!address.trim()) return true;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
||||
@@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
});
|
||||
}
|
||||
|
||||
const mediaMap: Record<string, ContactMedia> = {};
|
||||
if (contact?.media) {
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind !== "photo") mediaMap[key] = m;
|
||||
}
|
||||
}
|
||||
if (photoUri) {
|
||||
const photoKey = initialPhotoEntry?.key || "photo";
|
||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||
}
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
calendarUri: calendarUri.trim() || undefined,
|
||||
schedulingUri: schedulingUri.trim() || undefined,
|
||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||
};
|
||||
|
||||
@@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}
|
||||
};
|
||||
|
||||
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
|
||||
{/* Address Book Selector */}
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
<div className="flex items-center gap-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={photoUploading}
|
||||
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||
title={t("upload_photo")}
|
||||
aria-label={t("upload_photo")}
|
||||
>
|
||||
<Avatar
|
||||
name={previewName || undefined}
|
||||
email={previewEmail || undefined}
|
||||
contactPhotoUri={photoUri}
|
||||
size="lg"
|
||||
className="!w-20 !h-20 !text-xl"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
|
||||
</span>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||
{photoError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||
)}
|
||||
{photoUri && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePhotoRemove}
|
||||
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("remove_photo")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Name & Identity - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||
<FormSection icon={User} title={t("section_identity")}>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Mail} title={t("email")}>
|
||||
<div className="space-y-2">
|
||||
{emails.map((entry, i) => (
|
||||
<div key={i}>
|
||||
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Phone */}
|
||||
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Phone} title={t("phone")}>
|
||||
<div className="space-y-2">
|
||||
{phones.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Work & Organization */}
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Addresses - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||
{/* Addresses */}
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Online Services */}
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Anniversaries */}
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{anniversaries.map((ann, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Personal Info */}
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{personalInfoEntries.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Calendar */}
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||
{/* Notes */}
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type TriState = boolean | null;
|
||||
|
||||
interface ListFilters {
|
||||
organization: string;
|
||||
jobTitle: string;
|
||||
location: string;
|
||||
emailDomain: string;
|
||||
birthdayMonth: number | null;
|
||||
hasEmail: TriState;
|
||||
hasPhone: TriState;
|
||||
hasPhoto: TriState;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: ListFilters = {
|
||||
organization: "",
|
||||
jobTitle: "",
|
||||
location: "",
|
||||
emailDomain: "",
|
||||
birthdayMonth: null,
|
||||
hasEmail: null,
|
||||
hasPhone: null,
|
||||
hasPhoto: null,
|
||||
};
|
||||
|
||||
function cycleTri(v: TriState): TriState {
|
||||
return v === null ? true : v === true ? false : null;
|
||||
}
|
||||
|
||||
function countActiveFilters(f: ListFilters): number {
|
||||
let n = 0;
|
||||
if (f.organization.trim()) n++;
|
||||
if (f.jobTitle.trim()) n++;
|
||||
if (f.location.trim()) n++;
|
||||
if (f.emailDomain.trim()) n++;
|
||||
if (f.birthdayMonth !== null) n++;
|
||||
if (f.hasEmail !== null) n++;
|
||||
if (f.hasPhone !== null) n++;
|
||||
if (f.hasPhoto !== null) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function matchTri(actual: boolean, filter: TriState): boolean {
|
||||
if (filter === null) return true;
|
||||
return actual === filter;
|
||||
}
|
||||
|
||||
function getAnniversaryMonth(date: AnniversaryDate): number | null {
|
||||
if (typeof date === "string") {
|
||||
const iso = date.match(/^(\d{4})-(\d{2})/);
|
||||
if (iso) return parseInt(iso[2], 10);
|
||||
const partial = date.match(/^--(\d{2})/);
|
||||
if (partial) return parseInt(partial[1], 10);
|
||||
return null;
|
||||
}
|
||||
if ("month" in date && date.month) return date.month;
|
||||
if ("utc" in date && date.utc) {
|
||||
const d = new Date(date.utc);
|
||||
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContactListProps {
|
||||
contacts: ContactCard[];
|
||||
selectedContactId: string | null;
|
||||
@@ -20,7 +100,6 @@ interface ContactListProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelectContact: (id: string) => void;
|
||||
onCreateNew: () => void;
|
||||
categoryLabel: string;
|
||||
className?: string;
|
||||
selectedContactIds: Set<string>;
|
||||
onToggleSelection: (id: string) => void;
|
||||
@@ -42,7 +121,6 @@ export function ContactList({
|
||||
onSearchChange,
|
||||
onSelectContact,
|
||||
onCreateNew,
|
||||
categoryLabel,
|
||||
className,
|
||||
selectedContactIds,
|
||||
onToggleSelection,
|
||||
@@ -57,31 +135,93 @@ export function ContactList({
|
||||
onAddContactToGroup,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
|
||||
const monthNames = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
||||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
|
||||
}, [locale]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
const lower = searchQuery.toLowerCase();
|
||||
const lower = searchQuery.trim().toLowerCase();
|
||||
const orgLower = filters.organization.trim().toLowerCase();
|
||||
const jobLower = filters.jobTitle.trim().toLowerCase();
|
||||
const locLower = filters.location.trim().toLowerCase();
|
||||
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
|
||||
|
||||
return contacts.filter((c) => {
|
||||
const emails = c.emails ? Object.values(c.emails) : [];
|
||||
const phones = c.phones ? Object.values(c.phones) : [];
|
||||
const orgs = c.organizations ? Object.values(c.organizations) : [];
|
||||
const titles = c.titles ? Object.values(c.titles) : [];
|
||||
const addresses = c.addresses ? Object.values(c.addresses) : [];
|
||||
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
|
||||
|
||||
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
|
||||
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
|
||||
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
|
||||
|
||||
if (orgLower) {
|
||||
const match = orgs.some((o) => {
|
||||
if (o.name?.toLowerCase().includes(orgLower)) return true;
|
||||
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
|
||||
return false;
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (jobLower) {
|
||||
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
|
||||
}
|
||||
|
||||
if (locLower) {
|
||||
const match = addresses.some((a) => {
|
||||
const parts: string[] = [];
|
||||
if (a.full) parts.push(a.full);
|
||||
if (a.fullAddress) parts.push(a.fullAddress);
|
||||
if (a.locality) parts.push(a.locality);
|
||||
if (a.region) parts.push(a.region);
|
||||
if (a.country) parts.push(a.country);
|
||||
if (a.postcode) parts.push(a.postcode);
|
||||
if (a.street) parts.push(a.street);
|
||||
if (a.components) {
|
||||
for (const comp of a.components) {
|
||||
if (comp.value) parts.push(comp.value);
|
||||
}
|
||||
}
|
||||
return parts.some((p) => p.toLowerCase().includes(locLower));
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (domainLower) {
|
||||
const match = emails.some((e) => {
|
||||
const at = e.address?.toLowerCase().split("@");
|
||||
return at && at.length > 1 && at[1].includes(domainLower);
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (filters.birthdayMonth !== null) {
|
||||
const target = filters.birthdayMonth;
|
||||
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (!lower) return true;
|
||||
const name = getContactDisplayName(c).toLowerCase();
|
||||
const emails = c.emails
|
||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
||||
: [];
|
||||
const phones = c.phones
|
||||
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
|
||||
: [];
|
||||
const org = c.organizations
|
||||
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
|
||||
: [];
|
||||
return (
|
||||
name.includes(lower) ||
|
||||
emails.some((e) => e.includes(lower)) ||
|
||||
phones.some((p) => p.includes(lower)) ||
|
||||
org.some((o) => o.includes(lower))
|
||||
);
|
||||
if (name.includes(lower)) return true;
|
||||
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
|
||||
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
|
||||
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
|
||||
return false;
|
||||
});
|
||||
}, [contacts, searchQuery]);
|
||||
}, [contacts, searchQuery, filters]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
@@ -98,24 +238,182 @@ export function ContactList({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Search header */}
|
||||
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||
{categoryLabel} ({contacts.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
{/* Toolbar: select / search / filter */}
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSelection) {
|
||||
if (allSelected) onClearSelection();
|
||||
else onSelectAll(sortedIds);
|
||||
} else if (sortedIds.length > 0) {
|
||||
onToggleSelection(sortedIds[0]);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
hasSelection
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||
>
|
||||
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
filtersOpen || activeFilters > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
title={t("filters.toggle")}
|
||||
aria-label={t("filters.toggle")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!filtersOpen && activeFilters > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{activeFilters}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<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("filters.title")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiltersOpen(false)}
|
||||
className="h-7 w-7"
|
||||
aria-label={t("filters.close")}
|
||||
>
|
||||
<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("filters.organization")}</label>
|
||||
<Input
|
||||
value={filters.organization}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
|
||||
placeholder={t("filters.organization_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
|
||||
<Input
|
||||
value={filters.jobTitle}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
|
||||
placeholder={t("filters.job_title_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
|
||||
<Input
|
||||
value={filters.location}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
|
||||
placeholder={t("filters.location_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
|
||||
<Input
|
||||
value={filters.emailDomain}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
|
||||
placeholder={t("filters.email_domain_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
|
||||
<select
|
||||
value={filters.birthdayMonth ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
aria-label={t("filters.birthday_month")}
|
||||
>
|
||||
<option value="">{t("filters.any_month")}</option>
|
||||
{monthNames.map((name, i) => (
|
||||
<option key={i} value={i + 1}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleChip
|
||||
icon={<Mail className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_email")}
|
||||
value={filters.hasEmail}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<Phone className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_phone")}
|
||||
value={filters.hasPhone}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_photo")}
|
||||
value={filters.hasPhoto}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{hasSelection && (
|
||||
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||
@@ -166,19 +464,32 @@ export function ContactList({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||
{searchQuery ? (
|
||||
{searchQuery || activeFilters > 0 ? (
|
||||
<>
|
||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
{t("clear_search")}
|
||||
</Button>
|
||||
{activeFilters > 0 && !searchQuery ? (
|
||||
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
) : (
|
||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{searchQuery && (
|
||||
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
||||
{t("clear_search")}
|
||||
</Button>
|
||||
)}
|
||||
{activeFilters > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function printContact(contact: ContactCard, displayName?: string): void {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
const name = displayName || getContactDisplayName(contact);
|
||||
const photoUri = getContactPhotoUri(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
|
||||
const rows: string[] = [];
|
||||
const section = (title: string, items: string[]) => {
|
||||
if (items.length === 0) return;
|
||||
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
|
||||
};
|
||||
const row = (label: string, value: string) =>
|
||||
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||
|
||||
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||
section(
|
||||
"Organization",
|
||||
orgs.map(o => {
|
||||
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||
return row("Organization", value);
|
||||
}),
|
||||
);
|
||||
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||
section(
|
||||
"Address",
|
||||
addresses.map(a => {
|
||||
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||
.map(p => (typeof p === "string" ? p : ""))
|
||||
.filter(Boolean);
|
||||
return row(a.label || "Address", parts.join(", "));
|
||||
}),
|
||||
);
|
||||
section(
|
||||
"Online",
|
||||
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||
);
|
||||
section(
|
||||
"Anniversary",
|
||||
anniversaries.map(a => {
|
||||
const date = a.date;
|
||||
let formatted = "";
|
||||
if (typeof date === "object" && date !== null) {
|
||||
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||
else {
|
||||
const parts: string[] = [];
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||
formatted = parts.join("-");
|
||||
}
|
||||
} else if (date) {
|
||||
formatted = String(date);
|
||||
}
|
||||
return row(a.kind || "Date", formatted);
|
||||
}),
|
||||
);
|
||||
section("Notes", notes.map(n => row("", n.note || "")));
|
||||
|
||||
const photoTag = photoUri
|
||||
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(name || "Contact")}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
|
||||
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
section { margin-top: 20px; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
|
||||
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
|
||||
dt { color: #555; }
|
||||
dd { margin: 0; word-break: break-word; }
|
||||
@media print { body { margin: 16mm; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
${photoTag}
|
||||
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||
</header>
|
||||
${rows.join("")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
}
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||
"empty_search": "Keine Kontakte gefunden",
|
||||
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
|
||||
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
|
||||
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
|
||||
"clear_search": "Suche löschen",
|
||||
"import_vcard": "vCard importieren",
|
||||
"delete_confirm_title": "Kontakt löschen",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "In S/MIME importieren",
|
||||
"cert_already_imported": "Zertifikat bereits importiert",
|
||||
"cert_imported": "Zertifikat importiert",
|
||||
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
|
||||
"cert_import_failed": "Import des Zertifikats fehlgeschlagen",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
|
||||
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"email_error_inline": "Ungültiges E-Mail-Format",
|
||||
"save_failed": "Kontakt konnte nicht gespeichert werden",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"upload_photo": "Foto hochladen",
|
||||
"remove_photo": "Foto entfernen",
|
||||
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
|
||||
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
|
||||
"photo_invalid": "Ungültige Bilddatei",
|
||||
"change_photo": "Ändern"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Neue Gruppe",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Kontakt konnte nicht erstellt werden",
|
||||
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"send_email": "E-Mail senden",
|
||||
"add_to_group": "Zur Gruppe hinzufügen",
|
||||
"export_vcard": "Als vCard exportieren",
|
||||
"delete": "Löschen",
|
||||
"call": "Anrufen",
|
||||
"duplicate": "Duplizieren",
|
||||
"print": "Drucken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filter",
|
||||
"select": "Auswählen",
|
||||
"clear": "Zurücksetzen",
|
||||
"close": "Schließen",
|
||||
"title": "Erweiterte Filter",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "z. B. Acme GmbH",
|
||||
"job_title": "Berufsbezeichnung",
|
||||
"job_title_placeholder": "z. B. Designer",
|
||||
"location": "Ort",
|
||||
"location_placeholder": "Stadt oder Land",
|
||||
"email_domain": "E-Mail-Domain",
|
||||
"email_domain_placeholder": "beispiel.de",
|
||||
"birthday_month": "Geburtstag im",
|
||||
"any_month": "Beliebiger Monat",
|
||||
"has_email": "Mit E-Mail",
|
||||
"has_phone": "Mit Telefon",
|
||||
"has_photo": "Mit Foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Vorläufig",
|
||||
"needs_action": "Antwort ausstehend",
|
||||
"remove": "Entfernen",
|
||||
"edit": "Bearbeiten",
|
||||
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
|
||||
"send_invitations": "Einladungen an Teilnehmer senden",
|
||||
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
|
||||
|
||||
+53
-4
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||
"empty_search": "No contacts match your search",
|
||||
"empty_search_hint": "Try a different search term",
|
||||
"empty_filtered": "No contacts match your filters",
|
||||
"empty_filtered_hint": "Try adjusting or clearing filters",
|
||||
"clear_search": "Clear search",
|
||||
"import_vcard": "Import vCard",
|
||||
"delete_confirm_title": "Delete contact",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "Calendar",
|
||||
"calendar_uri": "Calendar URL",
|
||||
"scheduling_uri": "Scheduling URL",
|
||||
"freebusy_uri": "Free/Busy URL"
|
||||
"freebusy_uri": "Free/Busy URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "More actions",
|
||||
"age_years": "{count, plural, one {1 year old} other {# years old}}",
|
||||
"years_since": "{count, plural, one {1 year} other {# years}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "New Contact",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
"email_error_inline": "Invalid email format",
|
||||
"save_failed": "Failed to save contact",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"upload_photo": "Upload photo",
|
||||
"remove_photo": "Remove photo",
|
||||
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
|
||||
"photo_too_large": "Image is too large (max 10 MB)",
|
||||
"photo_invalid": "Invalid image file",
|
||||
"change_photo": "Change"
|
||||
},
|
||||
"groups": {
|
||||
"create": "New Group",
|
||||
@@ -1986,7 +2011,30 @@
|
||||
"send_email": "Send email",
|
||||
"add_to_group": "Add to group",
|
||||
"export_vcard": "Export as vCard",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"call": "Call",
|
||||
"duplicate": "Duplicate",
|
||||
"print": "Print"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filters",
|
||||
"select": "Select",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"title": "Advanced filters",
|
||||
"organization": "Company",
|
||||
"organization_placeholder": "e.g. Acme Corp",
|
||||
"job_title": "Job title",
|
||||
"job_title_placeholder": "e.g. Designer",
|
||||
"location": "Location",
|
||||
"location_placeholder": "City or country",
|
||||
"email_domain": "Email domain",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "Birthday in",
|
||||
"any_month": "Any month",
|
||||
"has_email": "Has email",
|
||||
"has_phone": "Has phone",
|
||||
"has_photo": "Has photo"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2062,6 +2110,7 @@
|
||||
"tentative": "Tentative",
|
||||
"needs_action": "Needs action",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"email_placeholder": "Add email address or search contacts",
|
||||
"send_invitations": "Send invitations to participants",
|
||||
"status_summary": "{accepted} accepted, {pending} pending",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||
"empty_search": "Ningún contacto coincide con tu búsqueda",
|
||||
"empty_search_hint": "Prueba con otro término de búsqueda",
|
||||
"empty_filtered": "Ningún contacto coincide con tus filtros",
|
||||
"empty_filtered_hint": "Ajusta o limpia los filtros",
|
||||
"clear_search": "Borrar búsqueda",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Eliminar contacto",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "Importar a S/MIME",
|
||||
"cert_already_imported": "Certificado ya importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Error al importar el certificado"
|
||||
"cert_import_failed": "Error al importar el certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Más acciones",
|
||||
"age_years": "{count, plural, one {1 año} other {# años}}",
|
||||
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Introduce una dirección de correo válida",
|
||||
"email_error_inline": "Formato de correo inválido",
|
||||
"save_failed": "Error al guardar el contacto",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"upload_photo": "Subir foto",
|
||||
"remove_photo": "Quitar foto",
|
||||
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
|
||||
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
|
||||
"photo_invalid": "Archivo de imagen no válido",
|
||||
"change_photo": "Cambiar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuevo grupo",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Error al crear el contacto",
|
||||
"error_update": "Error al actualizar el contacto",
|
||||
"error_delete": "Error al eliminar el contacto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar correo",
|
||||
"add_to_group": "Añadir al grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Eliminar",
|
||||
"call": "Llamar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Seleccionar",
|
||||
"clear": "Borrar",
|
||||
"close": "Cerrar",
|
||||
"title": "Filtros avanzados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "p. ej. Acme",
|
||||
"job_title": "Puesto",
|
||||
"job_title_placeholder": "p. ej. Diseñador",
|
||||
"location": "Ubicación",
|
||||
"location_placeholder": "Ciudad o país",
|
||||
"email_domain": "Dominio de correo",
|
||||
"email_domain_placeholder": "ejemplo.com",
|
||||
"birthday_month": "Cumpleaños en",
|
||||
"any_month": "Cualquier mes",
|
||||
"has_email": "Con correo",
|
||||
"has_phone": "Con teléfono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Provisional",
|
||||
"needs_action": "Pendiente de respuesta",
|
||||
"remove": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Añadir dirección de correo o buscar contactos",
|
||||
"send_invitations": "Enviar invitaciones a los participantes",
|
||||
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||
"empty_search": "Aucun contact ne correspond à votre recherche",
|
||||
"empty_search_hint": "Essayez un autre terme de recherche",
|
||||
"empty_filtered": "Aucun contact ne correspond à vos filtres",
|
||||
"empty_filtered_hint": "Ajustez ou effacez les filtres",
|
||||
"clear_search": "Effacer la recherche",
|
||||
"import_vcard": "Importer vCard",
|
||||
"delete_confirm_title": "Supprimer le contact",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "Importer dans S/MIME",
|
||||
"cert_already_imported": "Certificat déjà importé",
|
||||
"cert_imported": "Certificat importé",
|
||||
"cert_import_failed": "Échec de l'import du certificat"
|
||||
"cert_import_failed": "Échec de l'import du certificat",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Plus d'actions",
|
||||
"age_years": "{count, plural, one {1 an} other {# ans}}",
|
||||
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||
"email_error_inline": "Format d'e-mail invalide",
|
||||
"save_failed": "Échec de l'enregistrement du contact",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"upload_photo": "Téléverser une photo",
|
||||
"remove_photo": "Supprimer la photo",
|
||||
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
|
||||
"photo_too_large": "L’image est trop grande (max 10 Mo)",
|
||||
"photo_invalid": "Fichier image invalide",
|
||||
"change_photo": "Modifier"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nouveau groupe",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Échec de la création du contact",
|
||||
"error_update": "Échec de la mise à jour du contact",
|
||||
"error_delete": "Échec de la suppression du contact"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Ouvrir",
|
||||
"edit": "Modifier",
|
||||
"send_email": "Envoyer un courriel",
|
||||
"add_to_group": "Ajouter au groupe",
|
||||
"export_vcard": "Exporter en vCard",
|
||||
"delete": "Supprimer",
|
||||
"call": "Appeler",
|
||||
"duplicate": "Dupliquer",
|
||||
"print": "Imprimer"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtres",
|
||||
"select": "Sélectionner",
|
||||
"clear": "Effacer",
|
||||
"close": "Fermer",
|
||||
"title": "Filtres avancés",
|
||||
"organization": "Entreprise",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Poste",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Lieu",
|
||||
"location_placeholder": "Ville ou pays",
|
||||
"email_domain": "Domaine e-mail",
|
||||
"email_domain_placeholder": "exemple.com",
|
||||
"birthday_month": "Anniversaire en",
|
||||
"any_month": "Tous les mois",
|
||||
"has_email": "Avec e-mail",
|
||||
"has_phone": "Avec téléphone",
|
||||
"has_photo": "Avec photo"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Provisoire",
|
||||
"needs_action": "En attente de réponse",
|
||||
"remove": "Retirer",
|
||||
"edit": "Modifier",
|
||||
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
|
||||
"send_invitations": "Envoyer les invitations aux participants",
|
||||
"status_summary": "{accepted} accepté(s), {pending} en attente",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||
"empty_search": "Nessun contatto corrisponde alla ricerca",
|
||||
"empty_search_hint": "Prova con un altro termine di ricerca",
|
||||
"empty_filtered": "Nessun contatto corrisponde ai filtri",
|
||||
"empty_filtered_hint": "Modifica o cancella i filtri",
|
||||
"clear_search": "Cancella ricerca",
|
||||
"import_vcard": "Importa vCard",
|
||||
"delete_confirm_title": "Elimina contatto",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "Importa in S/MIME",
|
||||
"cert_already_imported": "Certificato già importato",
|
||||
"cert_imported": "Certificato importato",
|
||||
"cert_import_failed": "Importazione del certificato non riuscita"
|
||||
"cert_import_failed": "Importazione del certificato non riuscita",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Altre azioni",
|
||||
"age_years": "{count, plural, one {1 anno} other {# anni}}",
|
||||
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuovo contatto",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Inserisci un indirizzo email valido",
|
||||
"email_error_inline": "Formato email non valido",
|
||||
"save_failed": "Impossibile salvare il contatto",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"upload_photo": "Carica foto",
|
||||
"remove_photo": "Rimuovi foto",
|
||||
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
|
||||
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
|
||||
"photo_invalid": "File immagine non valido",
|
||||
"change_photo": "Cambia"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuovo gruppo",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Impossibile creare il contatto",
|
||||
"error_update": "Impossibile aggiornare il contatto",
|
||||
"error_delete": "Impossibile eliminare il contatto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Apri",
|
||||
"edit": "Modifica",
|
||||
"send_email": "Invia email",
|
||||
"add_to_group": "Aggiungi al gruppo",
|
||||
"export_vcard": "Esporta come vCard",
|
||||
"delete": "Elimina",
|
||||
"call": "Chiama",
|
||||
"duplicate": "Duplica",
|
||||
"print": "Stampa"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Seleziona",
|
||||
"clear": "Azzera",
|
||||
"close": "Chiudi",
|
||||
"title": "Filtri avanzati",
|
||||
"organization": "Azienda",
|
||||
"organization_placeholder": "es. Acme",
|
||||
"job_title": "Ruolo",
|
||||
"job_title_placeholder": "es. Designer",
|
||||
"location": "Luogo",
|
||||
"location_placeholder": "Città o paese",
|
||||
"email_domain": "Dominio email",
|
||||
"email_domain_placeholder": "esempio.com",
|
||||
"birthday_month": "Compleanno in",
|
||||
"any_month": "Qualsiasi mese",
|
||||
"has_email": "Con email",
|
||||
"has_phone": "Con telefono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Provvisorio",
|
||||
"needs_action": "In attesa di risposta",
|
||||
"remove": "Rimuovi",
|
||||
"edit": "Modifica",
|
||||
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
|
||||
"send_invitations": "Invia inviti ai partecipanti",
|
||||
"status_summary": "{accepted} accettato/i, {pending} in attesa",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||
"empty_search": "検索に一致する連絡先がありません",
|
||||
"empty_search_hint": "別の検索語をお試しください",
|
||||
"empty_filtered": "フィルターに一致する連絡先がありません",
|
||||
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
|
||||
"clear_search": "検索をクリア",
|
||||
"import_vcard": "vCardをインポート",
|
||||
"delete_confirm_title": "連絡先を削除",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "S/MIME にインポート",
|
||||
"cert_already_imported": "証明書はすでにインポートされています",
|
||||
"cert_imported": "証明書をインポートしました",
|
||||
"cert_import_failed": "証明書のインポートに失敗しました"
|
||||
"cert_import_failed": "証明書のインポートに失敗しました",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "その他の操作",
|
||||
"age_years": "{count}歳",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "有効なメールアドレスを入力してください",
|
||||
"email_error_inline": "メールアドレスの形式が正しくありません",
|
||||
"save_failed": "連絡先の保存に失敗しました",
|
||||
"delete": "削除"
|
||||
"delete": "削除",
|
||||
"upload_photo": "写真をアップロード",
|
||||
"remove_photo": "写真を削除",
|
||||
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
|
||||
"photo_too_large": "画像が大きすぎます (最大10MB)",
|
||||
"photo_invalid": "無効な画像ファイル",
|
||||
"change_photo": "変更"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新しいグループ",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "連絡先の作成に失敗しました",
|
||||
"error_update": "連絡先の更新に失敗しました",
|
||||
"error_delete": "連絡先の削除に失敗しました"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "開く",
|
||||
"edit": "編集",
|
||||
"send_email": "メール送信",
|
||||
"add_to_group": "グループに追加",
|
||||
"export_vcard": "vCardとしてエクスポート",
|
||||
"delete": "削除",
|
||||
"call": "電話する",
|
||||
"duplicate": "複製",
|
||||
"print": "印刷"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "フィルター",
|
||||
"select": "選択",
|
||||
"clear": "クリア",
|
||||
"close": "閉じる",
|
||||
"title": "詳細フィルター",
|
||||
"organization": "会社",
|
||||
"organization_placeholder": "例: Acme",
|
||||
"job_title": "役職",
|
||||
"job_title_placeholder": "例: デザイナー",
|
||||
"location": "場所",
|
||||
"location_placeholder": "都市または国",
|
||||
"email_domain": "メールドメイン",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "誕生月",
|
||||
"any_month": "すべての月",
|
||||
"has_email": "メールあり",
|
||||
"has_phone": "電話あり",
|
||||
"has_photo": "写真あり"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "仮承諾",
|
||||
"needs_action": "未回答",
|
||||
"remove": "削除",
|
||||
"edit": "編集",
|
||||
"email_placeholder": "メールアドレスを追加または連絡先を検索",
|
||||
"send_invitations": "参加者に招待を送信",
|
||||
"status_summary": "{accepted}人承諾、{pending}人保留",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
|
||||
"empty_search": "검색된 연락처가 없어요",
|
||||
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
|
||||
"empty_filtered": "필터에 일치하는 연락처가 없어요",
|
||||
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
|
||||
"clear_search": "검색 지우기",
|
||||
"import_vcard": "vCard 가져오기",
|
||||
"delete_confirm_title": "연락처 삭제",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "캘린더",
|
||||
"calendar_uri": "캘린더 URL",
|
||||
"scheduling_uri": "스케줄링 URL",
|
||||
"freebusy_uri": "Free/Busy URL"
|
||||
"freebusy_uri": "Free/Busy URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "작업 더보기",
|
||||
"age_years": "{count}세",
|
||||
"years_since": "{count}년 전"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "새 연락처",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
|
||||
"email_error_inline": "이메일 형식이 잘못되었어요",
|
||||
"save_failed": "연락처를 저장하지 못했어요",
|
||||
"delete": "삭제"
|
||||
"delete": "삭제",
|
||||
"upload_photo": "사진 업로드",
|
||||
"remove_photo": "사진 제거",
|
||||
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
|
||||
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
|
||||
"photo_invalid": "잘못된 이미지 파일",
|
||||
"change_photo": "변경"
|
||||
},
|
||||
"groups": {
|
||||
"create": "새 그룹",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "연락처를 만들지 못했어요",
|
||||
"error_update": "연락처를 업데이트하지 못했어요",
|
||||
"error_delete": "연락처를 삭제하지 못했어요"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "열기",
|
||||
"edit": "편집",
|
||||
"send_email": "이메일 보내기",
|
||||
"add_to_group": "그룹에 추가",
|
||||
"export_vcard": "vCard로 내보내기",
|
||||
"delete": "삭제",
|
||||
"call": "통화",
|
||||
"duplicate": "복제",
|
||||
"print": "인쇄"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "필터",
|
||||
"select": "선택",
|
||||
"clear": "지우기",
|
||||
"close": "닫기",
|
||||
"title": "고급 필터",
|
||||
"organization": "회사",
|
||||
"organization_placeholder": "예: Acme",
|
||||
"job_title": "직책",
|
||||
"job_title_placeholder": "예: 디자이너",
|
||||
"location": "위치",
|
||||
"location_placeholder": "도시 또는 국가",
|
||||
"email_domain": "이메일 도메인",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "생일 월",
|
||||
"any_month": "모든 달",
|
||||
"has_email": "이메일 있음",
|
||||
"has_phone": "전화번호 있음",
|
||||
"has_photo": "사진 있음"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "미정",
|
||||
"needs_action": "응답 필요",
|
||||
"remove": "삭제",
|
||||
"edit": "편집",
|
||||
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
|
||||
"send_invitations": "참석자에게 초대장 보내기",
|
||||
"status_summary": "{accepted}명 수락, {pending}명 대기 중",
|
||||
|
||||
+61
-4
@@ -1733,6 +1733,8 @@
|
||||
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
|
||||
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
|
||||
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
|
||||
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
|
||||
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
|
||||
"clear_search": "Notīrīt meklēšanu",
|
||||
"import_vcard": "vCard imports",
|
||||
"delete_confirm_title": "Dzēst kontaktu",
|
||||
@@ -1810,7 +1812,22 @@
|
||||
"calendar": "Kalendārs",
|
||||
"calendar_uri": "Kalendāra URL",
|
||||
"scheduling_uri": "Plānošanas URL",
|
||||
"freebusy_uri": "Pieejamības URL"
|
||||
"freebusy_uri": "Pieejamības URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Citas darbības",
|
||||
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
|
||||
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1819,7 +1836,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Jauns kontakts",
|
||||
@@ -1915,7 +1934,13 @@
|
||||
"email_invalid": "Ievadiet derīgu e-pasta adresi",
|
||||
"email_error_inline": "Nederīgs e-pasta formāts",
|
||||
"save_failed": "Neizdevās saglabāt kontaktu",
|
||||
"delete": "Dzēst"
|
||||
"delete": "Dzēst",
|
||||
"upload_photo": "Augšupielādēt foto",
|
||||
"remove_photo": "Noņemt foto",
|
||||
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
|
||||
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
|
||||
"photo_invalid": "Nederīgs attēla fails",
|
||||
"change_photo": "Mainīt"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Jauna grupa",
|
||||
@@ -1979,7 +2004,38 @@
|
||||
"rename_category": "Pārdēvēt kategoriju",
|
||||
"category_name_label": "Kategorijas nosaukums",
|
||||
"category_renamed": "Kategorija pārdēvēta",
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju"
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
|
||||
"context_menu": {
|
||||
"open": "Atvērt",
|
||||
"edit": "Rediģēt",
|
||||
"send_email": "Sūtīt e-pastu",
|
||||
"add_to_group": "Pievienot grupai",
|
||||
"export_vcard": "Eksportēt kā vCard",
|
||||
"delete": "Dzēst",
|
||||
"call": "Zvanīt",
|
||||
"duplicate": "Dublēt",
|
||||
"print": "Drukāt"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Atlasīt",
|
||||
"clear": "Notīrīt",
|
||||
"close": "Aizvērt",
|
||||
"title": "Paplašinātie filtri",
|
||||
"organization": "Uzņēmums",
|
||||
"organization_placeholder": "piem. Acme",
|
||||
"job_title": "Amats",
|
||||
"job_title_placeholder": "piem. Dizainers",
|
||||
"location": "Atrašanās vieta",
|
||||
"location_placeholder": "Pilsēta vai valsts",
|
||||
"email_domain": "E-pasta domēns",
|
||||
"email_domain_placeholder": "piemers.lv",
|
||||
"birthday_month": "Dzimšanas diena",
|
||||
"any_month": "Jebkurš mēnesis",
|
||||
"has_email": "Ar e-pastu",
|
||||
"has_phone": "Ar tālruni",
|
||||
"has_photo": "Ar foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendārs",
|
||||
@@ -2053,6 +2109,7 @@
|
||||
"tentative": "Pagaidām",
|
||||
"needs_action": "Nepieciešama atbilde",
|
||||
"remove": "Noņemt",
|
||||
"edit": "Rediģēt",
|
||||
"email_placeholder": "Pievienojiet adresi vai kontaktu",
|
||||
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
|
||||
"status_summary": "{accepted} pieņēmuši, {pending} gaida",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||
"empty_search": "Geen contacten gevonden",
|
||||
"empty_search_hint": "Probeer een andere zoekterm",
|
||||
"empty_filtered": "Geen contacten voldoen aan uw filters",
|
||||
"empty_filtered_hint": "Pas filters aan of wis ze",
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"import_vcard": "vCard importeren",
|
||||
"delete_confirm_title": "Contact verwijderen",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "Importeren naar S/MIME",
|
||||
"cert_already_imported": "Certificaat is al geïmporteerd",
|
||||
"cert_imported": "Certificaat geïmporteerd",
|
||||
"cert_import_failed": "Importeren van certificaat mislukt"
|
||||
"cert_import_failed": "Importeren van certificaat mislukt",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Meer acties",
|
||||
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
|
||||
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nieuw contact",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Voer een geldig e-mailadres in",
|
||||
"email_error_inline": "Ongeldig e-mailformaat",
|
||||
"save_failed": "Kon contact niet opslaan",
|
||||
"delete": "Verwijderen"
|
||||
"delete": "Verwijderen",
|
||||
"upload_photo": "Foto uploaden",
|
||||
"remove_photo": "Foto verwijderen",
|
||||
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
|
||||
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
|
||||
"photo_invalid": "Ongeldig afbeeldingsbestand",
|
||||
"change_photo": "Wijzigen"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nieuwe groep",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Kon contact niet aanmaken",
|
||||
"error_update": "Kon contact niet bijwerken",
|
||||
"error_delete": "Kon contact niet verwijderen"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Openen",
|
||||
"edit": "Bewerken",
|
||||
"send_email": "E-mail verzenden",
|
||||
"add_to_group": "Aan groep toevoegen",
|
||||
"export_vcard": "Exporteren als vCard",
|
||||
"delete": "Verwijderen",
|
||||
"call": "Bellen",
|
||||
"duplicate": "Dupliceren",
|
||||
"print": "Afdrukken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filters",
|
||||
"select": "Selecteren",
|
||||
"clear": "Wissen",
|
||||
"close": "Sluiten",
|
||||
"title": "Geavanceerde filters",
|
||||
"organization": "Bedrijf",
|
||||
"organization_placeholder": "bijv. Acme",
|
||||
"job_title": "Functie",
|
||||
"job_title_placeholder": "bijv. Ontwerper",
|
||||
"location": "Locatie",
|
||||
"location_placeholder": "Stad of land",
|
||||
"email_domain": "E-maildomein",
|
||||
"email_domain_placeholder": "voorbeeld.nl",
|
||||
"birthday_month": "Verjaardag in",
|
||||
"any_month": "Elke maand",
|
||||
"has_email": "Met e-mail",
|
||||
"has_phone": "Met telefoon",
|
||||
"has_photo": "Met foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Voorlopig",
|
||||
"needs_action": "Reactie vereist",
|
||||
"remove": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"email_placeholder": "E-mailadres toevoegen of contacten zoeken",
|
||||
"send_invitations": "Uitnodigingen sturen naar deelnemers",
|
||||
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
|
||||
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
|
||||
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
|
||||
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
|
||||
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"import_vcard": "Importuj vCard",
|
||||
"delete_confirm_title": "Usuń kontakt",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "Kalendarz",
|
||||
"calendar_uri": "Adres URL kalendarza",
|
||||
"scheduling_uri": "Adres URL planowania",
|
||||
"freebusy_uri": "Adres URL wolny/zajęty"
|
||||
"freebusy_uri": "Adres URL wolny/zajęty",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Więcej działań",
|
||||
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
|
||||
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nowy kontakt",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Wprowadź prawidłowy adres e-mail",
|
||||
"email_error_inline": "Nieprawidłowy format adresu e-mail",
|
||||
"save_failed": "Nie udało się zapisać kontaktu",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"upload_photo": "Prześlij zdjęcie",
|
||||
"remove_photo": "Usuń zdjęcie",
|
||||
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
|
||||
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
|
||||
"photo_invalid": "Nieprawidłowy plik obrazu",
|
||||
"change_photo": "Zmień"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nowa grupa",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Nie udało się utworzyć kontaktu",
|
||||
"error_update": "Nie udało się zaktualizować kontaktu",
|
||||
"error_delete": "Nie udało się usunąć kontaktu"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Otwórz",
|
||||
"edit": "Edytuj",
|
||||
"send_email": "Wyślij e-mail",
|
||||
"add_to_group": "Dodaj do grupy",
|
||||
"export_vcard": "Eksportuj jako vCard",
|
||||
"delete": "Usuń",
|
||||
"call": "Zadzwoń",
|
||||
"duplicate": "Duplikuj",
|
||||
"print": "Drukuj"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtry",
|
||||
"select": "Zaznacz",
|
||||
"clear": "Wyczyść",
|
||||
"close": "Zamknij",
|
||||
"title": "Filtry zaawansowane",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "np. Acme",
|
||||
"job_title": "Stanowisko",
|
||||
"job_title_placeholder": "np. Projektant",
|
||||
"location": "Lokalizacja",
|
||||
"location_placeholder": "Miasto lub kraj",
|
||||
"email_domain": "Domena e-mail",
|
||||
"email_domain_placeholder": "przyklad.pl",
|
||||
"birthday_month": "Urodziny w",
|
||||
"any_month": "Dowolny miesiąc",
|
||||
"has_email": "Z e-mailem",
|
||||
"has_phone": "Z telefonem",
|
||||
"has_photo": "Ze zdjęciem"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Wstępnie",
|
||||
"needs_action": "Wymaga działania",
|
||||
"remove": "Usuń",
|
||||
"edit": "Edytuj",
|
||||
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
|
||||
"send_invitations": "Wyślij zaproszenia do uczestników",
|
||||
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||
"empty_search": "Nenhum contato encontrado",
|
||||
"empty_search_hint": "Tente outro termo de pesquisa",
|
||||
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
|
||||
"empty_filtered_hint": "Ajuste ou limpe os filtros",
|
||||
"clear_search": "Limpar pesquisa",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Excluir contato",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"import_to_smime": "Importar para S/MIME",
|
||||
"cert_already_imported": "Certificado já importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Falha ao importar o certificado"
|
||||
"cert_import_failed": "Falha ao importar o certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Mais ações",
|
||||
"age_years": "{count, plural, one {1 ano} other {# anos}}",
|
||||
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||
"email_error_inline": "Formato de e-mail inválido",
|
||||
"save_failed": "Falha ao salvar contato",
|
||||
"delete": "Excluir"
|
||||
"delete": "Excluir",
|
||||
"upload_photo": "Carregar foto",
|
||||
"remove_photo": "Remover foto",
|
||||
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
|
||||
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
|
||||
"photo_invalid": "Arquivo de imagem inválido",
|
||||
"change_photo": "Alterar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Novo grupo",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Falha ao criar contato",
|
||||
"error_update": "Falha ao atualizar contato",
|
||||
"error_delete": "Falha ao excluir contato"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar e-mail",
|
||||
"add_to_group": "Adicionar ao grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Excluir",
|
||||
"call": "Ligar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Selecionar",
|
||||
"clear": "Limpar",
|
||||
"close": "Fechar",
|
||||
"title": "Filtros avançados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Cargo",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Localização",
|
||||
"location_placeholder": "Cidade ou país",
|
||||
"email_domain": "Domínio de e-mail",
|
||||
"email_domain_placeholder": "exemplo.com",
|
||||
"birthday_month": "Aniversário em",
|
||||
"any_month": "Qualquer mês",
|
||||
"has_email": "Com e-mail",
|
||||
"has_phone": "Com telefone",
|
||||
"has_photo": "Com foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Provisório",
|
||||
"needs_action": "Aguardando resposta",
|
||||
"remove": "Remover",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
|
||||
"send_invitations": "Enviar convites aos participantes",
|
||||
"status_summary": "{accepted} aceito(s), {pending} pendente(s)",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
|
||||
"empty_search": "Нет контактов, соответствующих вашему запросу",
|
||||
"empty_search_hint": "Попробуйте другой поисковый запрос",
|
||||
"empty_filtered": "Нет контактов, соответствующих фильтрам",
|
||||
"empty_filtered_hint": "Измените или очистите фильтры",
|
||||
"clear_search": "Очистить поиск",
|
||||
"import_vcard": "Импорт vCard",
|
||||
"delete_confirm_title": "Удалить контакт",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "Календарь",
|
||||
"calendar_uri": "URL календаря",
|
||||
"scheduling_uri": "URL планирования",
|
||||
"freebusy_uri": "URL доступности"
|
||||
"freebusy_uri": "URL доступности",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Другие действия",
|
||||
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
|
||||
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новый контакт",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Введите корректный адрес электронной почты",
|
||||
"email_error_inline": "Неверный формат email",
|
||||
"save_failed": "Не удалось сохранить контакт",
|
||||
"delete": "Удалить"
|
||||
"delete": "Удалить",
|
||||
"upload_photo": "Загрузить фото",
|
||||
"remove_photo": "Удалить фото",
|
||||
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
|
||||
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
|
||||
"photo_invalid": "Недопустимый файл изображения",
|
||||
"change_photo": "Изменить"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Новая группа",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Не удалось создать контакт",
|
||||
"error_update": "Не удалось обновить контакт",
|
||||
"error_delete": "Не удалось удалить контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Открыть",
|
||||
"edit": "Изменить",
|
||||
"send_email": "Отправить письмо",
|
||||
"add_to_group": "Добавить в группу",
|
||||
"export_vcard": "Экспортировать как vCard",
|
||||
"delete": "Удалить",
|
||||
"call": "Позвонить",
|
||||
"duplicate": "Дублировать",
|
||||
"print": "Печать"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фильтры",
|
||||
"select": "Выбрать",
|
||||
"clear": "Очистить",
|
||||
"close": "Закрыть",
|
||||
"title": "Расширенные фильтры",
|
||||
"organization": "Компания",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Должность",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Местоположение",
|
||||
"location_placeholder": "Город или страна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "primer.ru",
|
||||
"birthday_month": "День рождения в",
|
||||
"any_month": "Любой месяц",
|
||||
"has_email": "С эл. почтой",
|
||||
"has_phone": "С телефоном",
|
||||
"has_photo": "С фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Предварительно",
|
||||
"needs_action": "Требует ответа",
|
||||
"remove": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"email_placeholder": "Добавьте адрес или найдите контакт",
|
||||
"send_invitations": "Отправить приглашения участникам",
|
||||
"status_summary": "{accepted} принято, {pending} ожидает",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
|
||||
"empty_search": "Жоден контакт не відповідає вашому пошуку",
|
||||
"empty_search_hint": "Спробуйте інший термін пошуку",
|
||||
"empty_filtered": "Жоден контакт не відповідає фільтрам",
|
||||
"empty_filtered_hint": "Змініть або очистіть фільтри",
|
||||
"clear_search": "Очистити пошук",
|
||||
"import_vcard": "Імпорт vCard",
|
||||
"delete_confirm_title": "Видалити контакт",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "Календар",
|
||||
"calendar_uri": "URL-адреса календаря",
|
||||
"scheduling_uri": "URL-адреса планування",
|
||||
"freebusy_uri": "Вільний/зайнятий URL"
|
||||
"freebusy_uri": "Вільний/зайнятий URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Більше дій",
|
||||
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
|
||||
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новий контакт",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "Введіть дійсну електронну адресу",
|
||||
"email_error_inline": "Недійсний формат електронної пошти",
|
||||
"save_failed": "Не вдалося зберегти контакт",
|
||||
"delete": "Видалити"
|
||||
"delete": "Видалити",
|
||||
"upload_photo": "Завантажити фото",
|
||||
"remove_photo": "Видалити фото",
|
||||
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
|
||||
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
|
||||
"photo_invalid": "Недійсний файл зображення",
|
||||
"change_photo": "Змінити"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Нова група",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "Не вдалося створити контакт",
|
||||
"error_update": "Не вдалося оновити контакт",
|
||||
"error_delete": "Не вдалося видалити контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Відкрити",
|
||||
"edit": "Редагувати",
|
||||
"send_email": "Надіслати лист",
|
||||
"add_to_group": "Додати до групи",
|
||||
"export_vcard": "Експортувати як vCard",
|
||||
"delete": "Видалити",
|
||||
"call": "Зателефонувати",
|
||||
"duplicate": "Дублювати",
|
||||
"print": "Друк"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фільтри",
|
||||
"select": "Вибрати",
|
||||
"clear": "Очистити",
|
||||
"close": "Закрити",
|
||||
"title": "Розширені фільтри",
|
||||
"organization": "Компанія",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Посада",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Місцезнаходження",
|
||||
"location_placeholder": "Місто або країна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "priklad.ua",
|
||||
"birthday_month": "День народження в",
|
||||
"any_month": "Будь-який місяць",
|
||||
"has_email": "З ел. поштою",
|
||||
"has_phone": "З телефоном",
|
||||
"has_photo": "З фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "Орієнтовний",
|
||||
"needs_action": "Потребує дії",
|
||||
"remove": "видалити",
|
||||
"edit": "Редагувати",
|
||||
"email_placeholder": "Додайте електронну адресу або знайдіть контакти",
|
||||
"send_invitations": "Надішліть запрошення учасникам",
|
||||
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
|
||||
|
||||
+60
-3
@@ -1737,6 +1737,8 @@
|
||||
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
|
||||
"empty_search": "没有符合您搜索条件的联系人",
|
||||
"empty_search_hint": "尝试不同的搜索词",
|
||||
"empty_filtered": "没有符合您筛选条件的联系人",
|
||||
"empty_filtered_hint": "请调整或清除筛选条件",
|
||||
"clear_search": "清除搜索",
|
||||
"import_vcard": "导入 vCard",
|
||||
"delete_confirm_title": "删除联系人",
|
||||
@@ -1814,7 +1816,22 @@
|
||||
"calendar": "日历",
|
||||
"calendar_uri": "日历 URL",
|
||||
"scheduling_uri": "调度 URL",
|
||||
"freebusy_uri": "空闲/忙碌 URL"
|
||||
"freebusy_uri": "空闲/忙碌 URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "更多操作",
|
||||
"age_years": "{count}岁",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1840,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新联系人",
|
||||
@@ -1919,7 +1938,13 @@
|
||||
"email_invalid": "请输入有效的邮箱地址",
|
||||
"email_error_inline": "邮箱地址格式无效",
|
||||
"save_failed": "保存联系人失败",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"upload_photo": "上传照片",
|
||||
"remove_photo": "移除照片",
|
||||
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
|
||||
"photo_too_large": "图像过大 (最大 10MB)",
|
||||
"photo_invalid": "无效的图像文件",
|
||||
"change_photo": "更改"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新建群组",
|
||||
@@ -1979,6 +2004,37 @@
|
||||
"error_create": "创建联系人失败",
|
||||
"error_update": "无法更新联系人",
|
||||
"error_delete": "删除联系人失败"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "打开",
|
||||
"edit": "编辑",
|
||||
"send_email": "发送邮件",
|
||||
"add_to_group": "加入群组",
|
||||
"export_vcard": "导出为 vCard",
|
||||
"delete": "删除",
|
||||
"call": "拨打电话",
|
||||
"duplicate": "复制",
|
||||
"print": "打印"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "筛选",
|
||||
"select": "选择",
|
||||
"clear": "清除",
|
||||
"close": "关闭",
|
||||
"title": "高级筛选",
|
||||
"organization": "公司",
|
||||
"organization_placeholder": "例如 Acme",
|
||||
"job_title": "职位",
|
||||
"job_title_placeholder": "例如 设计师",
|
||||
"location": "所在地",
|
||||
"location_placeholder": "城市或国家",
|
||||
"email_domain": "邮箱域名",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "生日月份",
|
||||
"any_month": "任意月份",
|
||||
"has_email": "有邮箱",
|
||||
"has_phone": "有电话",
|
||||
"has_photo": "有照片"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2110,7 @@
|
||||
"tentative": "暂定",
|
||||
"needs_action": "待处理",
|
||||
"remove": "移除",
|
||||
"edit": "编辑",
|
||||
"email_placeholder": "添加邮箱地址或搜索联系人",
|
||||
"send_invitations": "向参与者发送邀请",
|
||||
"status_summary": "{accepted} 已接受,{pending} 待处理",
|
||||
|
||||
@@ -5,6 +5,11 @@ import { afterEach, vi } from 'vitest';
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -13,6 +18,12 @@ vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/en',
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/en',
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user