diff --git a/CHANGELOG.md b/CHANGELOG.md index b152932..788ab90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/prisma/migrations/20261025_002_icone_arbre/migration.sql b/backend/prisma/migrations/20261025_002_icone_arbre/migration.sql new file mode 100644 index 0000000..a8953e0 --- /dev/null +++ b/backend/prisma/migrations/20261025_002_icone_arbre/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6d7fdaa..581350e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/lib/tree-invitations.ts b/backend/src/lib/tree-invitations.ts index 4c4a986..376545f 100644 --- a/backend/src/lib/tree-invitations.ts +++ b/backend/src/lib/tree-invitations.ts @@ -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 } } } } }, }) } diff --git a/backend/src/routes/managers.ts b/backend/src/routes/managers.ts index 1f88080..94ae651 100644 --- a/backend/src/routes/managers.ts +++ b/backend/src/routes/managers.ts @@ -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, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index b3f4009..e461f31 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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 diff --git a/backend/test/co-gestion.db.test.ts b/backend/test/co-gestion.db.test.ts index a73b778..14e2435 100644 --- a/backend/test/co-gestion.db.test.ts +++ b/backend/test/co-gestion.db.test.ts @@ -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 }) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b4b0059..ee026d5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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)} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 7f693ec..2ea95b8 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -279,7 +279,7 @@ const AccountDashboard = ({ className={`tree-item pz-tree-item ${isActive ? 'active' : ''}`} onClick={() => onOpenTree(tree.id)} > - + {tree.name} {isActive && {t('Ouvert')}} diff --git a/frontend/src/components/AccountScreens.css b/frontend/src/components/AccountScreens.css index 249495f..1f3ab0c 100644 --- a/frontend/src/components/AccountScreens.css +++ b/frontend/src/components/AccountScreens.css @@ -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; } diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index c63384a..866f919 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -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 = ({ /> + {/* Icône de l'arbre : un emoji à la place de la première lettre dans les menus et sur la page d'invitation */} +
{t("Elle remplace la première lettre dans vos menus et sur la page d'invitation.")}
+{invitation.invitedBy diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index 0261340..6938b00 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -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; } } diff --git a/frontend/src/components/navbar/AvatarMenu.css b/frontend/src/components/navbar/AvatarMenu.css index e480cc3..1c3a2e7 100644 --- a/frontend/src/components/navbar/AvatarMenu.css +++ b/frontend/src/components/navbar/AvatarMenu.css @@ -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; } diff --git a/frontend/src/components/navbar/AvatarMenu.jsx b/frontend/src/components/navbar/AvatarMenu.jsx index 3d6f206..c96726d 100644 --- a/frontend/src/components/navbar/AvatarMenu.jsx +++ b/frontend/src/components/navbar/AvatarMenu.jsx @@ -104,7 +104,7 @@ function AvatarMenu({ return (