Merge pull request #149 from IliesAllali/feat/bouton-icone

Icône de l'arbre et bouton « Modifier »
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-25 19:25:19 +01:00
commit da680bceea
17 files changed
+86 -8

No files matched your search

+7 -1
View File
@@ -17,9 +17,15 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
### Search for someone elsewhere
- "Someone elsewhere" in the "+" menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it.
- "Someone elsewhere" in the bottom-right menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it.
- The server can query deaths in France since 1970 (INSEE records via matchID) and WikiTree, once listed in `SEARCH_SOURCES`. Each source says what it covers and what is sent. The owner adds a result to a person in the tree: empty fields only, marked as needing checking, source kept in the note.
### Trees
- A tree can have an icon, picked from fifteen emoji in Settings > Your tree. It replaces the first letter in the tree menu, on the account page and on the invitation page.
- For whoever manages the tree, the button at the bottom right now reads Edit, with a pencil. The family keeps Add.
- A tree name starting with an emoji no longer shows a broken letter in the menus.
### Fixes
- An admin can switch a person's profile to edit mode from a new Edit button on the profile.
@@ -0,0 +1,2 @@
-- Icône d'un arbre (un emoji), facultative. Migration additive : colonne vide par défaut.
ALTER TABLE "trees" ADD COLUMN "icon" TEXT;
+2
View File
@@ -126,6 +126,8 @@ model Tree {
slug String @unique
name String
description String?
// Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre
icon String?
rootPersonId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+1 -1
View File
@@ -37,7 +37,7 @@ export async function findPendingInvitationByToken(db: Db, token: string) {
if (!token || token.length > 200) return null
return db.treeInvitation.findFirst({
where: { tokenHash: hashInvitationToken(token), ...pendingInvitationWhere() },
include: { tree: { select: { id: true, name: true, ownerUserId: true, owner: { select: { firstName: true } } } } },
include: { tree: { select: { id: true, name: true, icon: true, ownerUserId: true, owner: { select: { firstName: true } } } } },
})
}
+1
View File
@@ -210,6 +210,7 @@ export const managerRoutes: FastifyPluginAsync = async (app) => {
}
return reply.send({
treeName: invitation.tree.name,
treeIcon: invitation.tree.icon,
invitedBy: invitation.tree.owner.firstName || null,
emailHint: maskEmail(invitation.email),
expiresAt: invitation.expiresAt,
+10
View File
@@ -40,6 +40,8 @@ const patchTreeSchema = z
slug: z.string().min(1).max(128).optional(),
description: z.string().max(500).optional().nullable(),
rootPersonId: z.string().min(1).optional().nullable(),
// Un emoji (graphème composé compris), ou null pour revenir à la première lettre
icon: z.string().trim().max(16).regex(/^\p{Extended_Pictographic}/u).optional().nullable(),
})
.refine((value) => Object.keys(value).length > 0, {
message: 'at_least_one_field_required',
@@ -392,6 +394,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: true,
name: true,
description: true,
icon: true,
rootPersonId: true,
createdAt: true,
updatedAt: true,
@@ -413,6 +416,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: true,
name: true,
description: true,
icon: true,
rootPersonId: true,
createdAt: true,
updatedAt: true,
@@ -442,6 +446,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
slug: string
name: string
description: string | null
icon: string | null
rootPersonId: string | null
createdAt: Date
updatedAt: Date
@@ -665,6 +670,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
name?: string
slug?: string
description?: string | null
icon?: string | null
rootPersonId?: string | null
} = {}
@@ -693,6 +699,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
updateData.description = payload.data.description
}
if (payload.data.icon !== undefined) {
updateData.icon = payload.data.icon
}
if (payload.data.rootPersonId !== undefined) {
if (payload.data.rootPersonId === null) {
updateData.rootPersonId = null
+9 -1
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Co-gestion d'un arbre (#145), sur une vraie base : une fonction de droits se vérifie avec de vraies requêtes,
// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée) :
// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8 pour les emoji) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/co-gestion.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
@@ -206,6 +206,14 @@ describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => {
expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(403)
})
it("l'icône de l'arbre : un emoji ou rien, posée par un gestionnaire, rendue dans la liste des arbres", async () => {
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: 'abc' })).status).toBe(400)
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: '🌳' })).status).toBe(200)
const list = await call('GET', '/trees', owner.token)
expect(list.body.trees.find((t: { id: string }) => t.id === treeId).icon).toBe('🌳')
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: null })).status).toBe(200)
})
describe('OIDC', () => {
let address = 0
async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) {
+1
View File
@@ -3404,6 +3404,7 @@ function App() {
onTreeDeleted={handleTreeDeleted}
onLeftTree={handleTreeDeleted}
onOpenShare={() => { setAdminPanelVisible(false); handleOpenContributionPanel() }}
onTreeUpdated={() => { if (auth.userAuth.token) auth.loadAccountTrees(auth.userAuth.token).catch(() => {}) }}
onGedcomImported={handleGedcomImported}
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
onClose={() => setAdminPanelVisible(false)}
+1 -1
View File
@@ -279,7 +279,7 @@ const AccountDashboard = ({
className={`tree-item pz-tree-item ${isActive ? 'active' : ''}`}
onClick={() => onOpenTree(tree.id)}
>
<span className="pz-tree-mono" aria-hidden="true">{initialOf(tree.name)}</span>
<span className={`pz-tree-mono${tree.icon ? ' pz-tree-mono--emoji' : ''}`} aria-hidden="true">{tree.icon || initialOf(tree.name)}</span>
<span className="tree-name pz-tree-name">{tree.name}</span>
{isActive && <span className="pz-tag pz-tag--accent">{t('Ouvert')}</span>}
<ChevronRight size={18} aria-hidden="true" className="pz-tree-chevron" />
@@ -66,3 +66,7 @@
.ap-item-name { flex: 1; min-width: 0; font: 400 16px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ap-item-name em { font-style: italic; font-weight: 300; }
.invite-doors { display: grid; gap: 10px; }
/* Arbre avec une icône choisie : l'emoji seul, sans la pastille de couleur de la lettre */
.pz-tree-mono--emoji { background: transparent; font-size: 22px; line-height: 1; }
.invite-tree-icon { font-size: 30px; line-height: 1; }
+25
View File
@@ -34,6 +34,9 @@ function normalizeSlug(value) {
.replace(/^-+|-+$/g, '')
}
// Icônes proposées pour un arbre (Patch, 25/10)
const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '📖', '🕊️', '🌊', '⛰️', '🍂', '🌸', '🐚', '☀️']
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
@@ -49,6 +52,7 @@ const AdminPanel = ({
onTreeDeleted,
onLeftTree,
onOpenShare,
onTreeUpdated,
onGedcomImported,
onOpenPhotoStart,
}) => {
@@ -61,6 +65,7 @@ const AdminPanel = ({
const [tree, setTree] = useState(null)
const [treeName, setTreeName] = useState('')
const [treeDescription, setTreeDescription] = useState('')
const [treeIcon, setTreeIcon] = useState(null)
// Contributions de la famille : 'pending' = validées par l'admin, 'direct' = appliquées immédiatement
const [contributionPolicy, setContributionPolicy] = useState('pending')
const [savingPolicy, setSavingPolicy] = useState(false)
@@ -132,6 +137,7 @@ const AdminPanel = ({
setTree(loadedTree)
setTreeName(loadedTree?.name || '')
setTreeDescription(loadedTree?.description || '')
setTreeIcon(loadedTree?.icon || null)
})
.catch(() => {
if (!active) return
@@ -186,6 +192,7 @@ const AdminPanel = ({
const payload = {
name: nextName,
description: treeDescription.trim() || null,
icon: treeIcon,
}
const normalized = normalizeSlug(nextName)
@@ -203,7 +210,9 @@ const AdminPanel = ({
setTree(updated)
setTreeName(updated?.name || nextName)
setTreeDescription(updated?.description || '')
setTreeIcon(updated?.icon || null)
setSuccessMessage(t('Modifications enregistrées.'))
onTreeUpdated?.(updated)
} catch {
setErrorMessage(t("Impossible d'enregistrer les modifications."))
} finally {
@@ -456,6 +465,22 @@ const AdminPanel = ({
/>
</div>
{/* Icône de l'arbre : un emoji à la place de la première lettre dans les menus et sur la page d'invitation */}
<div className="pz-field">
<span className="pz-label" id="settingsTreeIconLabel">{t("Icône de l'arbre")}</span>
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTreeIconLabel">
<button type="button" role="radio" aria-checked={!treeIcon} className="ts-icon ts-icon--letter" onClick={() => setTreeIcon(null)}>
{t('Première lettre')}
</button>
{TREE_ICONS.map((icon) => (
<button key={icon} type="button" role="radio" aria-checked={treeIcon === icon} className="ts-icon" onClick={() => setTreeIcon(icon)}>
{icon}
</button>
))}
</div>
<p className="pz-hint">{t("Elle remplace la première lettre dans vos menus et sur la page d'invitation.")}</p>
</div>
<div className="pz-field">
<label htmlFor="settingsTreeDescription">{t('Description')} <span className="ts-optional">{t('facultatif')}</span></label>
<input
@@ -85,6 +85,7 @@ const InvitationScreen = ({
} else {
const head = (
<div className="pz-auth-head">
{invitation.treeIcon && <span className="invite-tree-icon" aria-hidden="true">{invitation.treeIcon}</span>}
<h1 className="pz-title">{t('Gérer « {name} »', { name: invitation.treeName })}</h1>
<p className="pz-sub">
{invitation.invitedBy
@@ -56,6 +56,13 @@
.ts-row-actions { justify-content: flex-start; }
}
/* Icône de l'arbre : la lettre ou un emoji */
.ts-icons { display: flex; flex-wrap: wrap; gap: 6px; }
.ts-icon { width: 38px; height: 38px; border-radius: 12px; border: 1px solid var(--pz-line); background: var(--pz-surface); font-size: 19px; line-height: 1; cursor: pointer; }
.ts-icon--letter { width: auto; padding: 0 12px; border-radius: 999px; font: 500 13px var(--pz-sans); color: var(--pz-ink-2); }
.ts-icon[aria-checked="true"] { border-color: var(--pz-a500); background: var(--pz-a50); box-shadow: 0 0 0 3px var(--pz-a100); }
.ts-icon:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
/* Cinq onglets : ils rétrécissent (texte sur deux lignes) au lieu de déborder du panneau sur un petit téléphone */
.ts-tabs .pz-tab { min-width: 0; }
@media (max-width: 420px) { .ts-tabs .pz-tab { padding: 0 4px; font-size: 12.5px; line-height: 1.2; } }
@@ -47,3 +47,6 @@
.navm-current:hover { background: transparent !important; }
/* Arbre en cours : désactivé pour le clavier et le clic, mais texte à sa couleur normale (pas la mise en retrait de .navm-item:disabled) */
.navm-item.navm-current:disabled { opacity: 1; cursor: default; color: var(--pz-ink); }
/* Arbre avec une icône choisie : l'emoji seul, sans la pastille de couleur de la lettre */
.navm-tree-mono--emoji { background: transparent; font-size: 17px; line-height: 1; }
@@ -104,7 +104,7 @@ function AvatarMenu({
return (
<NavMenuItem
key={tree.id}
icon={<span className="navm-tree-mono" aria-hidden="true">{initialOf(tree.name)}</span>}
icon={<span className={`navm-tree-mono${tree.icon ? ' navm-tree-mono--emoji' : ''}`} aria-hidden="true">{tree.icon || initialOf(tree.name)}</span>}
label={tree.name}
onSelect={isActive ? undefined : () => { onClose(); onSelectTree?.(tree.id) }}
disabled={isActive}
@@ -362,10 +362,13 @@ function ContextualNavbar({
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
aria-label={t('Ajouter ou corriger')}
aria-label={isOwner || isDemo ? t('Modifier') : t('Ajouter ou corriger')}
>
<Plus size={20} strokeWidth={2.2} aria-hidden="true" />
{!isPhone && <span className="nav-add-label">{t('Ajouter')}</span>}
{/* Qui gère l'arbre (et la démo) : « Modifier » ; la famille garde « Ajouter », son geste est d'ajouter un souvenir */}
{isOwner || isDemo
? <Pencil size={18} strokeWidth={2.2} aria-hidden="true" />
: <Plus size={20} strokeWidth={2.2} aria-hidden="true" />}
{!isPhone && <span className="nav-add-label">{isOwner || isDemo ? t('Modifier') : t('Ajouter')}</span>}
</button>
)}
{hasAddMenu && openMenu === 'add' && renderAddMenu()}
+5
View File
@@ -69,6 +69,11 @@ export default {
// Fiche : droits retirés pendant une modification (PersonCard)
"Vous ne pouvez plus modifier cet arbre. Rechargez la page pour voir vos droits actuels.": 'You can no longer edit this tree. Reload the page to see your current rights.',
// Icône de l'arbre (AdminPanel) et bouton du bas (ContextualNavbar)
"Icône de l'arbre": 'Tree icon',
'Première lettre': 'First letter',
"Elle remplace la première lettre dans vos menus et sur la page d'invitation.": 'It replaces the first letter in your menus and on the invitation page.',
// Erreurs (errorMessages.js)
"Cette invitation est pour une autre adresse. Utilisez l'adresse à laquelle vous avez été invité.": 'This invitation is for another address. Use the address you were invited at.',
"Ce lien d'invitation ne marche plus. Demandez-en un nouveau à la personne qui vous a invité.": 'This invitation link no longer works. Ask the person who invited you for a new one.',