mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-11 08:02:17 +00:00
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:
1 parent
d0ab96d634
commit
b61ce5c8ad
17 files changed
+85
-7
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 } } } } },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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()}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in new issue
Block a user