///
/**
* Contacts - Master-detail container for PersonLike items.
*
* Architecture: Stores charm results (pattern outputs with [UI]), not raw data.
* Pattern instantiation happens at insertion time in handlers.
*
* Features:
* - Add/remove Person and FamilyMember contacts
* - Master-detail layout with resizable panels
* - Groups data model (UI deferred due to framework limitation:
* computed() siblings of .map() break reactive rendering)
*/
import {
action,
computed,
type Default,
handler,
NAME,
navigateTo,
pattern,
UI,
type VNode,
Writable,
} from "commontools";
// Import shared types
import type {
ContactGroup,
ContactPiece,
FamilyMember,
Person,
} from "./contact-types.tsx";
// Import patterns (they return pieces with [UI])
import PersonPattern from "./person.tsx";
import FamilyMemberPattern from "./family-member.tsx";
// ============================================================================
// Input/Output Schemas
// ============================================================================
interface Input {
// Store piece results, not raw data
contacts: Writable>;
groups: Writable>;
}
interface Output {
[NAME]: string;
[UI]: VNode;
contacts: ContactPiece[];
groups: ContactGroup[];
count: number;
}
// ============================================================================
// Handlers - Instantiate patterns here, push piece results
// ============================================================================
const addPerson = handler<
unknown,
{
contacts: Writable;
selectedIndex: Writable;
}
>((_event, { contacts, selectedIndex }) => {
const personData = Writable.of({
firstName: "",
lastName: "",
middleName: "",
nickname: "",
prefix: "",
suffix: "",
pronouns: "",
birthday: { month: 0, day: 0, year: 0 },
photo: "",
email: "",
phone: "",
notes: "",
tags: [],
addresses: [],
socialProfiles: [],
});
const charm = PersonPattern({
person: personData,
sameAs: contacts,
});
const newIndex = (contacts.get() || []).length;
contacts.push(charm as ContactPiece);
selectedIndex.set(newIndex);
});
const addFamilyMember = handler<
unknown,
{
contacts: Writable;
selectedIndex: Writable;
}
>((_event, { contacts, selectedIndex }) => {
const memberData = Writable.of({
firstName: "",
lastName: "",
relationship: "",
birthday: "",
dietaryRestrictions: [],
notes: "",
tags: [],
allergies: [],
giftIdeas: [],
});
const charm = FamilyMemberPattern({
member: memberData,
sameAs: contacts,
});
const newIndex = (contacts.get() || []).length;
contacts.push(charm as ContactPiece);
selectedIndex.set(newIndex);
});
const removeContact = handler<
unknown,
{
contacts: Writable;
groups: Writable;
index: number;
selectedIndex: Writable;
}
>((_event, { contacts, groups, index, selectedIndex }) => {
const current = contacts.get() || [];
contacts.set(current.toSpliced(index, 1));
// Update group indices: remove references to deleted index, shift higher indices
const currentGroups = groups.get() || [];
const updatedGroups = currentGroups.map((g) => ({
...g,
contactIndices: (g.contactIndices || [])
.filter((i: number) => i !== index)
.map((i: number) => (i > index ? i - 1 : i)),
}));
groups.set(updatedGroups);
const sel = selectedIndex.get();
if (sel >= current.length - 1) {
selectedIndex.set(Math.max(-1, current.length - 2));
} else if (sel > index) {
selectedIndex.set(sel - 1);
}
});
const selectContact = handler<
unknown,
{ selectedIndex: Writable; index: number }
>((_event, { selectedIndex, index }) => {
selectedIndex.set(index);
});
// Group handlers
const addGroup = handler<
unknown,
{ groups: Writable }
>((_event, { groups }) => {
const current = groups.get() || [];
groups.set([...current, { name: "New Group", contactIndices: [] }]);
});
// ============================================================================
// Pattern
// ============================================================================
export default pattern(({ contacts, groups }) => {
const count = computed(() => (contacts.get() || []).length);
const selectedIndex = Writable.of(-1);
const openInNewView = action(() => {
const idx = selectedIndex.get();
if (idx < 0) return;
const charm = contacts.key(idx).get();
return navigateTo(charm);
});
return {
[NAME]: computed(() => `Contacts (${count})`),
[UI]: (
Contacts
+ Person
+ Family
+ Group
{/* Left: Contact List */}
{/* Render contact list using reactive .map() */}
{contacts.map((charm, index) => (
selectedIndex.get() === index
? "background: var(--ct-color-blue-50, #eff6ff); border: 1px solid var(--ct-color-blue-300, #93c5fd); cursor: pointer;"
: "cursor: pointer;"
)}
onClick={selectContact({ selectedIndex, index })}
>
{computed(() => {
const name = charm[NAME] || "";
const parts = name.split(" ");
if (parts.length >= 2) {
return (parts[0].charAt(0) + parts[1].charAt(0))
.toUpperCase();
}
return name.charAt(0).toUpperCase() || "?";
})}
{charm[NAME]}
×
))}
{/* Right: Detail View - just render the piece's [UI] */}
{computed(() => {
const idx = selectedIndex.get();
if (idx < 0 || idx >= (contacts.get() || []).length) {
return (
←
Select a contact to view details
);
}
const piece = contacts.key(idx);
// Piece already has [UI] - just render it with wrapper
return (
Open ↗
);
})}
),
contacts,
groups,
count,
};
});