diff --git a/app/api/family-notes/[family]/route.ts b/app/api/family-notes/[family]/route.ts deleted file mode 100644 index eea89ac..0000000 --- a/app/api/family-notes/[family]/route.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; -import { getGraphQLClient } from '@/lib/graphql/client'; - -interface FamilyResearchNote { - id: string; - familyName: string; - content: string; - updatedAt: string; -} - -interface FamilyNoteQueryResponse { - familyResearchNote: FamilyResearchNote | null; -} - -interface UpsertFamilyNoteMutationResponse { - upsertFamilyResearchNote: FamilyResearchNote; -} - -const FAMILY_NOTE_QUERY = ` - query FamilyResearchNote($familyName: String!) { - familyResearchNote(familyName: $familyName) { - id - familyName - content - updatedAt - } - } -`; - -const UPSERT_FAMILY_NOTE_MUTATION = ` - mutation UpsertFamilyResearchNote($input: FamilyResearchNoteInput!) { - upsertFamilyResearchNote(input: $input) { - id - familyName - content - updatedAt - } - } -`; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ family: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { family } = await params; - const client = getGraphQLClient(); - const { data, errors } = await client.request(FAMILY_NOTE_QUERY, { - familyName: family.toUpperCase(), - }); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to fetch family note' }, - { status: 500 } - ); - } - - if (!data?.familyResearchNote) { - return NextResponse.json( - { error: 'Family notes not found' }, - { status: 404 } - ); - } - - return NextResponse.json({ - family: data.familyResearchNote.familyName, - content: data.familyResearchNote.content, - lastModified: data.familyResearchNote.updatedAt, - }); - } catch (error: any) { - console.error('Error reading family notes:', error); - return NextResponse.json( - { error: 'Failed to read family notes' }, - { status: 500 } - ); - } -} - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ family: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { family } = await params; - const { content } = await request.json(); - - if (typeof content !== 'string') { - return NextResponse.json( - { error: 'Invalid content format' }, - { status: 400 } - ); - } - - const client = getGraphQLClient(); - const { data, errors } = await client.request(UPSERT_FAMILY_NOTE_MUTATION, { - input: { - familyName: family.toUpperCase(), - content, - }, - }, { - 'x-user-id': session.user.id!, - }); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to update family notes' }, - { status: 500 } - ); - } - - return NextResponse.json({ - success: true, - lastModified: data?.upsertFamilyResearchNote.updatedAt, - }); - } catch (error) { - console.error('Error updating family notes:', error); - return NextResponse.json( - { error: 'Failed to update family notes' }, - { status: 500 } - ); - } -} diff --git a/app/api/family-notes/route.ts b/app/api/family-notes/route.ts deleted file mode 100644 index a925e07..0000000 --- a/app/api/family-notes/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; -import { getGraphQLClient } from '@/lib/graphql/client'; - -interface FamilyResearchNote { - id: string; - familyName: string; - content: string; - createdAt: string; - updatedAt: string; -} - -interface FamilyNotesQueryResponse { - familyResearchNotes: FamilyResearchNote[]; -} - -const FAMILY_NOTES_QUERY = ` - query FamilyResearchNotes { - familyResearchNotes { - id - familyName - content - createdAt - updatedAt - } - } -`; - -export async function GET(request: NextRequest) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const client = getGraphQLClient(); - const { data, errors } = await client.request(FAMILY_NOTES_QUERY); - - if (errors) { - console.error('GraphQL errors:', errors); - return NextResponse.json( - { error: 'Failed to fetch family notes' }, - { status: 500 } - ); - } - - // Transform to match expected format - const families = (data?.familyResearchNotes || []).map((note) => ({ - family: note.familyName.toLowerCase(), - title: note.familyName.replace(/_/g, ' '), - size: note.content.length, - lastModified: note.updatedAt, - })); - - families.sort((a, b) => a.family.localeCompare(b.family)); - - return NextResponse.json({ families }); - } catch (error) { - console.error('Error listing family notes:', error); - return NextResponse.json( - { error: 'Failed to list family notes' }, - { status: 500 } - ); - } -} diff --git a/app/family-notes/[family]/page.tsx b/app/family-notes/[family]/page.tsx index 92710f9..3d7366a 100644 --- a/app/family-notes/[family]/page.tsx +++ b/app/family-notes/[family]/page.tsx @@ -1,10 +1,15 @@ -import { Metadata } from 'next'; +import type { Metadata } from 'next'; import { FamilyNoteViewerClient } from '@/components/FamilyNoteViewerClient'; export const metadata: Metadata = { title: 'Family Research Notes', }; -export default function FamilyNotePage({ params }: { params: { family: string } }) { - return ; +export default async function FamilyNotePage({ + params, +}: { + params: Promise<{ family: string }>; +}) { + const { family } = await params; + return ; } diff --git a/app/family-notes/page.tsx b/app/family-notes/page.tsx index f33f402..009e0dc 100644 --- a/app/family-notes/page.tsx +++ b/app/family-notes/page.tsx @@ -1,4 +1,4 @@ -import { Metadata } from 'next'; +import type { Metadata } from 'next'; import { FamilyNotesClient } from '@/components/FamilyNotesClient'; import { PageHeader } from '@/components/ui'; diff --git a/components/FamilyNoteViewerClient.tsx b/components/FamilyNoteViewerClient.tsx index 51b69f2..b5c98b9 100644 --- a/components/FamilyNoteViewerClient.tsx +++ b/components/FamilyNoteViewerClient.tsx @@ -1,8 +1,8 @@ 'use client'; +import { useMutation, useQuery } from '@apollo/client/react'; import { ArrowLeft, Edit, Save, X } from 'lucide-react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; @@ -14,78 +14,80 @@ import { Skeleton, Textarea, } from '@/components/ui'; +import { + GET_FAMILY_RESEARCH_NOTE, + UPSERT_FAMILY_RESEARCH_NOTE, +} from '@/lib/graphql/queries/family-notes'; interface Props { family: string; } export function FamilyNoteViewerClient({ family }: Props) { - const router = useRouter(); - const [content, setContent] = useState(''); - const [originalContent, setOriginalContent] = useState(''); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [isEditing, setIsEditing] = useState(false); - const [error, setError] = useState(null); + const [content, setContent] = useState(''); + + const { data, loading, error } = useQuery<{ + familyResearchNote: { + id: string; + familyName: string; + content: string; + updatedAt: string; + } | null; + }>(GET_FAMILY_RESEARCH_NOTE, { + variables: { familyName: family.toUpperCase() }, + }); useEffect(() => { - async function loadNote() { - try { - const res = await fetch(`/api/family-notes/${family}`); - if (!res.ok) { - if (res.status === 404) { - throw new Error('Family notes not found'); - } - throw new Error('Failed to load family notes'); - } - const data = await res.json(); - setContent(data.content); - setOriginalContent(data.content); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } + if (data?.familyResearchNote) { + setContent(data.familyResearchNote.content); } - loadNote(); - }, [family]); + }, [data]); + + const [upsertNote, { loading: saving }] = useMutation( + UPSERT_FAMILY_RESEARCH_NOTE, + { + refetchQueries: [ + { + query: GET_FAMILY_RESEARCH_NOTE, + variables: { familyName: family.toUpperCase() }, + }, + ], + }, + ); const handleSave = async () => { - setSaving(true); try { - const res = await fetch(`/api/family-notes/${family}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), + await upsertNote({ + variables: { + input: { + familyName: family.toUpperCase(), + content, + }, + }, }); - - if (!res.ok) throw new Error('Failed to save notes'); - - setOriginalContent(content); setIsEditing(false); toast.success('Notes saved successfully'); - } catch (err: any) { - toast.error('Failed to save notes', { description: err.message }); - } finally { - setSaving(false); + } catch (err) { + const error = err as Error; + toast.error('Failed to save notes', { description: error.message }); } }; const handleCancel = () => { - setContent(originalContent); + if (data?.familyResearchNote) { + setContent(data.familyResearchNote.content); + } setIsEditing(false); }; + const originalContent = data?.familyResearchNote?.content || ''; const hasChanges = content !== originalContent; if (loading) { return ( <> - +
@@ -99,7 +101,7 @@ export function FamilyNoteViewerClient({ family }: Props) { ); } - if (error) { + if (error || !data?.familyResearchNote) { return ( <> -

{error}

+

+ {error?.message || 'Family notes not found'} +

- @@ -152,7 +159,11 @@ export function FamilyNoteViewerClient({ family }: Props) { {/* Action buttons */}
- @@ -207,16 +218,24 @@ export function FamilyNoteViewerClient({ family }: Props) { ( -

{children}

+

+ {children} +

), h2: ({ children }) => ( -

{children}

+

+ {children} +

), h3: ({ children }) => ( -

{children}

+

+ {children} +

), h4: ({ children }) => ( -

{children}

+

+ {children} +

), p: ({ children }) =>

{children}

, ul: ({ children }) => ( diff --git a/components/FamilyNotesClient.tsx b/components/FamilyNotesClient.tsx index b44a82c..f37b7fd 100644 --- a/components/FamilyNotesClient.tsx +++ b/components/FamilyNotesClient.tsx @@ -1,37 +1,29 @@ 'use client'; +import { useQuery } from '@apollo/client/react'; import { BookOpen, ChevronRight } from 'lucide-react'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@/components/ui'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from '@/components/ui'; +import { GET_FAMILY_RESEARCH_NOTES } from '@/lib/graphql/queries/family-notes'; -interface FamilyNote { - family: string; - title: string; - size: number; - lastModified: string; +interface FamilyResearchNote { + id: string; + familyName: string; + content: string; + createdAt: string; + updatedAt: string; } export function FamilyNotesClient() { - const [families, setFamilies] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function loadFamilies() { - try { - const res = await fetch('/api/family-notes'); - if (!res.ok) throw new Error('Failed to load family notes'); - const data = await res.json(); - setFamilies(data.families); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - } - loadFamilies(); - }, []); + const { data, loading, error } = useQuery<{ + familyResearchNotes: FamilyResearchNote[]; + }>(GET_FAMILY_RESEARCH_NOTES); const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -51,8 +43,8 @@ export function FamilyNotesClient() { if (loading) { return (
- {[...Array(6)].map((_, i) => ( - + {Array.from({ length: 6 }, (_, i) => `skeleton-${i}`).map((key) => ( + @@ -70,17 +62,21 @@ export function FamilyNotesClient() { return ( -

Error: {error}

+

Error: {error.message}

); } + const families = data?.familyResearchNotes || []; + if (families.length === 0) { return ( -

No family research notes found.

+

+ No family research notes found. +

); @@ -90,8 +86,8 @@ export function FamilyNotesClient() {
{families.map((family) => ( @@ -99,17 +95,17 @@ export function FamilyNotesClient() {
- {family.family} + {family.familyName.replace(/_/g, ' ')}

- {formatSize(family.size)} + {formatSize(family.content.length)}

- Updated {formatDate(family.lastModified)} + Updated {formatDate(family.updatedAt)}

diff --git a/components/person/PersonFactsCard.tsx b/components/person/PersonFactsCard.tsx index 5e45ad6..b68f0f2 100644 --- a/components/person/PersonFactsCard.tsx +++ b/components/person/PersonFactsCard.tsx @@ -24,7 +24,12 @@ import { TableCell, TableRow, } from '@/components/ui'; -import { ADD_FACT, DELETE_FACT, GET_PERSON, UPDATE_FACT } from '@/lib/graphql/queries'; +import { + ADD_FACT, + DELETE_FACT, + GET_PERSON, + UPDATE_FACT, +} from '@/lib/graphql/queries'; import type { Fact } from '@/lib/types'; const FACT_TYPES = [ @@ -46,7 +51,9 @@ const FACT_TYPES = [ ]; /** Badge color by fact type */ -function getFactBadgeVariant(type: string | null): 'default' | 'secondary' | 'outline' { +function getFactBadgeVariant( + type: string | null, +): 'default' | 'secondary' | 'outline' { switch (type) { case 'Biography': return 'default'; @@ -72,18 +79,27 @@ export default function PersonFactsCard({ const t = useTranslations('Person'); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); - const [formData, setFormData] = useState({ fact_type: 'Note', fact_value: '' }); + const [formData, setFormData] = useState({ + fact_type: 'Note', + fact_value: '', + }); const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; const [addFact, { loading: adding }] = useMutation(ADD_FACT, { refetchQueries, - onCompleted: () => { toast.success('Fact added'); resetForm(); }, + onCompleted: () => { + toast.success('Fact added'); + resetForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); const [updateFact, { loading: updating }] = useMutation(UPDATE_FACT, { refetchQueries, - onCompleted: () => { toast.success('Fact updated'); resetForm(); }, + onCompleted: () => { + toast.success('Fact updated'); + resetForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); const [deleteFact] = useMutation(DELETE_FACT, { @@ -137,7 +153,9 @@ export default function PersonFactsCard({
- {t('facts') || 'Facts & Details'} + + {t('facts') || 'Facts & Details'} + {canEdit && !showForm && ( -
@@ -209,7 +241,11 @@ export default function PersonFactsCard({ {canEdit && (
- - {isMe ? t('unlinkMe') : t('markAsMe')} + + {isMe ? t('unlinkMe') : t('markAsMe')} + )} {canEdit && ( @@ -177,16 +200,30 @@ export default function PersonHeroCard({ variant="ghost" size="icon-sm" onClick={onToggleNotable} - className={person.is_notable ? 'text-amber-500' : 'text-muted-foreground'} + className={ + person.is_notable + ? 'text-amber-500' + : 'text-muted-foreground' + } > - + - {person.is_notable ? t('removeNotableStatus') : t('markAsNotableTitle')} + + {person.is_notable + ? t('removeNotableStatus') + : t('markAsNotableTitle')} + - @@ -238,7 +275,11 @@ export default function PersonHeroCard({ @@ -246,7 +287,11 @@ export default function PersonHeroCard({ @@ -264,7 +309,10 @@ export default function PersonHeroCard({ @@ -287,7 +335,9 @@ export default function PersonHeroCard({ const type = n.name_type ? ` (${n.name_type})` : ''; return ( - {name}{type}{i < alternateNames.length - 1 ? ', ' : ''} + {name} + {type} + {i < alternateNames.length - 1 ? ', ' : ''} ); })} @@ -341,7 +391,9 @@ function VitalStat({
{icon} - {label} + + {label} +

Unknown

@@ -349,16 +401,20 @@ function VitalStat({ } return ( -
+
{icon} - {label} + + {label} +
- {date && ( -

{date}

- )} + {date &&

{date}

} {place && ( -

{place}

+

+ {place} +

)}
); diff --git a/components/person/UnifiedLifeEventsCard.tsx b/components/person/UnifiedLifeEventsCard.tsx index 5523ef8..921bd18 100644 --- a/components/person/UnifiedLifeEventsCard.tsx +++ b/components/person/UnifiedLifeEventsCard.tsx @@ -9,10 +9,10 @@ import { Badge, Button, Card, + CardAction, CardContent, CardHeader, CardTitle, - CardAction, Input, Label, Select, @@ -37,31 +37,74 @@ const HERO_EVENTS = ['Birth', 'Death', 'Burial']; /** All available event types */ const ALL_EVENT_TYPES = [ - 'Birth', 'Death', 'Burial', 'Christening', 'Baptism', - 'Immigration', 'Naturalization', 'Residence', 'Occupation', - 'Education', 'Military', 'Census', 'Marriage', 'Divorce', - 'Emigration', 'Confirmation', 'Funeral', 'Cremation', - 'Obituary', 'Will', 'Draft Registration', 'Other', + 'Birth', + 'Death', + 'Burial', + 'Christening', + 'Baptism', + 'Immigration', + 'Naturalization', + 'Residence', + 'Occupation', + 'Education', + 'Military', + 'Census', + 'Marriage', + 'Divorce', + 'Emigration', + 'Confirmation', + 'Funeral', + 'Cremation', + 'Obituary', + 'Will', + 'Draft Registration', + 'Other', ]; /** Icons for each event type */ const EVENT_ICONS: Record = { - Birth: '๐ŸŽ‚', Death: 'โœ๏ธ', Burial: '๐Ÿชฆ', Christening: 'โ›ช', - Baptism: 'โ›ช', Immigration: '๐Ÿ›ณ๏ธ', Emigration: 'โœˆ๏ธ', - Naturalization: '๐Ÿ“œ', Residence: '๐Ÿ ', Occupation: '๐Ÿ’ผ', - Education: '๐ŸŽ“', Military: '๐ŸŽ–๏ธ', Census: '๐Ÿ“Š', Marriage: '๐Ÿ’’', - Divorce: '๐Ÿ’”', Confirmation: 'โœž', Funeral: 'โšฑ๏ธ', Cremation: '๐Ÿ”ฅ', - Obituary: '๐Ÿ“ฐ', Will: '๐Ÿ“‹', 'Draft Registration': '๐Ÿ“Œ', Other: '๐Ÿ“Œ', + Birth: '๐ŸŽ‚', + Death: 'โœ๏ธ', + Burial: '๐Ÿชฆ', + Christening: 'โ›ช', + Baptism: 'โ›ช', + Immigration: '๐Ÿ›ณ๏ธ', + Emigration: 'โœˆ๏ธ', + Naturalization: '๐Ÿ“œ', + Residence: '๐Ÿ ', + Occupation: '๐Ÿ’ผ', + Education: '๐ŸŽ“', + Military: '๐ŸŽ–๏ธ', + Census: '๐Ÿ“Š', + Marriage: '๐Ÿ’’', + Divorce: '๐Ÿ’”', + Confirmation: 'โœž', + Funeral: 'โšฑ๏ธ', + Cremation: '๐Ÿ”ฅ', + Obituary: '๐Ÿ“ฐ', + Will: '๐Ÿ“‹', + 'Draft Registration': '๐Ÿ“Œ', + Other: '๐Ÿ“Œ', }; /** Event category for grouping */ function getEventCategory(type: string): string { - if (['Immigration', 'Emigration', 'Naturalization'].includes(type)) return 'Migration'; - if (['Military', 'Military Enlistment', 'Military Discharge', 'Draft Registration'].includes(type)) return 'Military'; + if (['Immigration', 'Emigration', 'Naturalization'].includes(type)) + return 'Migration'; + if ( + [ + 'Military', + 'Military Enlistment', + 'Military Discharge', + 'Draft Registration', + ].includes(type) + ) + return 'Military'; if (['Residence', 'Census'].includes(type)) return 'Residence'; if (['Marriage', 'Divorce'].includes(type)) return 'Family'; if (['Occupation', 'Education'].includes(type)) return 'Career'; - if (['Christening', 'Baptism', 'Confirmation'].includes(type)) return 'Religious'; + if (['Christening', 'Baptism', 'Confirmation'].includes(type)) + return 'Religious'; return 'Other'; } @@ -78,7 +121,14 @@ const CATEGORY_COLORS: Record = { /** Name types for alternate names */ const NAME_TYPES = [ - 'maiden', 'married', 'nickname', 'birth', 'adopted', 'alias', 'religious', 'other', + 'maiden', + 'married', + 'nickname', + 'birth', + 'adopted', + 'alias', + 'religious', + 'other', ]; interface Props { @@ -93,8 +143,22 @@ interface Props { function sortEvents(events: LifeEvent[]): LifeEvent[] { return [...events].sort((a, b) => { - const aVal = a.event_type === 'Birth' ? -Infinity : a.event_type === 'Death' ? Infinity - 1 : a.event_type === 'Burial' ? Infinity : (a.event_year || 0); - const bVal = b.event_type === 'Birth' ? -Infinity : b.event_type === 'Death' ? Infinity - 1 : b.event_type === 'Burial' ? Infinity : (b.event_year || 0); + const aVal = + a.event_type === 'Birth' + ? -Infinity + : a.event_type === 'Death' + ? Infinity - 1 + : a.event_type === 'Burial' + ? Infinity + : a.event_year || 0; + const bVal = + b.event_type === 'Birth' + ? -Infinity + : b.event_type === 'Death' + ? Infinity - 1 + : b.event_type === 'Burial' + ? Infinity + : b.event_year || 0; return aVal - bVal; }); } @@ -111,23 +175,37 @@ export default function UnifiedLifeEventsCard({ const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [formData, setFormData] = useState({ - event_type: 'Residence', event_date: '', event_year: '', event_place: '', event_value: '', + event_type: 'Residence', + event_date: '', + event_year: '', + event_place: '', + event_value: '', }); // Alternate name state const [showAliasForm, setShowAliasForm] = useState(false); const [editingAliasId, setEditingAliasId] = useState(null); - const [aliasData, setAliasData] = useState({ name_type: 'nickname', name_given: '', name_surname: '' }); + const [aliasData, setAliasData] = useState({ + name_type: 'nickname', + name_given: '', + name_surname: '', + }); const refetchQueries = [{ query: GET_PERSON, variables: { id: personId } }]; const [addEvent, { loading: adding }] = useMutation(ADD_LIFE_EVENT, { refetchQueries, - onCompleted: () => { toast.success(t('eventAdded')); resetForm(); }, + onCompleted: () => { + toast.success(t('eventAdded')); + resetForm(); + }, onError: (err) => toast.error(t('error'), { description: err.message }), }); const [updateEvent, { loading: updating }] = useMutation(UPDATE_LIFE_EVENT, { refetchQueries, - onCompleted: () => { toast.success(t('eventUpdated')); resetForm(); }, + onCompleted: () => { + toast.success(t('eventUpdated')); + resetForm(); + }, onError: (err) => toast.error(t('error'), { description: err.message }), }); const [deleteEvent] = useMutation(DELETE_LIFE_EVENT, { @@ -138,13 +216,24 @@ export default function UnifiedLifeEventsCard({ // Alias mutations const [addAlias, { loading: addingAlias }] = useMutation(ADD_ALTERNATE_NAME, { - refetchQueries, onCompleted: () => { toast.success('Alias added'); resetAliasForm(); }, - onError: (err) => toast.error('Error', { description: err.message }), - }); - const [updateAlias, { loading: updatingAlias }] = useMutation(UPDATE_ALTERNATE_NAME, { - refetchQueries, onCompleted: () => { toast.success('Alias updated'); resetAliasForm(); }, + refetchQueries, + onCompleted: () => { + toast.success('Alias added'); + resetAliasForm(); + }, onError: (err) => toast.error('Error', { description: err.message }), }); + const [updateAlias, { loading: updatingAlias }] = useMutation( + UPDATE_ALTERNATE_NAME, + { + refetchQueries, + onCompleted: () => { + toast.success('Alias updated'); + resetAliasForm(); + }, + onError: (err) => toast.error('Error', { description: err.message }), + }, + ); const [deleteAlias] = useMutation(DELETE_ALTERNATE_NAME, { refetchQueries, onCompleted: () => toast.success('Alias deleted'), @@ -153,13 +242,21 @@ export default function UnifiedLifeEventsCard({ // Filter out hero events (Birth, Death, Burial) and Marriage (shown in hero stats) const timelineEvents = sortEvents( - lifeEvents.filter((e) => !HERO_EVENTS.includes(e.event_type) && e.event_type !== 'Marriage'), + lifeEvents.filter( + (e) => !HERO_EVENTS.includes(e.event_type) && e.event_type !== 'Marriage', + ), ).filter((e) => !(e.event_type === 'Death' && living)); const getIcon = (type: string) => EVENT_ICONS[type] || EVENT_ICONS.Other; const resetForm = () => { - setFormData({ event_type: 'Residence', event_date: '', event_year: '', event_place: '', event_value: '' }); + setFormData({ + event_type: 'Residence', + event_date: '', + event_year: '', + event_place: '', + event_value: '', + }); setEditingId(null); setShowForm(false); }; @@ -172,8 +269,10 @@ export default function UnifiedLifeEventsCard({ const handleEdit = (evt: LifeEvent) => { setFormData({ - event_type: evt.event_type, event_date: evt.event_date || '', - event_year: evt.event_year?.toString() || '', event_place: evt.event_place || '', + event_type: evt.event_type, + event_date: evt.event_date || '', + event_year: evt.event_year?.toString() || '', + event_place: evt.event_place || '', event_value: evt.event_value || '', }); setEditingId(evt.id.toString()); @@ -183,9 +282,13 @@ export default function UnifiedLifeEventsCard({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const input = { - event_type: formData.event_type, event_date: formData.event_date || null, - event_year: formData.event_year ? parseInt(formData.event_year, 10) : null, - event_place: formData.event_place || null, event_value: formData.event_value || null, + event_type: formData.event_type, + event_date: formData.event_date || null, + event_year: formData.event_year + ? parseInt(formData.event_year, 10) + : null, + event_place: formData.event_place || null, + event_value: formData.event_value || null, }; if (editingId) { await updateEvent({ variables: { id: editingId, input } }); @@ -201,14 +304,22 @@ export default function UnifiedLifeEventsCard({ }; const handleEditAlias = (alias: AlternateName) => { - setAliasData({ name_type: alias.name_type || 'nickname', name_given: alias.name_given || '', name_surname: alias.name_surname || '' }); + setAliasData({ + name_type: alias.name_type || 'nickname', + name_given: alias.name_given || '', + name_surname: alias.name_surname || '', + }); setEditingAliasId(alias.id.toString()); setShowAliasForm(true); }; const handleAliasSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const input = { name_type: aliasData.name_type, name_given: aliasData.name_given || null, name_surname: aliasData.name_surname || null }; + const input = { + name_type: aliasData.name_type, + name_given: aliasData.name_given || null, + name_surname: aliasData.name_surname || null, + }; if (editingAliasId) { await updateAlias({ variables: { id: editingAliasId, input } }); } else { @@ -223,7 +334,8 @@ export default function UnifiedLifeEventsCard({ }; // Don't render at all if no events and no aliases and not editable - if (timelineEvents.length === 0 && alternateNames.length === 0 && !canEdit) return null; + if (timelineEvents.length === 0 && alternateNames.length === 0 && !canEdit) + return null; return ( @@ -245,39 +357,83 @@ export default function UnifiedLifeEventsCard({ {/* Add/Edit Event Form */} {showForm && ( - +
- + setFormData((p) => ({ ...p, event_type: v })) + } + > + + + {ALL_EVENT_TYPES.map((type) => ( - {getIcon(type)} {type} + + {getIcon(type)} {type} + ))}
- setFormData((p) => ({ ...p, event_year: e.target.value }))} placeholder="1920" /> + + setFormData((p) => ({ ...p, event_year: e.target.value })) + } + placeholder="1920" + />
- setFormData((p) => ({ ...p, event_date: e.target.value }))} placeholder={t('datePlaceholder')} /> + + setFormData((p) => ({ ...p, event_date: e.target.value })) + } + placeholder={t('datePlaceholder')} + />
- setFormData((p) => ({ ...p, event_place: e.target.value }))} placeholder={t('placePlaceholder')} /> + + setFormData((p) => ({ ...p, event_place: e.target.value })) + } + placeholder={t('placePlaceholder')} + />
- setFormData((p) => ({ ...p, event_value: e.target.value }))} placeholder={t('detailsPlaceholder')} /> + + setFormData((p) => ({ ...p, event_value: e.target.value })) + } + placeholder={t('detailsPlaceholder')} + />
- - + +
)} @@ -287,18 +443,24 @@ export default function UnifiedLifeEventsCard({
{timelineEvents.map((evt) => { const category = getEventCategory(evt.event_type); - const colorClass = CATEGORY_COLORS[category] || CATEGORY_COLORS.Other; + const colorClass = + CATEGORY_COLORS[category] || CATEGORY_COLORS.Other; return (
{/* Timeline dot */} -
+
{/* Event type + date on one line */}
- + {getIcon(evt.event_type)} {evt.event_type} {(evt.event_date || evt.event_year) && ( @@ -311,8 +473,12 @@ export default function UnifiedLifeEventsCard({ {(evt.event_place || evt.event_value) && (
{evt.event_place && {evt.event_place}} - {evt.event_place && evt.event_value && ยท } - {evt.event_value && {evt.event_value}} + {evt.event_place && evt.event_value && ( + ยท + )} + {evt.event_value && ( + {evt.event_value} + )}
)}
@@ -320,10 +486,19 @@ export default function UnifiedLifeEventsCard({ {/* Edit/delete buttons */} {canEdit && (
- -
@@ -334,7 +509,9 @@ export default function UnifiedLifeEventsCard({ })}
) : ( - !canEdit &&

{t('noEvents')}

+ !canEdit && ( +

{t('noEvents')}

+ ) )} {/* Also Known As section */} @@ -343,15 +520,32 @@ export default function UnifiedLifeEventsCard({ Also known as:{' '} {alternateNames.length > 0 ? ( alternateNames.map((n, i) => { - const name = n.name_full || [n.name_given, n.name_surname].filter(Boolean).join(' '); + const name = + n.name_full || + [n.name_given, n.name_surname].filter(Boolean).join(' '); const type = n.name_type ? ` (${n.name_type})` : ''; return ( - {name}{type} + + {name} + {type} + {canEdit && ( - - + + )} {i < alternateNames.length - 1 && ', '} @@ -362,34 +556,86 @@ export default function UnifiedLifeEventsCard({ None )} {canEdit && !showAliasForm && ( - )} - {/* Add/Edit Alias Form */} {showAliasForm && ( -
+
- + setAliasData((p) => ({ ...p, name_type: v })) + } + > + + + + + {NAME_TYPES.map((type) => ( + + {type} + + ))} +
- setAliasData((p) => ({ ...p, name_given: e.target.value }))} placeholder="First" /> + + setAliasData((p) => ({ + ...p, + name_given: e.target.value, + })) + } + placeholder="First" + />
- setAliasData((p) => ({ ...p, name_surname: e.target.value }))} placeholder="Last" /> + + setAliasData((p) => ({ + ...p, + name_surname: e.target.value, + })) + } + placeholder="Last" + />
- - + +
)} diff --git a/lib/graphql/client.ts b/lib/graphql/client.ts deleted file mode 100644 index 972edef..0000000 --- a/lib/graphql/client.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Server-side GraphQL Client - * - * Provides a simple fetch-based GraphQL client for use in API routes and server components. - * Uses the local GraphQL endpoint at /api/graphql. - */ - -interface GraphQLResponse { - data?: T; - errors?: Array<{ message: string; extensions?: unknown }>; -} - -export class GraphQLClient { - private endpoint: string; - private defaultHeaders: Record; - - constructor(endpoint: string, defaultHeaders: Record = {}) { - this.endpoint = endpoint; - this.defaultHeaders = defaultHeaders; - } - - async request( - query: string, - variables?: Record, - headers?: Record, - ): Promise> { - const allHeaders = { - 'Content-Type': 'application/json', - ...this.defaultHeaders, - ...headers, - }; - - const response = await fetch(this.endpoint, { - method: 'POST', - headers: allHeaders, - body: JSON.stringify({ - query, - variables, - }), - }); - - if (!response.ok) { - throw new Error(`GraphQL request failed: ${response.statusText}`); - } - - return response.json(); - } -} - -/** - * Get a server-side GraphQL client instance - * - * @param headers - Optional headers to include with requests (e.g., x-user-id) - * @returns GraphQLClient instance configured for the local GraphQL endpoint - */ -export function getGraphQLClient( - headers: Record = {}, -): GraphQLClient { - // Determine the correct endpoint based on environment - let endpoint: string; - - if (typeof window === 'undefined') { - // Server-side: use full URL - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - endpoint = `${baseUrl}/api/graphql`; - } else { - // Client-side: use relative URL - endpoint = '/api/graphql'; - } - - return new GraphQLClient(endpoint, headers); -} diff --git a/lib/graphql/queries/family-notes.ts b/lib/graphql/queries/family-notes.ts new file mode 100644 index 0000000..2e09bd9 --- /dev/null +++ b/lib/graphql/queries/family-notes.ts @@ -0,0 +1,36 @@ +import { gql } from '@apollo/client'; + +export const GET_FAMILY_RESEARCH_NOTES = gql` + query FamilyResearchNotes { + familyResearchNotes { + id + familyName + content + createdAt + updatedAt + } + } +`; + +export const GET_FAMILY_RESEARCH_NOTE = gql` + query FamilyResearchNote($familyName: String!) { + familyResearchNote(familyName: $familyName) { + id + familyName + content + createdAt + updatedAt + } + } +`; + +export const UPSERT_FAMILY_RESEARCH_NOTE = gql` + mutation UpsertFamilyResearchNote($input: FamilyResearchNoteInput!) { + upsertFamilyResearchNote(input: $input) { + id + familyName + content + updatedAt + } + } +`; diff --git a/lib/graphql/queries/index.ts b/lib/graphql/queries/index.ts index a12ef74..f2cf132 100644 --- a/lib/graphql/queries/index.ts +++ b/lib/graphql/queries/index.ts @@ -81,6 +81,12 @@ export { REMOVE_SPOUSE, UPDATE_FAMILY, } from './family'; +// Family research notes queries and mutations +export { + GET_FAMILY_RESEARCH_NOTE, + GET_FAMILY_RESEARCH_NOTES, + UPSERT_FAMILY_RESEARCH_NOTE, +} from './family-notes'; // Fragments export { MEDIA_FIELDS, diff --git a/lib/graphql/resolvers/family-research-notes.ts b/lib/graphql/resolvers/family-research-notes.ts index f57a959..2edbde6 100644 --- a/lib/graphql/resolvers/family-research-notes.ts +++ b/lib/graphql/resolvers/family-research-notes.ts @@ -36,7 +36,7 @@ export const familyResearchNotesResolvers = { */ familyResearchNotes: async (_: unknown, __: unknown, { pool }: Context) => { const { rows } = await pool.query( - `SELECT * FROM family_research_notes ORDER BY family_name` + `SELECT * FROM family_research_notes ORDER BY family_name`, ); return rows.map((row) => ({ id: row.id, @@ -54,11 +54,11 @@ export const familyResearchNotesResolvers = { familyResearchNote: async ( _: unknown, { familyName }: { familyName: string }, - { pool }: Context + { pool }: Context, ) => { const { rows } = await pool.query( `SELECT * FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); if (rows.length === 0) { @@ -84,14 +84,14 @@ export const familyResearchNotesResolvers = { upsertFamilyResearchNote: async ( _: unknown, { input }: { input: FamilyResearchNoteInput }, - { pool, userId }: Context + { pool, userId }: Context, ) => { const { familyName, content } = input; // Check if exists const { rows: existing } = await pool.query( `SELECT id FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); let row: FamilyResearchNote; @@ -103,7 +103,7 @@ export const familyResearchNotesResolvers = { SET content = $1, updated_by = $2 WHERE family_name = $3 RETURNING *`, - [content, userId || null, familyName] + [content, userId || null, familyName], ); row = rows[0]; } else { @@ -112,7 +112,7 @@ export const familyResearchNotesResolvers = { `INSERT INTO family_research_notes (id, family_name, content, updated_by, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING *`, - [generateId(), familyName, content, userId || null] + [generateId(), familyName, content, userId || null], ); row = rows[0]; } @@ -133,11 +133,11 @@ export const familyResearchNotesResolvers = { deleteFamilyResearchNote: async ( _: unknown, { familyName }: { familyName: string }, - { pool }: Context + { pool }: Context, ) => { const { rowCount } = await pool.query( `DELETE FROM family_research_notes WHERE family_name = $1`, - [familyName] + [familyName], ); return (rowCount ?? 0) > 0; }, @@ -148,15 +148,15 @@ export const familyResearchNotesResolvers = { * Resolve updatedBy user reference */ updatedBy: async ( - parent: any, + parent: { updatedBy?: { id: string } | null }, _: unknown, - { pool }: Context + { pool }: Context, ) => { if (!parent.updatedBy?.id) return null; const { rows } = await pool.query( `SELECT id, name, email, role FROM users WHERE id = $1`, - [parent.updatedBy.id] + [parent.updatedBy.id], ); return rows[0] || null; diff --git a/lib/graphql/resolvers/index.ts b/lib/graphql/resolvers/index.ts index 7893b4b..ca983e9 100644 --- a/lib/graphql/resolvers/index.ts +++ b/lib/graphql/resolvers/index.ts @@ -14,8 +14,8 @@ import { cyclesResolvers } from './cycles'; import { dashboardResolvers } from './dashboard'; import { duplicatesResolvers } from './duplicates'; import { factResolvers } from './fact'; -import { familyResearchNotesResolvers } from './family-research-notes'; import { familyResolvers } from './family'; +import { familyResearchNotesResolvers } from './family-research-notes'; import { gedcomResolvers } from './gedcom'; import { life_eventResolvers } from './life_event'; import { mapResolvers } from './map'; diff --git a/lib/graphql/resolvers/person/field-resolvers.ts b/lib/graphql/resolvers/person/field-resolvers.ts index 6ca541b..a05df7d 100644 --- a/lib/graphql/resolvers/person/field-resolvers.ts +++ b/lib/graphql/resolvers/person/field-resolvers.ts @@ -25,7 +25,6 @@ export const personFieldResolvers = { : []; }, - father: async (person: { id: string }, _: unknown, ctx: Context) => { const families = await ctx.loaders.familiesAsChildLoader.load(person.id); if (families.length === 0) return null; diff --git a/lib/graphql/schema/index.ts b/lib/graphql/schema/index.ts index 9aee667..79d951f 100644 --- a/lib/graphql/schema/index.ts +++ b/lib/graphql/schema/index.ts @@ -9,8 +9,8 @@ import { adminTypes } from './types/admin'; import { commentTypes } from './types/comment'; import { commonTypes } from './types/common'; import { crestTypes } from './types/crest'; -import { familyResearchNotesTypeDefs } from './types/family-research-notes'; import { familyTypes } from './types/family'; +import { familyResearchNotesTypeDefs } from './types/family-research-notes'; import { locationTypes } from './types/location'; import { mediaTypes } from './types/media'; import { personTypes } from './types/person'; diff --git a/lib/graphql/schema/types/index.ts b/lib/graphql/schema/types/index.ts index 74a9351..bdab1d0 100644 --- a/lib/graphql/schema/types/index.ts +++ b/lib/graphql/schema/types/index.ts @@ -10,8 +10,8 @@ export { adminTypes } from './admin'; export { commentTypes } from './comment'; export { commonTypes } from './common'; export { crestTypes } from './crest'; -export { familyResearchNotesTypeDefs } from './family-research-notes'; export { familyTypes } from './family'; +export { familyResearchNotesTypeDefs } from './family-research-notes'; export { locationTypes } from './location'; export { mediaTypes } from './media'; export { personTypes } from './person'; diff --git a/scripts/migrate-family-notes-api.ts b/scripts/migrate-family-notes-api.ts new file mode 100644 index 0000000..e57090c --- /dev/null +++ b/scripts/migrate-family-notes-api.ts @@ -0,0 +1,103 @@ +/** + * API-based migration script for family research notes + * Uses GraphQL API instead of direct database connection + * + * Usage: GRAPHQL_ENDPOINT=https://family.milanese.life/api/graphql API_KEY=your_key npx tsx scripts/migrate-family-notes-api.ts + */ + +import { promises as fs } from 'fs'; +import path from 'path'; + +const NOTES_DIR = '/home/petermilanese/workspace/projects/genealogy/research/families'; +const GRAPHQL_ENDPOINT = process.env.GRAPHQL_ENDPOINT || 'https://family.milanese.life/api/graphql'; +const API_KEY = process.env.API_KEY; + +if (!API_KEY) { + console.error('ERROR: API_KEY environment variable not set'); + console.error('Usage: API_KEY=your_key npx tsx scripts/migrate-family-notes-api.ts'); + process.exit(1); +} + +const UPSERT_MUTATION = ` + mutation UpsertFamilyResearchNote($input: FamilyResearchNoteInput!) { + upsertFamilyResearchNote(input: $input) { + id + familyName + } + } +`; + +async function uploadNote(familyName: string, content: string): Promise { + try { + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': API_KEY!, + }, + body: JSON.stringify({ + query: UPSERT_MUTATION, + variables: { + input: { + familyName, + content, + }, + }, + }), + }); + + const result = await response.json(); + + if (result.errors) { + console.error(`โŒ ${familyName} - GraphQL errors:`, result.errors[0].message); + return false; + } + + console.log(`โœ… ${familyName} - uploaded (${content.length} bytes)`); + return true; + } catch (error) { + console.error(`โŒ ${familyName} - error:`, error instanceof Error ? error.message : error); + return false; + } +} + +async function main() { + console.log('Starting family research notes migration via API...\n'); + console.log(`GraphQL Endpoint: ${GRAPHQL_ENDPOINT}\n`); + + // Read all markdown files + const files = await fs.readdir(NOTES_DIR); + const mdFiles = files.filter((f) => f.endsWith('.md')); + + console.log(`Found ${mdFiles.length} markdown files to migrate\n`); + + let successCount = 0; + let errorCount = 0; + + for (const filename of mdFiles) { + const familyName = filename.replace('.md', ''); + const filepath = path.join(NOTES_DIR, filename); + const content = await fs.readFile(filepath, 'utf-8'); + + const success = await uploadNote(familyName, content); + if (success) { + successCount++; + } else { + errorCount++; + } + } + + console.log(`\nโœจ Migration complete!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + console.log(` Total: ${mdFiles.length}`); + + if (errorCount > 0) { + process.exit(1); + } +} + +main().catch((error) => { + console.error('Migration failed:', error); + process.exit(1); +});