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 = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '2026.08.27.02',
|
version: '2026.08.27.03',
|
||||||
platform: '>=1.0.0',
|
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.
|
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
|
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 { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||||||
import { useDavEdit, CREATING, type NewEvent } from './useDavEdit';
|
import { useDavEdit, CREATING, type NewEvent } from './useDavEdit';
|
||||||
|
import { useFilter, matches } from './useFilter';
|
||||||
import { useTranslation } from 'officerdev';
|
import { useTranslation } from 'officerdev';
|
||||||
|
|
||||||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
// 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);
|
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
start.setMinutes(0, 0, 0);
|
start.setMinutes(0, 0, 0);
|
||||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
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 [draft, setDraft] = useState({ summary: '', location: '' });
|
||||||
const [confirming, setConfirming] = useState<string | null>(null);
|
const [confirming, setConfirming] = useState<string | null>(null);
|
||||||
const [draftNew, setDraftNew] = useState<Draft | null>(null);
|
const [draftNew, setDraftNew] = useState<Draft | null>(null);
|
||||||
|
const filter = useFilter('Filter events');
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
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
|
// 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
|
const notice = error
|
||||||
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||||
: isLoading
|
: isLoading
|
||||||
? 'Loading events…'
|
? 'Loading events…'
|
||||||
: events.length === 0
|
: events.length === 0
|
||||||
? `${selected.displayName} has no events.`
|
? `${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[] }> = [];
|
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
|
||||||
for (const ev of sorted) {
|
for (const ev of sorted) {
|
||||||
const key = dayKey(ev);
|
const key = dayKey(ev);
|
||||||
@@ -110,9 +126,11 @@ export const CalendarView = () => {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{filter.input}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
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"
|
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} />}
|
{notice && <Placeholder text={notice} />}
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<section key={day.key} className="flex flex-col gap-2">
|
<section key={day.key} className="flex flex-col gap-2">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{formatDay(day.key)}
|
{formatDay(day.key)}
|
||||||
</h2>
|
</h2>
|
||||||
{day.events.map((ev) => (
|
{day.events.map((ev) => (
|
||||||
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
||||||
<span
|
<span
|
||||||
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
style={{ backgroundColor: selected.color || '#94a3b8' }}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
|
||||||
{ev.rrule && (
|
{ev.rrule && (
|
||||||
<span
|
<span
|
||||||
title={ev.rrule}
|
title={ev.rrule}
|
||||||
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
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" />
|
<Repeat className="h-3 w-3" />
|
||||||
repeats
|
repeats
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</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
|
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. */}
|
a half-editor that silently rewrites an RRULE is worse than none. */}
|
||||||
{editing === ev.href && (
|
{editing === ev.href && (
|
||||||
<form
|
<form
|
||||||
className="mt-2 flex flex-col gap-2"
|
className="mt-2 flex flex-col gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!ev.href) return;
|
if (!ev.href) return;
|
||||||
// `null` removes the line; an empty string would leave an empty LOCATION behind.
|
// `null` removes the line; an empty string would leave an empty LOCATION behind.
|
||||||
void edit
|
void edit
|
||||||
.save(ev.href, { summary: draft.summary, location: draft.location || null })
|
.save(ev.href, { summary: draft.summary, location: draft.location || null })
|
||||||
.then((ok) => ok && setEditing(null));
|
.then((ok) => ok && setEditing(null));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draft.summary}
|
value={draft.summary}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={draft.location}
|
value={draft.location}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
|
||||||
placeholder="Where"
|
placeholder="Where"
|
||||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={edit.busy === ev.href}
|
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"
|
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'}
|
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(null)}
|
onClick={() => setEditing(null)}
|
||||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
)}
|
||||||
)}
|
{ev.description && (
|
||||||
{ev.location && (
|
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Only for an event we can actually address. An item with no href came from somewhere
|
||||||
</article>
|
that did not give us one, and offering a delete that cannot work is worse than not
|
||||||
))}
|
offering it. */}
|
||||||
</section>
|
{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>
|
||||||
</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 { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react';
|
||||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||||
import { useDavEdit, CREATING } from './useDavEdit';
|
import { useDavEdit, CREATING } from './useDavEdit';
|
||||||
|
import { useFilter, matches } from './useFilter';
|
||||||
import { useTranslation } from 'officerdev';
|
import { useTranslation } from 'officerdev';
|
||||||
|
|
||||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
// 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 [draft, setDraft] = useState({ fullName: '', email: '', phone: '' });
|
||||||
const [confirming, setConfirming] = useState<string | null>(null);
|
const [confirming, setConfirming] = useState<string | null>(null);
|
||||||
const [draftNew, setDraftNew] = useState<ReturnType<typeof blankDraft> | 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 (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
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
|
// 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
|
const notice = error
|
||||||
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
? t('dav.readFailed', { name: selected.displayName, reason: String(error) })
|
||||||
: isLoading
|
: isLoading
|
||||||
? 'Loading contacts…'
|
? 'Loading contacts…'
|
||||||
: contacts.length === 0
|
: contacts.length === 0
|
||||||
? `${selected.displayName} has no contacts.`
|
? `${selected.displayName} has no contacts.`
|
||||||
: null;
|
: shown.length === 0
|
||||||
|
? `No contact matches “${filter.query}”.`
|
||||||
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
: null;
|
||||||
|
const sorted = [...shown].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
||||||
|
|
||||||
const submitNew = (e: React.FormEvent) => {
|
const submitNew = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -65,9 +72,11 @@ export const ContactsView = () => {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
<div className="truncate text-sm font-semibold leading-tight">{selected.displayName}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{filter.input}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDraftNew((d) => (d ? null : blankDraft()))}
|
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"
|
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} />}
|
{notice && <Placeholder text={notice} />}
|
||||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
||||||
{sorted.map((contact) => (
|
{sorted.map((contact) => (
|
||||||
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
<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">
|
<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)}
|
{initials(contact.fullName)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
||||||
{(contact.title || contact.org) && (
|
{(contact.title || contact.org) && (
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Building2 className="h-3 w-3 shrink-0" />
|
<Building2 className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{contact.emails.map((email) => (
|
{contact.emails.map((email) => (
|
||||||
<a
|
<a
|
||||||
key={email}
|
key={email}
|
||||||
href={`mailto:${email}`}
|
href={`mailto:${email}`}
|
||||||
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Mail className="h-3 w-3 shrink-0" />
|
<Mail className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">{email}</span>
|
<span className="truncate">{email}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{contact.phones.map((phone) => (
|
{contact.phones.map((phone) => (
|
||||||
<a
|
<a
|
||||||
key={phone}
|
key={phone}
|
||||||
href={`tel:${phone}`}
|
href={`tel:${phone}`}
|
||||||
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
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" />
|
<Phone className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">{phone}</span>
|
<span className="truncate">{phone}</span>
|
||||||
</a>
|
</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
|
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. */}
|
is why `setProperty` replaces a line rather than rebuilding the card. */}
|
||||||
{editing === contact.href && (
|
{editing === contact.href && (
|
||||||
<form
|
<form
|
||||||
className="mt-2 flex flex-col gap-2"
|
className="mt-2 flex flex-col gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!contact.href) return;
|
if (!contact.href) return;
|
||||||
/* The form shows the FIRST email and phone; a card can hold several. Sending just
|
/* 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. */
|
the one would delete the rest, so the others are carried through untouched. */
|
||||||
void edit
|
void edit
|
||||||
.save(contact.href, {
|
.save(contact.href, {
|
||||||
fullName: draft.fullName,
|
fullName: draft.fullName,
|
||||||
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
|
emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean),
|
||||||
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
|
phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean),
|
||||||
})
|
})
|
||||||
.then((ok) => ok && setEditing(null));
|
.then((ok) => ok && setEditing(null));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draft.fullName}
|
value={draft.fullName}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
className="rounded-md border bg-background px-2 py-1 text-sm"
|
className="rounded-md border bg-background px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={draft.email}
|
value={draft.email}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
className="rounded-md border bg-background px-2 py-1 text-xs"
|
className="rounded-md border bg-background px-2 py-1 text-xs"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={draft.phone}
|
value={draft.phone}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
||||||
placeholder="Phone"
|
placeholder="Phone"
|
||||||
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={edit.busy === contact.href}
|
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"
|
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'}
|
<Check className="h-3 w-3" /> {edit.busy === contact.href ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(null)}
|
onClick={() => setEditing(null)}
|
||||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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