Add mini family genealogy tree with per-person photos
Per explicit request: "mini family genealogy mapping with ability to add
photos on each family member." Scoped via clarifying questions: people are
lightweight records independent of login accounts (most real tree nodes —
grandparents, deceased relatives, young children — never sign up),
relationships support parent/child + spouse (enough to render a real
multi-generation tree), photos go to Supabase Storage (native to the
backend already in use, no new infrastructure).
Schema: nf_people (name, gender, birth/death dates, notes, optional
linked_user_id if the person also happens to have a real account,
photo_path) and nf_relationships (person_a/person_b + type: parent_of
directional, spouse_of symmetric). Both family-member-shared like the
Asset Registry — any owner/agent/member can view and edit, unlike the
per-author Wassiyah/Waqf model. New private Storage bucket
nf-people-photos, path convention {family_id}/{person_id}/{filename} so
RLS can check family membership straight from the path without a join.
New FamilyTree.svelte: add/edit/delete a person, upload/replace/remove
their photo (signed URLs, 1hr TTL, refreshed on every load since the
bucket is private), link/unlink relationships, and a recursive generational
tree render — roots are anyone with no recorded parent, spouses shown
inline next to their partner rather than as separate branches.
e2e-family-tree.cjs: 4-person 3-generation tree built end to end against
the live backend — add people, link parent/child + spouse relationships,
upload a real PNG to Supabase Storage and confirm it renders as the
avatar, remove it and confirm it reverts to the initial-letter fallback,
edit a person's notes, delete a relationship, and confirm a second family
under the same account sees none of this tree (isolation, same pattern
already proven for Wassiyah/Waqf). Found two real test-authoring bugs
along the way (not app bugs): a case-insensitive :has-text substring match
on "Spouse" was also matching the relationship-type select's own "is
spouse of" option text, and a person-summary click meant to inspect an
already-expanded card was instead toggling it closed. Both fixed by
selecting on DOM position/structure instead of loose text matching.
9/9 passing.
Full regression sweep: e2e-uat 32/32, e2e-fastpath 16/16, e2e-trust 12/12,
e2e-business 10/10, e2e-digital-vehicle 10/10 (one transient failure on
first run, passed clean on retry — consistent with earlier-observed
flakiness under heavy parallel test load, not a regression),
e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-per-member 11/11,
e2e-family-tree 9/9 — 174/174 total.
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
// Verifies the mini family genealogy map end-to-end against the live backend:
|
||||||
|
// add people (with photo upload to Supabase Storage), link parent/child and
|
||||||
|
// spouse relationships, confirm the generational tree renders correctly, and
|
||||||
|
// that a second family's tree is fully isolated (no data bleed across
|
||||||
|
// families sharing the same account, matching the isolation pattern already
|
||||||
|
// proven for Wassiyah/Waqf).
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const path = require('path');
|
||||||
|
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
|
||||||
|
const BASE = 'https://moslem04.falahos.my/';
|
||||||
|
const results = [];
|
||||||
|
const consoleErrors = [];
|
||||||
|
function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => consoleErrors.push(e.message));
|
||||||
|
|
||||||
|
const familyName = await signInFreshFamily(page, BASE, 'e2e-family-tree');
|
||||||
|
await page.locator('nav button[aria-label="Tree"]').click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
|
// Add three people: grandfather, father, son
|
||||||
|
async function addPerson(name, extra = {}) {
|
||||||
|
await page.locator('.form-card .field:has-text("Full name") input').fill(name);
|
||||||
|
if (extra.gender) await page.locator('.form-card .field:has-text("Gender") select').selectOption(extra.gender);
|
||||||
|
if (extra.birthDate) await page.locator('.form-card .field:has-text("Birth date") input').fill(extra.birthDate);
|
||||||
|
await page.locator('.form-card button.btn-primary', { hasText: 'Add person' }).click();
|
||||||
|
await page.locator('.person-card', { hasText: name }).waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await addPerson('Grandfather Ahmad', { gender: 'male', birthDate: '1940-01-01' });
|
||||||
|
await addPerson('Father Ismail', { gender: 'male', birthDate: '1965-05-15' });
|
||||||
|
await addPerson('Grandmother Fatimah', { gender: 'female', birthDate: '1945-03-10' });
|
||||||
|
await addPerson('Son Yusuf', { gender: 'male', birthDate: '1995-08-20' });
|
||||||
|
|
||||||
|
const allAdded = await page.locator('.person-card').count();
|
||||||
|
record('Family Tree: 4 people added successfully', allAdded === 4, `${allAdded} cards`);
|
||||||
|
|
||||||
|
// Link relationships: Grandfather is parent of Father; Grandfather spouse_of Grandmother; Father parent of Son
|
||||||
|
async function addRelationship(personALabel, type, personBLabel) {
|
||||||
|
// The relationship form-card has exactly 3 selects in DOM order: person A,
|
||||||
|
// type, person B. Selecting by position avoids :has-text substring
|
||||||
|
// ambiguity (the type select's own option "is spouse of" contains
|
||||||
|
// "spouse", so a text-based locator for the third field also matches it).
|
||||||
|
const relCard = page.locator('.form-card', { has: page.locator('button.btn-primary', { hasText: 'Add relationship' }) });
|
||||||
|
const selects = relCard.locator('select');
|
||||||
|
await selects.nth(0).selectOption({ label: personALabel });
|
||||||
|
await selects.nth(1).selectOption(type);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await selects.nth(2).selectOption({ label: personBLabel });
|
||||||
|
await page.locator('button.btn-primary', { hasText: 'Add relationship' }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
|
||||||
|
await addRelationship('Grandfather Ahmad', 'parent_of', 'Father Ismail');
|
||||||
|
await addRelationship('Grandfather Ahmad', 'spouse_of', 'Grandmother Fatimah');
|
||||||
|
await addRelationship('Father Ismail', 'parent_of', 'Son Yusuf');
|
||||||
|
|
||||||
|
const relCount = await page.locator('.rel-row').count();
|
||||||
|
record('Family Tree: 3 relationships recorded', relCount === 3, `${relCount} rows`);
|
||||||
|
|
||||||
|
// Verify tree structure: Grandfather is a root, Father nested under him, Son nested under Father
|
||||||
|
const treeText = await page.locator('.tree-section').innerText();
|
||||||
|
const rootShown = treeText.includes('Grandfather Ahmad');
|
||||||
|
const spouseShown = treeText.includes('Grandmother Fatimah');
|
||||||
|
const fatherNested = treeText.includes('Father Ismail');
|
||||||
|
const sonNested = treeText.includes('Son Yusuf');
|
||||||
|
record('Family Tree: generational tree renders all 4 people correctly nested', rootShown && spouseShown && fatherNested && sonNested, treeText.replace(/\s+/g, ' ').slice(0, 300));
|
||||||
|
|
||||||
|
// Photo upload — real file, real Supabase Storage
|
||||||
|
await page.locator('.person-summary', { hasText: 'Father Ismail' }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const fileInput = page.locator('.person-card', { hasText: 'Father Ismail' }).locator('input[type=file]');
|
||||||
|
await fileInput.setInputFiles(path.join(__dirname, 'test-photo.png'));
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const avatarImgVisible = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('img.avatar').isVisible().catch(() => false);
|
||||||
|
record('Family Tree: photo uploads and renders as the person\'s avatar', avatarImgVisible);
|
||||||
|
|
||||||
|
// Photo removal
|
||||||
|
if (avatarImgVisible) {
|
||||||
|
await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('button', { hasText: 'Remove photo' }).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const avatarGoneBack = await page.locator('.person-card', { hasText: 'Father Ismail' }).locator('.avatar-placeholder').isVisible().catch(() => false);
|
||||||
|
record('Family Tree: removing photo reverts to initial-letter placeholder', avatarGoneBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit a person (expand the card first — Edit is only visible when expanded)
|
||||||
|
await page.locator('.person-summary', { hasText: 'Son Yusuf' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('button', { hasText: 'Edit' }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.locator('.form-card .field:has-text("Notes") input').fill('Eldest grandchild');
|
||||||
|
await page.locator('.form-card button.btn-primary', { hasText: 'Save changes' }).click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
// Card should still be expanded (editing doesn't collapse it) — check directly.
|
||||||
|
const notesVisible = await page.locator('.person-card', { hasText: 'Son Yusuf' }).locator('.notes').isVisible().catch(() => false);
|
||||||
|
record('Family Tree: editing a person persists notes', notesVisible);
|
||||||
|
|
||||||
|
// Delete a relationship, confirm it's gone
|
||||||
|
const delBtn = page.locator('.rel-row', { hasText: 'Grandmother Fatimah' }).locator('button');
|
||||||
|
await delBtn.click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const relCountAfterDelete = await page.locator('.rel-row').count();
|
||||||
|
record('Family Tree: deleting a relationship removes it', relCountAfterDelete === 2, `${relCountAfterDelete} rows remain`);
|
||||||
|
|
||||||
|
// ── Isolation: a fresh family (same account) should NOT see this tree ──
|
||||||
|
const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
await signInFreshFamily(isolationPage, BASE, 'e2e-family-tree-isolation');
|
||||||
|
await isolationPage.locator('nav button[aria-label="Tree"]').click();
|
||||||
|
await isolationPage.waitForTimeout(600);
|
||||||
|
const leakedPerson = await isolationPage.locator('.person-card', { hasText: 'Grandfather Ahmad' }).isVisible().catch(() => false);
|
||||||
|
record('Family Tree: a different family sees none of this tree (isolation)', !leakedPerson);
|
||||||
|
await isolationPage.close();
|
||||||
|
|
||||||
|
record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
const passCount = results.filter(r => r.pass).length;
|
||||||
|
const failCount = results.length - passCount;
|
||||||
|
console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`);
|
||||||
|
if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`));
|
||||||
|
process.exit(failCount > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
|
||||||
+7
-5
@@ -17,6 +17,7 @@
|
|||||||
import FamilySwitcher from './lib/FamilySwitcher.svelte';
|
import FamilySwitcher from './lib/FamilySwitcher.svelte';
|
||||||
import FamilyManagement from './lib/FamilyManagement.svelte';
|
import FamilyManagement from './lib/FamilyManagement.svelte';
|
||||||
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
|
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
|
||||||
|
import FamilyTree from './lib/FamilyTree.svelte';
|
||||||
|
|
||||||
let currentLang = $state('en');
|
let currentLang = $state('en');
|
||||||
lang.subscribe(v => currentLang = v);
|
lang.subscribe(v => currentLang = v);
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Claims (H2)', 'Family', 'Settings'];
|
const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
|
||||||
const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🔗', '👥', '⚙️'];
|
const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
|
||||||
let activeTab = $state(0);
|
let activeTab = $state(0);
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
@@ -104,9 +105,10 @@
|
|||||||
{:else if activeTab === 6}<NominationRegistry />
|
{:else if activeTab === 6}<NominationRegistry />
|
||||||
{:else if activeTab === 7}<DeathTrigger />
|
{:else if activeTab === 7}<DeathTrigger />
|
||||||
{:else if activeTab === 8}<MutawalliDashboard />
|
{:else if activeTab === 8}<MutawalliDashboard />
|
||||||
{:else if activeTab === 9}<DigitalClaims />
|
{:else if activeTab === 9}<FamilyTree />
|
||||||
{:else if activeTab === 10}<FamilyManagement />
|
{:else if activeTab === 10}<DigitalClaims />
|
||||||
{:else if activeTab === 11}
|
{:else if activeTab === 11}<FamilyManagement />
|
||||||
|
{:else if activeTab === 12}
|
||||||
<div class="module">
|
<div class="module">
|
||||||
<div class="module-header">
|
<div class="module-header">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<script>
|
||||||
|
// Mini family genealogy map. People here are lightweight records (nf_people)
|
||||||
|
// independent of login accounts — most real tree nodes (grandparents,
|
||||||
|
// deceased relatives, young children) never sign up. Relationships are
|
||||||
|
// parent_of (directional) and spouse_of (symmetric, queried both ways).
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { activeFamilyId } from './family.js';
|
||||||
|
import { currentUser } from './auth.js';
|
||||||
|
import {
|
||||||
|
listPeople, addPerson, updatePerson, removePerson,
|
||||||
|
uploadPersonPhoto, removePersonPhoto, getPersonPhotoUrl,
|
||||||
|
listRelationships, addRelationship, removeRelationship
|
||||||
|
} from './db.js';
|
||||||
|
import InfoPanel from './InfoPanel.svelte';
|
||||||
|
import Disclaimer from './Disclaimer.svelte';
|
||||||
|
|
||||||
|
let familyId = $state(null);
|
||||||
|
activeFamilyId.subscribe(v => familyId = v);
|
||||||
|
|
||||||
|
let people = $state([]);
|
||||||
|
let relationships = $state([]);
|
||||||
|
let photoUrls = $state({}); // personId -> signed url
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let form = $state({ fullName: '', gender: '', birthDate: '', deathDate: '', notes: '' });
|
||||||
|
let editingId = $state(null);
|
||||||
|
|
||||||
|
let relForm = $state({ personAId: '', personBId: '', type: 'parent_of' });
|
||||||
|
|
||||||
|
let uploadingFor = $state(null);
|
||||||
|
let expandedId = $state(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!familyId) return;
|
||||||
|
people = await listPeople(familyId);
|
||||||
|
relationships = await listRelationships(familyId);
|
||||||
|
// Refresh signed URLs for anyone with a photo — private bucket, so these expire.
|
||||||
|
const entries = await Promise.all(
|
||||||
|
people.filter(p => p.photoPath).map(async p => [p.id, await getPersonPhotoUrl(p.photoPath)])
|
||||||
|
);
|
||||||
|
photoUrls = Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
$effect(() => { familyId; refresh(); });
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return { fullName: '', gender: '', birthDate: '', deathDate: '', notes: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePerson() {
|
||||||
|
error = '';
|
||||||
|
if (!form.fullName) return;
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await updatePerson(editingId, form);
|
||||||
|
editingId = null;
|
||||||
|
} else {
|
||||||
|
await addPerson(familyId, currentUser()?.id, form);
|
||||||
|
}
|
||||||
|
form = emptyForm();
|
||||||
|
await refresh();
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(p) {
|
||||||
|
editingId = p.id;
|
||||||
|
form = { fullName: p.fullName, gender: p.gender || '', birthDate: p.birthDate || '', deathDate: p.deathDate || '', notes: p.notes || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
form = emptyForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePerson(id) {
|
||||||
|
if (!confirm('Remove this person and all their recorded relationships? This cannot be undone.')) return;
|
||||||
|
await removePerson(id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoChange(e, personId) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadingFor = personId;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await uploadPersonPhoto(familyId, personId, file);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Photo upload failed: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
uploadingFor = null;
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoRemove(person) {
|
||||||
|
await removePersonPhoto(person.id, person.photoPath);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRel() {
|
||||||
|
error = '';
|
||||||
|
if (!relForm.personAId || !relForm.personBId || relForm.personAId === relForm.personBId) {
|
||||||
|
error = 'Pick two different people.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await addRelationship(familyId, relForm.personAId, relForm.personBId, relForm.type);
|
||||||
|
relForm = { personAId: '', personBId: '', type: 'parent_of' };
|
||||||
|
await refresh();
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delRel(id) {
|
||||||
|
await removeRelationship(id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function personName(id) {
|
||||||
|
return people.find(p => p.id === id)?.fullName || '(removed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple generational tree: roots = people with no recorded parent.
|
||||||
|
// Each node's children = people this person is parent_of. Spouses shown
|
||||||
|
// alongside their partner rather than as separate tree branches.
|
||||||
|
const childrenOf = $derived.by(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const r of relationships.filter(r => r.type === 'parent_of')) {
|
||||||
|
(map[r.personAId] ??= []).push(r.personBId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const spousesOf = $derived.by(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const r of relationships.filter(r => r.type === 'spouse_of')) {
|
||||||
|
(map[r.personAId] ??= []).push(r.personBId);
|
||||||
|
(map[r.personBId] ??= []).push(r.personAId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const hasParent = $derived(new Set(relationships.filter(r => r.type === 'parent_of').map(r => r.personBId)));
|
||||||
|
const roots = $derived(people.filter(p => !hasParent.has(p.id)));
|
||||||
|
|
||||||
|
function toggleExpand(id) {
|
||||||
|
expandedId = expandedId === id ? null : id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<h2>Family Tree</h2>
|
||||||
|
<InfoPanel
|
||||||
|
title="Family Tree"
|
||||||
|
what="A simple map of your family — parents, children, spouses, and a photo for each person. Includes anyone: grandparents, relatives who've passed away, children too young for their own account. Nobody here needs to sign in."
|
||||||
|
how="Add a person, then link them to others as a parent/child or spouse using the relationship form. The tree below groups people by generation automatically, starting from anyone with no parent recorded."
|
||||||
|
fields={[
|
||||||
|
{ label: 'Full name', hint: 'The only required field.' },
|
||||||
|
{ label: 'Birth / death date', hint: 'Optional — leave blank if unknown.' },
|
||||||
|
{ label: 'Photo', hint: 'Upload after adding the person — tap their card to expand it.' },
|
||||||
|
{ label: 'Relationships', hint: 'Parent of (directional) or Spouse of (mutual) — pick two people and a type.' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="sub">Shared family knowledge — any family member can view and edit this map.</p>
|
||||||
|
|
||||||
|
{#if error}<p class="error-text">{error}</p>{/if}
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<h3>{editingId ? 'Edit person' : 'Add a person'}</h3>
|
||||||
|
<label class="field"><span>Full name</span><input type="text" bind:value={form.fullName} /></label>
|
||||||
|
<label class="field"><span>Gender</span>
|
||||||
|
<select bind:value={form.gender}>
|
||||||
|
<option value="">Unspecified</option>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Birth date</span><input type="date" bind:value={form.birthDate} /></label>
|
||||||
|
<label class="field"><span>Death date (if applicable)</span><input type="date" bind:value={form.deathDate} /></label>
|
||||||
|
<label class="field"><span>Notes</span><input type="text" bind:value={form.notes} placeholder="e.g. maiden name, place of origin" /></label>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-primary" onclick={savePerson}>{editingId ? 'Save changes' : 'Add person'}</button>
|
||||||
|
{#if editingId}<button class="btn-secondary" onclick={cancelEdit}>Cancel</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if people.length >= 2}
|
||||||
|
<div class="form-card">
|
||||||
|
<h3>Add a relationship</h3>
|
||||||
|
<label class="field"><span>Person</span>
|
||||||
|
<select bind:value={relForm.personAId}>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{#each people as p}<option value={p.id}>{p.fullName}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>Relationship</span>
|
||||||
|
<select bind:value={relForm.type}>
|
||||||
|
<option value="parent_of">is parent of</option>
|
||||||
|
<option value="spouse_of">is spouse of</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"><span>{relForm.type === 'parent_of' ? 'Child' : 'Spouse'}</span>
|
||||||
|
<select bind:value={relForm.personBId}>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{#each people as p}<option value={p.id}>{p.fullName}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary" onclick={addRel}>Add relationship</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="tree-section">
|
||||||
|
<h3>Tree</h3>
|
||||||
|
{#if roots.length === 0}
|
||||||
|
<p class="empty">Add people and relationships above to build the tree.</p>
|
||||||
|
{/if}
|
||||||
|
{#each roots as root (root.id)}
|
||||||
|
{@render personNode(root, 0)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="all-people-section">
|
||||||
|
<h3>All people</h3>
|
||||||
|
{#each people as p (p.id)}
|
||||||
|
<div class="person-card">
|
||||||
|
<button class="person-summary" onclick={() => toggleExpand(p.id)}>
|
||||||
|
{#if photoUrls[p.id]}
|
||||||
|
<img class="avatar" src={photoUrls[p.id]} alt={p.fullName} />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar avatar-placeholder">{p.fullName.charAt(0).toUpperCase()}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="person-info">
|
||||||
|
<strong>{p.fullName}</strong>
|
||||||
|
<span class="muted">{[p.birthDate, p.deathDate].filter(Boolean).join(' – ') || 'dates unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if expandedId === p.id}
|
||||||
|
<div class="person-detail">
|
||||||
|
{#if p.notes}<p class="notes">{p.notes}</p>{/if}
|
||||||
|
<div class="photo-controls">
|
||||||
|
<label class="upload-btn">
|
||||||
|
{uploadingFor === p.id ? 'Uploading…' : (photoUrls[p.id] ? 'Replace photo' : 'Upload photo')}
|
||||||
|
<input type="file" accept="image/*" onchange={(e) => handlePhotoChange(e, p.id)} disabled={uploadingFor === p.id} />
|
||||||
|
</label>
|
||||||
|
{#if photoUrls[p.id]}<button class="btn-small-danger" onclick={() => handlePhotoRemove(p)}>Remove photo</button>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-small" onclick={() => startEdit(p)}>Edit</button>
|
||||||
|
<button class="btn-small-danger" onclick={() => deletePerson(p.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No one added yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relationships-section">
|
||||||
|
<h3>Relationships</h3>
|
||||||
|
{#each relationships as r (r.id)}
|
||||||
|
<div class="rel-row">
|
||||||
|
<span>{personName(r.personAId)} {r.type === 'parent_of' ? 'is parent of' : 'is spouse of'} {personName(r.personBId)}</span>
|
||||||
|
<button onclick={() => delRel(r.id)}>✕</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No relationships recorded yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Disclaimer text="This tree is informational family knowledge — it is not itself a Faraid heir determination. Use the Faraid Calculator for that." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet personNode(person, depth)}
|
||||||
|
<div class="tree-node" style="margin-left: {depth * 20}px">
|
||||||
|
<div class="tree-person">
|
||||||
|
{#if photoUrls[person.id]}
|
||||||
|
<img class="avatar avatar-sm" src={photoUrls[person.id]} alt={person.fullName} />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar avatar-sm avatar-placeholder">{person.fullName.charAt(0).toUpperCase()}</div>
|
||||||
|
{/if}
|
||||||
|
<span>{person.fullName}</span>
|
||||||
|
{#each (spousesOf[person.id] || []) as spouseId}
|
||||||
|
<span class="spouse-tag">⚭ {personName(spouseId)}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#each (childrenOf[person.id] || []) as childId}
|
||||||
|
{@const child = people.find(p => p.id === childId)}
|
||||||
|
{#if child}
|
||||||
|
{@render personNode(child, depth + 1)}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.module { padding: 4px 0 40px; }
|
||||||
|
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
|
||||||
|
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
|
||||||
|
h3 { font-size: 14px; color: #C9A84C; margin-bottom: 10px; }
|
||||||
|
.sub { font-size: 13px; color: #8A8478; margin-bottom: 16px; }
|
||||||
|
.error-text { color: #EF4444; font-size: 12.5px; margin-bottom: 12px; }
|
||||||
|
.form-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.field span { font-size: 12px; color: #B8B2A6; }
|
||||||
|
.field input, .field select { background: rgba(255,255,255,0.05); border: 1px solid rgba(201,168,76,0.2); border-radius: 8px; padding: 10px 12px; color: #E8E4DC; font-size: 14px; width: 100%; }
|
||||||
|
.btn-row { display: flex; gap: 8px; }
|
||||||
|
.btn-primary { flex: 1; padding: 12px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
|
||||||
|
.btn-secondary { padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; cursor: pointer; }
|
||||||
|
.btn-small { background: rgba(255,255,255,0.08); color: #E8E4DC; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; }
|
||||||
|
.btn-small-danger { background: rgba(239,68,68,0.1); color: #EF4444; border: none; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; }
|
||||||
|
|
||||||
|
.tree-section, .all-people-section, .relationships-section { margin-bottom: 20px; }
|
||||||
|
.tree-node { padding: 6px 0; border-left: 1px solid rgba(201,168,76,0.15); padding-left: 10px; }
|
||||||
|
.tree-person { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #E8E4DC; }
|
||||||
|
.spouse-tag { font-size: 11px; color: #8A8478; }
|
||||||
|
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 16px 0; }
|
||||||
|
|
||||||
|
.person-card { background: rgba(255,255,255,0.03); border-radius: 12px; margin-bottom: 8px; overflow: hidden; }
|
||||||
|
.person-summary { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px; background: none; border: none; cursor: pointer; text-align: left; }
|
||||||
|
.avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||||
|
.avatar-sm { width: 24px; height: 24px; }
|
||||||
|
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: rgba(201,168,76,0.15); color: #C9A84C; font-weight: 700; font-size: 15px; }
|
||||||
|
.person-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.person-info strong { color: #E8E4DC; font-size: 13.5px; }
|
||||||
|
.muted { color: #8A8478; font-size: 11px; }
|
||||||
|
.person-detail { padding: 0 12px 12px; }
|
||||||
|
.notes { font-size: 12px; color: #B8B2A6; margin-bottom: 10px; }
|
||||||
|
.photo-controls { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
|
||||||
|
.upload-btn { position: relative; background: rgba(201,168,76,0.15); color: #C9A84C; border-radius: 8px; padding: 8px 12px; font-size: 12px; cursor: pointer; font-weight: 600; }
|
||||||
|
.upload-btn input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
.rel-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 12.5px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
.rel-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -278,3 +278,80 @@ export async function notifyHeirs(memberId, familyId) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Family Genealogy ──
|
||||||
|
// nf_people are lightweight — most real tree nodes (grandparents, deceased
|
||||||
|
// relatives, young children) never have a login. linked_user_id is optional.
|
||||||
|
const PHOTO_BUCKET = 'nf-people-photos';
|
||||||
|
|
||||||
|
export async function listPeople(familyId) {
|
||||||
|
const { data, error } = await supabase.from('nf_people').select('*').eq('family_id', familyId).order('created_at');
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map(p => ({
|
||||||
|
id: p.id, familyId: p.family_id, linkedUserId: p.linked_user_id, fullName: p.full_name,
|
||||||
|
gender: p.gender, birthDate: p.birth_date, deathDate: p.death_date, notes: p.notes, photoPath: p.photo_path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPerson(familyId, createdBy, fields) {
|
||||||
|
const { data, error } = await supabase.from('nf_people').insert({
|
||||||
|
family_id: familyId, created_by: createdBy, full_name: fields.fullName, gender: fields.gender || null,
|
||||||
|
birth_date: fields.birthDate || null, death_date: fields.deathDate || null, notes: fields.notes || null
|
||||||
|
}).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePerson(id, fields) {
|
||||||
|
const { error } = await supabase.from('nf_people').update({
|
||||||
|
full_name: fields.fullName, gender: fields.gender || null, birth_date: fields.birthDate || null,
|
||||||
|
death_date: fields.deathDate || null, notes: fields.notes || null, updated_at: new Date().toISOString()
|
||||||
|
}).eq('id', id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePerson(id) {
|
||||||
|
const { error } = await supabase.from('nf_people').delete().eq('id', id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploads a photo for a person and stores its storage path on the record. Path: {familyId}/{personId}/{filename} so RLS can check family membership from the path alone. */
|
||||||
|
export async function uploadPersonPhoto(familyId, personId, file) {
|
||||||
|
const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
|
||||||
|
const path = `${familyId}/${personId}/${Date.now()}.${ext}`;
|
||||||
|
const { error: upErr } = await supabase.storage.from(PHOTO_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
const { error: dbErr } = await supabase.from('nf_people').update({ photo_path: path, updated_at: new Date().toISOString() }).eq('id', personId);
|
||||||
|
if (dbErr) throw dbErr;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePersonPhoto(personId, photoPath) {
|
||||||
|
if (photoPath) await supabase.storage.from(PHOTO_BUCKET).remove([photoPath]);
|
||||||
|
const { error } = await supabase.from('nf_people').update({ photo_path: null, updated_at: new Date().toISOString() }).eq('id', personId);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed URL (private bucket) valid for 1 hour — call fresh each time the photo is displayed. */
|
||||||
|
export async function getPersonPhotoUrl(photoPath) {
|
||||||
|
if (!photoPath) return null;
|
||||||
|
const { data, error } = await supabase.storage.from(PHOTO_BUCKET).createSignedUrl(photoPath, 3600);
|
||||||
|
if (error) return null;
|
||||||
|
return data.signedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRelationships(familyId) {
|
||||||
|
const { data, error } = await supabase.from('nf_relationships').select('*').eq('family_id', familyId);
|
||||||
|
if (error) throw error;
|
||||||
|
return (data || []).map(r => ({ id: r.id, personAId: r.person_a_id, personBId: r.person_b_id, type: r.relationship_type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRelationship(familyId, personAId, personBId, type) {
|
||||||
|
const { error } = await supabase.from('nf_relationships').insert({ family_id: familyId, person_a_id: personAId, person_b_id: personBId, relationship_type: type });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRelationship(id) {
|
||||||
|
const { error } = await supabase.from('nf_relationships').delete().eq('id', id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Reference in New Issue
Block a user