Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions components/PersonPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import PersonFormModal from '@/components/PersonFormModal';
import CoatOfArmsDisplay from '@/components/person/CoatOfArmsDisplay';
import NotableEditorModal from '@/components/person/NotableEditorModal';
import { PendingFindingsIndicator } from '@/components/person/PendingFindingsIndicator';
import PersonConnectionsCard from '@/components/person/PersonConnectionsCard';
import PersonFactsCard from '@/components/person/PersonFactsCard';
import PersonHeroCard from '@/components/person/PersonHeroCard';
import PersonRelativesList from '@/components/person/PersonRelativesList';
Expand Down Expand Up @@ -242,6 +243,12 @@ export default function PersonPageClient({ personId }: Props) {
canEdit={canEdit}
/>

{/* Non-family connections */}
<PersonConnectionsCard
personId={personId}
canEdit={canEdit}
/>

{/* Coat of Arms */}
<CoatOfArmsDisplay facts={person.facts} />

Expand Down
344 changes: 344 additions & 0 deletions components/person/PersonConnectionsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
'use client';

import { useMutation, useQuery } from '@apollo/client/react';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import PersonSearchSelect from '@/components/PersonSearchSelect';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableRow,
} from '@/components/ui';
import {
ADD_PERSON_CONNECTION,
GET_PERSON_CONNECTIONS,
REMOVE_PERSON_CONNECTION,
UPDATE_PERSON_CONNECTION,
} from '@/lib/graphql/queries';
import type { PersonConnection } from '@/lib/types';

const CONNECTION_TYPES = [
{ value: 'friend', label: 'Friend' },
{ value: 'colleague', label: 'Colleague' },
{ value: 'neighbor', label: 'Neighbor' },
{ value: 'employer', label: 'Employer' },
{ value: 'employee', label: 'Employee' },
{ value: 'godparent', label: 'Godparent' },
{ value: 'godchild', label: 'Godchild' },
{ value: 'other', label: 'Other' },
];

interface ConnectedPerson {
id: string;
name_full: string;
birth_year?: number | null;
death_year?: number | null;
}

type ConnectionWithPerson = PersonConnection & { person: ConnectedPerson };

interface PersonConnectionsCardProps {
personId: string;
canEdit: boolean;
}

const emptyForm = {
other_person_id: '',
connection_type: 'friend',
notes: '',
start_year: '',
end_year: '',
};

