Icône de l'arbre (un emoji, dans les menus, le compte et la page d'invitation) et bouton « Modifier » pour qui gère l'arbre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 18:22:48 +01:00
1 parent d0ab96d634
commit b61ce5c8ad
17 files changed
+85 -7

No files matched your search

+6
View File
@@ -15,6 +15,12 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
- Deleting your account now names the co-managers of the trees you own, which are deleted for them too.
- In the tree menu, "Admin" is now "Co-manager".
### 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
@@ -3382,6 +3382,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}
@@ -357,10 +357,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.',