mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #149 from IliesAllali/feat/bouton-icone
Icône de l'arbre et bouton « Modifier »
This commit is contained in:
17 files changed
+86
-8
No files matched your search
+7
-1
@@ -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;
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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