export default function PersonConnectionsCard({
personId,
canEdit,
}: PersonConnectionsCardProps) {
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState(emptyForm);

const { data, loading } = useQuery<{
person: { connections: ConnectionWithPerson[] } | null;
}>(GET_PERSON_CONNECTIONS, { variables: { id: personId } });

const connections = data?.person?.connections ?? [];

const refetchQueries = [
{ query: GET_PERSON_CONNECTIONS, variables: { id: personId } },
];

const [addConnection, { loading: adding }] = useMutation(
ADD_PERSON_CONNECTION,
{
refetchQueries,
onCompleted: () => {
toast.success('Connection added');
resetForm();
},
onError: (err) => toast.error('Error', { description: err.message }),
},
);

const [updateConnection, { loading: updating }] = useMutation(
UPDATE_PERSON_CONNECTION,
{
refetchQueries,
onCompleted: () => {
toast.success('Connection updated');
resetForm();
},
onError: (err) => toast.error('Error', { description: err.message }),
},
);

const [removeConnection] = useMutation(REMOVE_PERSON_CONNECTION, {
refetchQueries,
onCompleted: () => toast.success('Connection removed'),
onError: (err) => toast.error('Error', { description: err.message }),
});

const resetForm = () => {
setFormData(emptyForm);
setEditingId(null);
setShowForm(false);
};

const handleEdit = (conn: ConnectionWithPerson) => {
setFormData({
other_person_id: conn.person.id,
connection_type: conn.connection_type,
notes: conn.notes || '',
start_year: conn.start_year?.toString() || '',
end_year: conn.end_year?.toString() || '',
});
setEditingId(conn.id.toString());
setShowForm(true);
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const input = {
other_person_id: formData.other_person_id || undefined,
connection_type: formData.connection_type,
notes: formData.notes || null,
start_year: formData.start_year ? parseInt(formData.start_year, 10) : null,
end_year: formData.end_year ? parseInt(formData.end_year, 10) : null,
};
if (editingId) {
await updateConnection({ variables: { id: editingId, input } });
} else {
if (!formData.other_person_id) {
toast.error('Please select a person');
return;
}
await addConnection({ variables: { personId, input } });
}
};

const handleDelete = async (id: number) => {
if (confirm('Remove this connection?')) {
await removeConnection({ variables: { id } });
}
};

if (loading) return null;
if (connections.length === 0 && !canEdit) return null;

return (
<Card className="mb-6">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Connections</CardTitle>
{canEdit && !showForm && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowForm(true)}
icon={<Plus className="size-4" />}
>
Add
</Button>
)}
</div>
</CardHeader>
<CardContent>
{showForm && (
<form
onSubmit={handleSubmit}
className="mb-4 p-3 bg-muted rounded-lg space-y-2"
>
{!editingId && (
<div>
<Label className="text-xs mb-1">Person</Label>
<PersonSearchSelect
value={formData.other_person_id}
onChange={(id) =>
setFormData((p) => ({ ...p, other_person_id: id || '' }))
}
placeholder="Search for a person..."
/>
</div>
)}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs mb-1">Type</Label>
<Select
value={formData.connection_type}
onValueChange={(v) =>
setFormData((p) => ({ ...p, connection_type: v }))
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONNECTION_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1">From year</Label>
<Input
className="h-8 text-sm"
type="number"
value={formData.start_year}
onChange={(e) =>
setFormData((p) => ({ ...p, start_year: e.target.value }))
}
placeholder="e.g. 1950"
/>
</div>
<div>
<Label className="text-xs mb-1">To year</Label>
<Input
className="h-8 text-sm"
type="number"
value={formData.end_year}
onChange={(e) =>
setFormData((p) => ({ ...p, end_year: e.target.value }))
}
placeholder="e.g. 1970"
/>
</div>
</div>
<div>
<Label className="text-xs mb-1">Notes</Label>
<Input
className="h-8 text-sm"
value={formData.notes}
onChange={(e) =>
setFormData((p) => ({ ...p, notes: e.target.value }))
}
placeholder="Optional notes..."
/>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" loading={adding || updating}>
{editingId ? 'Update' : 'Add'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={resetForm}
>
Cancel
</Button>
</div>
</form>
)}

{connections.length > 0 ? (
<Table>
<TableBody>
{connections.map((conn) => (
<TableRow key={conn.id} className="group">
<TableCell className="w-28 py-2">
<Badge variant="outline">
{CONNECTION_TYPES.find(
(t) => t.value === conn.connection_type,
)?.label ?? conn.connection_type}
</Badge>
</TableCell>
<TableCell className="py-2 text-sm">
<a
href={`/people/${conn.person.id}`}
className="font-medium hover:underline"
>
{conn.person.name_full}
</a>
{(conn.person.birth_year || conn.person.death_year) && (
<span className="text-muted-foreground text-xs ml-1">
({conn.person.birth_year ?? '?'}
{conn.person.death_year
? ` – ${conn.person.death_year}`
: ''}
)
</span>
)}
{conn.notes && (
<p className="text-xs text-muted-foreground mt-0.5">
{conn.notes}
</p>
)}
{(conn.start_year || conn.end_year) && (
<p className="text-xs text-muted-foreground">
{conn.start_year ?? '?'}
{conn.end_year ? ` – ${conn.end_year}` : '+'}
</p>
)}
</TableCell>
{canEdit && (
<TableCell className="w-16 py-2">
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleEdit(conn)}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={() => handleDelete(conn.id)}
>
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">
No connections recorded yet.
</p>
)}
</CardContent>
</Card>
);
}
Loading
Loading