Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b578c2aa3b | ||
|
|
6954c878d3 |
+1
-1
@@ -9,7 +9,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.27.02',
|
||||
version: '2026.08.27.03',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: Calendar and contacts
|
||||
name: calendar-and-contacts
|
||||
title: Calendar and contacts
|
||||
description: Use when the owner asks you to add, move, cancel or look up an event, or to add, remove or find a contact — "dinner Saturday with my wife", "move that to 9", "when am I seeing the CEO", "add Peter Griffin, this number". Carries the JSON routes, the one credential, and the timezone trap that puts every event an hour out.
|
||||
version: 2
|
||||
---
|
||||
|
||||
+146
-125
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||||
import { useDavEdit, CREATING, type NewEvent } from './useDavEdit';
|
||||
import { useFilter, matches } from './useFilter';
|
||||
import { useTranslation } from 'officerdev';
|
||||
|
||||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
||||
@@ -39,7 +40,14 @@ const blankDraft = (): Draft => {
|
||||
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||
start.setMinutes(0, 0, 0);
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
return { summary: '', location: '', date: localDate(start), start: localTime(start), end: localTime(end), allDay: false };
|
||||
return {
|
||||
summary: '',
|
||||
location: '',
|
||||
date: localDate(start),
|
||||
start: localTime(start),
|
||||
end: localTime(end),
|
||||
allDay: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,21 +79,29 @@ export const CalendarView = () => {
|
||||
const [draft, setDraft] = useState({ summary: '', location: '' });
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [draftNew, setDraftNew] = useState<Draft | null>(null);
|
||||
const filter = useFilter('Filter events');
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||||
|
||||
// Title, place, notes — and the DAY, so "saturday" or "2026-08-29" narrows to a date the way a person
|
||||
// expects. `start` is an ISO string, which is why the date is searchable at all without parsing it.
|
||||
const shown = events.filter((ev) => matches(filter.query, ev.summary, ev.location, ev.description, ev.start));
|
||||
|
||||
// Not an early return any more: the header carries the only way to ADD an event, and a calendar that
|
||||
// is empty or still loading is exactly when somebody wants it.
|
||||
// is empty or still loading is exactly when somebody wants it. A filter that matches nothing says so,
|
||||
// rather than looking like a calendar that lost its events.
|
||||
const notice = error
|
||||
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||
: isLoading
|
||||
? 'Loading events…'
|
||||
: events.length === 0
|
||||
? `${selected.displayName} has no events.`
|
||||
: null;
|
||||
: shown.length === 0
|
||||
? `No event matches “${filter.query}”.`
|
||||
: null;
|
||||
|
||||
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||||
const sorted = [...shown].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
|
||||
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
|
||||
for (const ev of sorted) {
|
||||
const key = dayKey(ev);
|
||||
@@ -110,9 +126,11 @@ export const CalendarView = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{events.length} {events.length === 1 ? 'event' : 'events'}
|
||||
{filter.query ? `${shown.length} of ${events.length}` : events.length}{' '}
|
||||
{events.length === 1 ? 'event' : 'events'}
|
||||
</div>
|
||||
</div>
|
||||
{filter.input}
|
||||
<button
|
||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
@@ -196,132 +214,135 @@ export const CalendarView = () => {
|
||||
|
||||
{notice && <Placeholder text={notice} />}
|
||||
{days.map((day) => (
|
||||
<section key={day.key} className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{formatDay(day.key)}
|
||||
</h2>
|
||||
{day.events.map((ev) => (
|
||||
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||
<span
|
||||
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
||||
{ev.rrule && (
|
||||
<span
|
||||
title={ev.rrule}
|
||||
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<Repeat className="h-3 w-3" />
|
||||
repeats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
|
||||
<section key={day.key} className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{formatDay(day.key)}
|
||||
</h2>
|
||||
{day.events.map((ev) => (
|
||||
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||
<span
|
||||
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
||||
{ev.rrule && (
|
||||
<span
|
||||
title={ev.rrule}
|
||||
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<Repeat className="h-3 w-3" />
|
||||
repeats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
|
||||
|
||||
{/* The editor is the title and the place, and nothing else. Those are the two fields a
|
||||
{/* The editor is the title and the place, and nothing else. Those are the two fields a
|
||||
person actually corrects; times and recurrence are where an editor does damage, and
|
||||
a half-editor that silently rewrites an RRULE is worse than none. */}
|
||||
{editing === ev.href && (
|
||||
<form
|
||||
className="mt-2 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!ev.href) return;
|
||||
// `null` removes the line; an empty string would leave an empty LOCATION behind.
|
||||
void edit
|
||||
.save(ev.href, { summary: draft.summary, location: draft.location || null })
|
||||
.then((ok) => ok && setEditing(null));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft.summary}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
|
||||
placeholder="Title"
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={draft.location}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
|
||||
placeholder="Where"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={edit.busy === ev.href}
|
||||
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{editing === ev.href && (
|
||||
<form
|
||||
className="mt-2 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!ev.href) return;
|
||||
// `null` removes the line; an empty string would leave an empty LOCATION behind.
|
||||
void edit
|
||||
.save(ev.href, { summary: draft.summary, location: draft.location || null })
|
||||
.then((ok) => ok && setEditing(null));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft.summary}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
|
||||
placeholder="Title"
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={draft.location}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
|
||||
placeholder="Where"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={edit.busy === ev.href}
|
||||
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{ev.location && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{ev.location}</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{ev.location && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{ev.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{ev.description && (
|
||||
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Only for an event we can actually address. An item with no href came from somewhere
|
||||
that did not give us one, and offering a delete that cannot work is worse than not
|
||||
offering it. */}
|
||||
{ev.href && editing !== ev.href && (
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
{confirming === ev.href ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void edit.remove(ev.href!).then(() => setConfirming(null))}
|
||||
disabled={edit.busy === ev.href}
|
||||
title="Delete this event"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({ summary: ev.summary ?? '', location: ev.location ?? '' });
|
||||
setEditing(ev.href!);
|
||||
}}
|
||||
title="Edit"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(ev.href!)}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{ev.description && (
|
||||
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
{/* Only for an event we can actually address. An item with no href came from somewhere
|
||||
that did not give us one, and offering a delete that cannot work is worse than not
|
||||
offering it. */}
|
||||
{ev.href && editing !== ev.href && (
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
{confirming === ev.href ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void edit.remove(ev.href!).then(() => setConfirming(null))}
|
||||
disabled={edit.busy === ev.href}
|
||||
title="Delete this event"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(null)}
|
||||
className="rounded p-1 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({ summary: ev.summary ?? '', location: ev.location ?? '' });
|
||||
setEditing(ev.href!);
|
||||
}}
|
||||
title="Edit"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(ev.href!)}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+143
-134
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||
import { useDavEdit, CREATING } from './useDavEdit';
|
||||
import { useFilter, matches } from './useFilter';
|
||||
import { useTranslation } from 'officerdev';
|
||||
|
||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||
@@ -31,21 +32,27 @@ export const ContactsView = () => {
|
||||
const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' });
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [draftNew, setDraftNew] = useState<ReturnType<typeof blankDraft> | null>(null);
|
||||
const filter = useFilter('Filter contacts');
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||
|
||||
// Everything a person might half-remember about someone: the name, either address, the company.
|
||||
const shown = contacts.filter((c) => matches(filter.query, c.fullName, c.org, c.title, ...c.emails, ...c.phones));
|
||||
|
||||
// Not an early return: the header carries the only way to ADD a card, and an empty address book is
|
||||
// exactly when somebody wants it.
|
||||
// exactly when somebody wants it. A filter that matches nothing says SO — otherwise it reads as an
|
||||
// address book that lost its contacts.
|
||||
const notice = error
|
||||
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||
: isLoading
|
||||
? 'Loading contacts…'
|
||||
: contacts.length === 0
|
||||
? `${selected.displayName} has no contacts.`
|
||||
: null;
|
||||
|
||||
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||
: shown.length === 0
|
||||
? `No contact matches “${filter.query}”.`
|
||||
: null;
|
||||
const sorted = [...shown].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||
|
||||
const submitNew = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -65,9 +72,11 @@ export const ContactsView = () => {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contacts.length} {contacts.length === 1 ? 'contact' : 'contacts'}
|
||||
{filter.query ? `${shown.length} of ${contacts.length}` : contacts.length}{' '}
|
||||
{contacts.length === 1 ? 'contact' : 'contacts'}
|
||||
</div>
|
||||
</div>
|
||||
{filter.input}
|
||||
<button
|
||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
@@ -128,141 +137,141 @@ export const ContactsView = () => {
|
||||
{notice && <Placeholder text={notice} />}
|
||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
||||
{sorted.map((contact) => (
|
||||
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
||||
{initials(contact.fullName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
||||
{(contact.title || contact.org) && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.emails.map((email) => (
|
||||
<a
|
||||
key={email}
|
||||
href={`mailto:${email}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{email}</span>
|
||||
</a>
|
||||
))}
|
||||
{contact.phones.map((phone) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Phone className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{phone}</span>
|
||||
</a>
|
||||
))}
|
||||
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
||||
{initials(contact.fullName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
||||
{(contact.title || contact.org) && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.emails.map((email) => (
|
||||
<a
|
||||
key={email}
|
||||
href={`mailto:${email}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{email}</span>
|
||||
</a>
|
||||
))}
|
||||
{contact.phones.map((phone) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone}`}
|
||||
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Phone className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{phone}</span>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Name, one email, one phone — the three fields worth correcting from a list. A card can
|
||||
{/* Name, one email, one phone — the three fields worth correcting from a list. A card can
|
||||
hold several of each; editing rewrites the FIRST and leaves the rest untouched, which
|
||||
is why `setProperty` replaces a line rather than rebuilding the card. */}
|
||||
{editing === contact.href && (
|
||||
<form
|
||||
className="mt-2 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!contact.href) return;
|
||||
/* The form shows the FIRST email and phone; a card can hold several. Sending just
|
||||
{editing === contact.href && (
|
||||
<form
|
||||
className="mt-2 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!contact.href) return;
|
||||
/* The form shows the FIRST email and phone; a card can hold several. Sending just
|
||||
the one would delete the rest, so the others are carried through untouched. */
|
||||
void edit
|
||||
.save(contact.href, {
|
||||
fullName: draft.fullName,
|
||||
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
|
||||
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
|
||||
})
|
||||
.then((ok) => ok && setEditing(null));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft.fullName}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
||||
placeholder="Name"
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={draft.email}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
||||
placeholder="Email"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
value={draft.phone}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
||||
placeholder="Phone"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={edit.busy === contact.href}
|
||||
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3 w-3" /> {edit.busy === contact.href ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contact.href && editing !== contact.href && (
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
{confirming === contact.href ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void edit.remove(contact.href!).then(() => setConfirming(null))}
|
||||
disabled={edit.busy === contact.href}
|
||||
title="Delete this contact"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
fullName: contact.fullName ?? '',
|
||||
email: contact.emails[0] ?? '',
|
||||
phone: contact.phones[0] ?? '',
|
||||
});
|
||||
setEditing(contact.href!);
|
||||
}}
|
||||
title="Edit"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(contact.href!)}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
void edit
|
||||
.save(contact.href, {
|
||||
fullName: draft.fullName,
|
||||
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
|
||||
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
|
||||
})
|
||||
.then((ok) => ok && setEditing(null));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft.fullName}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
||||
placeholder="Name"
|
||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={draft.email}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
||||
placeholder="Email"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
value={draft.phone}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
||||
placeholder="Phone"
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={edit.busy === contact.href}
|
||||
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3 w-3" /> {edit.busy === contact.href ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contact.href && editing !== contact.href && (
|
||||
<div className="flex shrink-0 items-start gap-1">
|
||||
{confirming === contact.href ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void edit.remove(contact.href!).then(() => setConfirming(null))}
|
||||
disabled={edit.busy === contact.href}
|
||||
title="Delete this contact"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
fullName: contact.fullName ?? '',
|
||||
email: contact.emails[0] ?? '',
|
||||
phone: contact.phones[0] ?? '',
|
||||
});
|
||||
setEditing(contact.href!);
|
||||
}}
|
||||
title="Edit"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(contact.href!)}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { matches } from './useFilter';
|
||||
|
||||
// The accent rule is the one that matters on this machine: an address book of Portuguese names is
|
||||
// unsearchable if "jose" does not find "José".
|
||||
|
||||
describe('matches', () => {
|
||||
test('an empty query keeps everything', () => {
|
||||
expect(matches('', 'anything')).toBe(true);
|
||||
expect(matches(' ', 'anything')).toBe(true);
|
||||
});
|
||||
|
||||
test('is case insensitive', () => {
|
||||
expect(matches('RAMIRO', 'Cervejaria Ramiro')).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores accents in BOTH directions', () => {
|
||||
expect(matches('jose', 'José Conceição')).toBe(true);
|
||||
expect(matches('josé', 'Jose Conceicao')).toBe(true);
|
||||
expect(matches('conceicao', 'José Conceição')).toBe(true);
|
||||
});
|
||||
|
||||
test('every term must match, but they may land in different fields', () => {
|
||||
expect(matches('ana lisboa', 'Ana Silva', 'Lisboa')).toBe(true);
|
||||
expect(matches('ana porto', 'Ana Silva', 'Lisboa')).toBe(false);
|
||||
});
|
||||
|
||||
test('skips absent fields rather than matching on "undefined"', () => {
|
||||
expect(matches('undefined', 'Ana Silva', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('a date is searchable because start is an ISO string', () => {
|
||||
expect(matches('2026-08-29', 'Dinner', undefined, undefined, '2026-08-29T19:00:00Z')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* A filter box for a list that is already in the browser.
|
||||
*
|
||||
* Client-side, deliberately. Both screens load a whole collection in one request and hold it — a
|
||||
* calendar of a few hundred events, an address book of a few hundred cards — so filtering is a string
|
||||
* comparison over data already in hand. A server round trip per keystroke would be slower, would need a
|
||||
* search endpoint on the sidecar, and would show a spinner where a person expects a list to narrow.
|
||||
*
|
||||
* Matching is CASE- AND ACCENT-INSENSITIVE, and that second half is not decoration here: this owner's
|
||||
* contacts are Portuguese. Typing "jose" must find "José" or the box is useless for half the address
|
||||
* book. `NFD` splits a letter from its accent and the range strips what is left behind.
|
||||
*
|
||||
* Every term must match SOMEWHERE, not all in one field — so "ana lisboa" finds the Ana whose address is
|
||||
* in Lisbon, which is how people actually search a contact list.
|
||||
*/
|
||||
const fold = (value: string) => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
|
||||
export const matches = (query: string, ...fields: (string | undefined)[]): boolean => {
|
||||
const terms = fold(query).split(/\s+/).filter(Boolean);
|
||||
if (!terms.length) return true;
|
||||
const haystack = fold(fields.filter(Boolean).join(' '));
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
};
|
||||
|
||||
export function useFilter(placeholder: string) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const input = (
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-md border bg-background py-1 pl-7 pr-7 text-xs"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
title="Clear"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { query, input };
|
||||
}
|
||||
Reference in New Issue
Block a user