Nettoyage frontend : styles et fonctions sans appelant, drapeau ?layout=sugiyama, en-tête d'autorisation partagé

- CSS : règles des anciens écrans (assistant de création, gestionnaire de souvenirs, réglages, lecteur vidéo), keyframes et variable sans lecteur
- Fonctions sans appelant : mockData (3), treeApi (stats, audit), getMediaToken, drawPathLabel, NavButton
- Fichiers-barils navbar/ et search/ remplacés par des imports directs
- Galaxy : le placement par blocs est le seul chemin
- api/client.js : bearer(token) remplace 59 en-têtes écrits à la main ; import dynamique inutile de client.js retiré

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Fable 5.2 committed 2026-11-12 12:51:56 +01:00
1 parent f8d1a48240
commit 4c80dea44a
28 files changed
+100 -1069

No files matched your search

-587
View File
@@ -21,258 +21,10 @@
place-items: center;
}
.account-dashboard-card {
width: min(720px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 12px;
padding: 24px;
border-radius: 18px;
border: 1px solid #ebe6df;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 16px 38px rgba(42, 38, 34, 0.14);
}
.account-dashboard-card h1 {
font-size: 28px;
color: #2a2622;
}
.account-dashboard-card h2 {
font-size: 20px;
color: #4f4841;
}
.account-dashboard-card p {
color: #4f4841;
}
.account-form,
.create-tree-form {
display: grid;
gap: 8px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 14px;
}
.account-form label,
.create-tree-form label {
font-size: 14px;
color: #4f4841;
}
.account-form input,
.create-tree-form input {
width: 100%;
border-radius: 10px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 10px 12px;
outline: none;
}
.account-form input:focus,
.create-tree-form input:focus {
border-color: #d2694a;
box-shadow: 0 0 0 2px rgba(210, 105, 74, 0.15);
}
.account-form button,
.create-tree-form button {
border: 0;
border-radius: 10px;
padding: 11px 14px;
font-weight: 450;
color: #ffffff;
background: #2a2622;
cursor: pointer;
margin-top: 4px;
}
.account-form button:disabled,
.create-tree-form button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.tree-name {
font-weight: 700;
}
.tree-meta {
font-size: 14px;
color: #4f4841;
}
.wizard-overlay {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 20px;
background: rgba(34, 33, 31, 0.34);
}
.wizard-card {
width: min(460px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 10px;
border-radius: 18px;
border: 1px solid #f4d6c9;
background: #ffffff;
padding: 22px 20px 18px;
box-shadow: 0 24px 45px rgba(36, 33, 30, 0.34);
}
/* ── Sections personnes ── */
.wizard-persons {
display: grid;
gap: 8px;
}
.wizard-person {
display: grid;
gap: 7px;
border: 1px solid #edb6a4;
border-radius: 12px;
background: rgba(251, 250, 249, 0.9);
padding: 11px 12px;
}
.wizard-person-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a847d;
}
.wizard-person-label--self {
color: #2a2622;
}
.wizard-person-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.wizard-card input {
width: 100%;
border-radius: 9px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 9px 11px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.wizard-card input::placeholder {
color: #a7a29c;
}
.wizard-card input:focus {
border-color: #d2694a;
box-shadow: 0 0 0 2px rgba(210, 105, 74, 0.15);
}
.wizard-year {
grid-column: 1 / -1;
font-size: 13px !important;
}
.wizard-card button {
border: 0;
border-radius: 10px;
padding: 11px 14px;
font-size: 14px;
font-weight: 500;
color: #ffffff;
background: #2a2622;
cursor: pointer;
margin-top: 4px;
transition: filter 0.15s;
}
.wizard-card button:not(:disabled):hover {
filter: brightness(1.08);
}
.wizard-card button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.wizard-card .ghost {
margin-top: 0;
background: transparent;
color: #5f5a54;
border: 1px solid #efc7b8;
font-weight: 450;
}
.wizard-error {
background: rgba(171, 49, 49, 0.1);
border: 1px solid rgba(171, 49, 49, 0.32);
color: #862f2f;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
}
/* ============================================================
Bannière de bienvenue — onboarding premier utilisateur
============================================================ */
.demo-welcome {
position: fixed;
top: 20px;
left: 20px;
z-index: 800;
max-width: 260px;
padding: 14px 16px;
background: rgba(242, 239, 234, 0.93);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
border: 1px solid rgba(42, 38, 34, 0.1);
box-shadow:
0 4px 24px rgba(42, 38, 34, 0.14),
0 1px 4px rgba(42, 38, 34, 0.06);
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 13px;
color: #2A2622;
line-height: 1.55;
pointer-events: none;
animation: demoWelcomeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}
@keyframes demoWelcomeIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.demo-welcome strong {
color: #2a2622;
display: block;
margin-bottom: 5px;
font-size: 14px;
}
/* ============================================================
Nœud vide arbre — premier arbre sans personnes
============================================================ */
@@ -349,190 +101,6 @@
margin-left: 2px;
}
.media-manager-overlay {
position: absolute;
inset: 0;
z-index: 32;
display: grid;
place-items: center;
padding: 20px;
background: rgba(34, 33, 31, 0.3);
}
.media-manager-card {
width: min(760px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 10px;
border-radius: 16px;
border: 1px solid #f4d6c9;
background: #ffffff;
padding: 20px;
box-shadow: 0 24px 45px rgba(36, 33, 30, 0.34);
}
.media-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.media-manager-header h1 {
font-size: 24px;
color: #2a2622;
}
.media-manager-form {
display: grid;
gap: 8px;
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.7);
}
.media-manager-form label {
font-size: 14px;
color: #4f4841;
}
.media-manager-form input,
.media-manager-form select {
width: 100%;
border-radius: 10px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 10px 12px;
outline: none;
}
.media-manager-form button,
.media-manager-actions button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
font-weight: 450;
color: #ffffff;
background: #2a2622;
cursor: pointer;
}
.media-manager-card .ghost {
border: 1px solid #efc7b8;
border-radius: 10px;
padding: 10px 12px;
background: transparent;
color: #2a2622;
font-weight: 450;
cursor: pointer;
}
.media-manager-list {
display: grid;
gap: 8px;
}
.media-manager-item {
display: grid;
grid-template-columns: 74px 1fr auto;
align-items: center;
gap: 10px;
border: 1px solid #f4d6c9;
background: #ffffff;
border-radius: 10px;
padding: 8px;
}
.media-manager-preview {
width: 74px;
height: 74px;
border-radius: 8px;
background: #f4f2ee;
overflow: hidden;
display: grid;
place-items: center;
color: #4f4841;
font-size: 14px;
}
.media-manager-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-manager-meta {
display: grid;
gap: 2px;
color: #4f4841;
}
.media-manager-meta span {
color: #6b645d;
font-size: 14px;
}
.media-manager-actions {
display: flex;
gap: 6px;
}
.media-manager-empty {
color: #4f4841;
background: rgba(255, 255, 255, 0.72);
border: 1px dashed #f4d6c9;
border-radius: 10px;
padding: 12px;
}
.media-manager-error {
background: rgba(171, 49, 49, 0.1);
border: 1px solid rgba(171, 49, 49, 0.32);
color: #862f2f;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
}
/* ── Contribution change cards ── */
/* champs modifiés */
/* Boutons décision dans le header de carte */
/* Aperçu annotations */
/* résumé décisions dans la meta */
/* footer session */
/* Groupement par type d'entité */
/* Confirmation "Tout approuver" inline */
@media (max-width: 700px) {
.media-manager-item {
grid-template-columns: 1fr;
}
.media-manager-actions {
width: 100%;
}
.media-manager-actions button {
flex: 1;
}
}
.account-dashboard-card.auth-only {
width: min(480px, 100%);
text-align: center;
}
.tree-created-prompt {
position: fixed;
top: 20px;
@@ -563,123 +131,6 @@
font-size: 14px;
}
.settings-card {
width: min(900px, 100%);
}
.settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-tabs button {
border: 1px solid #efc7b8;
border-radius: 999px;
padding: 8px 12px;
background: transparent;
color: #2a2622;
cursor: pointer;
}
.settings-tabs button.active {
color: #ffffff;
border-color: #2a2622;
background: #2a2622;
}
.settings-share-card {
display: grid;
gap: 8px;
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.65);
}
.settings-share-card h3 {
color: #4f4841;
font-size: 16px;
}
.settings-share-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.settings-share-link-row input {
flex: 1;
min-width: 0;
}
.settings-password-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.settings-password-row code {
border: 1px dashed #f4d6c9;
border-radius: 8px;
background: #ffffff;
padding: 6px 10px;
color: #4f4841;
}
.settings-hint {
color: #4f4841;
font-size: 14px;
border-left: 3px solid #f4d6c9;
padding-left: 10px;
}
.settings-data-row {
display: flex;
justify-content: space-between;
gap: 12px;
border: 1px solid #ebe6df;
border-radius: 10px;
padding: 12px;
align-items: center;
}
.settings-data-row p {
color: #6B645D;
font-size: 14px;
margin-top: 4px;
}
.settings-data-row--muted {
opacity: 0.72;
}
.settings-advanced-toggle {
justify-self: start;
}
.settings-danger-box {
display: grid;
gap: 8px;
border: 1px solid rgba(171, 49, 49, 0.34);
border-radius: 12px;
background: rgba(171, 49, 49, 0.06);
padding: 12px;
}
@media (max-width: 700px) {
.settings-share-link-row {
flex-direction: column;
align-items: stretch;
}
.settings-data-row {
flex-direction: column;
align-items: stretch;
}
}
.account-overlay--open {
visibility: visible;
pointer-events: auto;
@@ -697,23 +148,6 @@
color: var(--ink-2, #6B645D);
}
.account-hint {
margin: -6px 0 10px;
font-size: 12px;
color: var(--ink-2, #6B645D);
}
/* Paramètres : contributions de la famille */
/* Écran compte : suppression du compte */
.account-delete-zone {
display: flex;
justify-content: flex-end;
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid #f8e4db;
}
.account-delete-link {
border: 0;
background: transparent;
@@ -733,27 +167,6 @@
cursor: not-allowed;
}
.account-delete-list {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
list-style: disc;
color: #4f4841;
font-size: 14px;
line-height: 1.45;
}
.account-delete-form {
display: grid;
gap: 8px;
}
.account-delete-form label {
font-size: 14px;
color: #4f4841;
}
.account-delete-modal .ghost {
border: 1px solid #efc7b8;
border-radius: 10px;
+2 -2
View File
@@ -24,9 +24,9 @@ import DraftRestoreBanner from './components/DraftRestoreBanner'
import AddPersonPanel from './components/AddPersonPanel'
import SearchSourcesPanel from './components/SearchSourcesPanel.jsx'
import ContributionSessionModal from './components/ContributionSessionModal'
import { ContextualNavbar } from './components/navbar'
import ContextualNavbar from './components/navbar/ContextualNavbar'
import ZoomControl from './components/ZoomControl'
import { ExpandableSearch } from './components/search'
import ExpandableSearch from './components/search/ExpandableSearch'
import { t, dateLocale } from './i18n/index.js'
import {
createParentChildLink,
+7 -19
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, apiRequest } from './client'
import { API_BASE_URL, apiRequest, bearer } from './client'
export async function loginUser(email, password) {
const data = await apiRequest('/auth/login', {
@@ -22,9 +22,7 @@ export async function registerUser(firstName, email, password, invitationToken)
export async function logoutUser(token) {
const data = await apiRequest('/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -34,9 +32,7 @@ export async function logoutUser(token) {
export async function deleteAccount(token, confirmation) {
const data = await apiRequest('/auth/me', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(confirmation),
})
@@ -47,9 +43,7 @@ export async function deleteAccount(token, confirmation) {
export async function fetchAccountProfile(token) {
const data = await apiRequest('/auth/me', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -58,9 +52,7 @@ export async function fetchAccountProfile(token) {
export async function fetchLastOpenedTree(token) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.lastTree || null
@@ -69,9 +61,7 @@ export async function fetchLastOpenedTree(token) {
export async function updateLastOpenedTree(token, payload) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -92,9 +82,7 @@ export function oidcStartUrl(returnTo = '/') {
export async function requestOidcLinkTicket(token) {
const data = await apiRequest('/auth/oidc/link', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.ticket
+4 -1
View File
@@ -104,4 +104,7 @@ async function apiRequest(path, options = {}) {
return payload
}
export { ApiError, API_BASE_URL, apiRequest }
// En-tête d'un appel fait au nom d'un compte ou d'un accès partagé
const bearer = (token) => ({ Authorization: `Bearer ${token}` })
export { ApiError, API_BASE_URL, apiRequest, bearer }
+4 -4
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, apiRequest } from './client.js'
import { API_BASE_URL, apiRequest, bearer } from './client.js'
// Reconnaissance des visages : la connexion Immich est propre à chaque arbre, configurée par
// ses administrateurs. L'adresse est choisie parmi celles autorisées par l'opérateur du
@@ -7,7 +7,7 @@ import { API_BASE_URL, apiRequest } from './client.js'
function withAuth(options = {}, token) {
if (!token) return options
return { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` } }
return { ...options, headers: { ...(options.headers || {}), ...bearer(token) } }
}
export function getImmichErrorMessage(error, t) {
@@ -38,7 +38,7 @@ export function listImmichAssets(treeId, token, page = 1, type = 'IMAGE', { quer
export async function fetchImmichAsset(treeId, assetId, token, variant = 'thumbnail', signal) {
const response = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/immich/assets/${encodeURIComponent(assetId)}/${variant}`, {
headers: { Authorization: `Bearer ${token}` }, signal,
headers: bearer(token), signal,
})
if (!response.ok) {
const error = new Error('immich_asset_failed')
@@ -106,7 +106,7 @@ export async function fetchImmichLink(treeId, personId, token) {
export async function fetchImmichThumbnail(treeId, immichPersonId, token) {
const path = `/trees/${encodeURIComponent(treeId)}/immich/thumbnail/${encodeURIComponent(immichPersonId)}`
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
headers: token ? bearer(token) : {},
})
if (!response.ok) {
throw new Error(`immich_thumbnail_failed_${response.status}`)
+7 -8
View File
@@ -1,29 +1,28 @@
import { apiRequest } from './client'
import { apiRequest, bearer } from './client'
// Co-gestion d'un arbre (issue #145) : gestionnaires, invitations, et la page du lien d'invitation
const auth = (token) => ({ Authorization: `Bearer ${token}` })
const tree = (treeId) => `/trees/${encodeURIComponent(treeId)}`
export function listManagers(treeId, token) {
return apiRequest(`${tree(treeId)}/managers`, { headers: auth(token) })
return apiRequest(`${tree(treeId)}/managers`, { headers: bearer(token) })
}
export function inviteManager(treeId, email, token) {
return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: auth(token), body: JSON.stringify({ email }) })
return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: bearer(token), body: JSON.stringify({ email }) })
}
export function renewInvitationLink(treeId, invitationId, token) {
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: auth(token) })
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: bearer(token) })
}
export function revokeInvitation(treeId, invitationId, token) {
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: auth(token) })
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: bearer(token) })
}
// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (son propre identifiant)
export function removeManager(treeId, userId, token) {
return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: auth(token) })
return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: bearer(token) })
}
// Sous /api comme /api/arbre/:slug : nginx ne relaie à l'API que /api (préfixe retiré), /auth et /trees
@@ -32,7 +31,7 @@ export function fetchInvitation(invitationToken) {
}
export function acceptInvitation(invitationToken, token) {
return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: auth(token) })
return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: bearer(token) })
}
export function invitationUrl(invitationToken) {
+3 -3
View File
@@ -1,9 +1,9 @@
import { apiRequest } from './client'
import { apiRequest, bearer } from './client'
// Sources que ce serveur interroge (SEARCH_SOURCES) : une liste d'identifiants, vide par défaut
export async function fetchSearchSources(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources`, {
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
return Array.isArray(data?.sources) ? data.sources : []
}
@@ -11,7 +11,7 @@ export async function fetchSearchSources(treeId, token) {
export async function searchSource(treeId, sourceId, query, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources/${encodeURIComponent(sourceId)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(query),
})
return Array.isArray(data?.results) ? data.results : []
+45 -163
View File
@@ -1,4 +1,4 @@
import { apiRequest } from './client'
import { API_BASE_URL, apiRequest, bearer } from './client'
import { readContributorName } from '../utils/contributorName.js'
export async function resolveTreeBySlug(slug) {
@@ -12,14 +12,9 @@ export async function resolveTreeBySlug(slug) {
}
export async function unlockTreeAccess(treeId, password, userToken = '') {
const headers = {}
if (userToken) {
headers.Authorization = `Bearer ${userToken}`
}
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/unlock`, {
method: 'POST',
headers,
headers: userToken ? bearer(userToken) : {},
body: JSON.stringify({ password }),
})
@@ -30,9 +25,7 @@ export async function unlockTreeAccess(treeId, password, userToken = '') {
export async function linkTreeAccess(treeId, accessToken, userToken) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/link`, {
method: 'POST',
headers: {
Authorization: `Bearer ${userToken}`,
},
headers: bearer(userToken),
body: JSON.stringify({ accessToken }),
})
@@ -46,7 +39,7 @@ export async function fetchTreeGraph(treeId, token) {
method: 'GET',
cache: 'no-store',
headers: {
Authorization: `Bearer ${token}`,
...bearer(token),
...(visitorName ? { 'X-Visitor-Name': encodeURIComponent(visitorName) } : {}),
},
})
@@ -63,7 +56,7 @@ export async function fetchTreeGraph(treeId, token) {
export async function fetchPersonLife(treeId, personId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
}
@@ -72,7 +65,7 @@ export async function createLifeEvent(treeId, personId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, {
method: 'POST',
idempotent: true,
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(payload),
})
}
@@ -80,7 +73,7 @@ export async function createLifeEvent(treeId, personId, payload, token) {
export async function updateLifeEvent(treeId, eventId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(payload),
})
}
@@ -89,16 +82,14 @@ export async function deleteLifeEvent(treeId, eventId, token, { keepalive = fals
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
keepalive,
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
}
export async function listTrees(token) {
const data = await apiRequest('/trees', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -110,9 +101,7 @@ export async function listTrees(token) {
export async function getTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -124,9 +113,7 @@ export async function getTree(treeId, token) {
export async function createTree(payload, token) {
const data = await apiRequest('/trees', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -136,9 +123,7 @@ export async function createTree(payload, token) {
export async function updateTree(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -148,9 +133,7 @@ export async function updateTree(treeId, payload, token) {
export async function deleteTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -159,9 +142,7 @@ export async function deleteTree(treeId, token) {
export async function createPerson(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -171,9 +152,7 @@ export async function createPerson(treeId, payload, token) {
export async function updatePerson(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -184,9 +163,7 @@ export async function deletePerson(treeId, personId, token, { keepalive = false
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -195,9 +172,7 @@ export async function deletePerson(treeId, personId, token, { keepalive = false
export async function createUnion(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -207,9 +182,7 @@ export async function createUnion(treeId, payload, token) {
export async function createParentChildLink(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -220,9 +193,7 @@ export async function createParentChildLink(treeId, payload, token) {
export async function updateUnion(treeId, unionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -232,9 +203,7 @@ export async function updateUnion(treeId, unionId, payload, token) {
export async function deleteUnion(treeId, unionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -244,9 +213,7 @@ export async function deleteUnion(treeId, unionId, token) {
export async function reorderSiblings(treeId, order, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/links/order`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ order }),
})
}
@@ -255,9 +222,7 @@ export async function reorderSiblings(treeId, order, token) {
export async function updateParentChildLink(treeId, linkId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -267,9 +232,7 @@ export async function updateParentChildLink(treeId, linkId, payload, token) {
export async function deleteParentChildLink(treeId, linkId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -278,9 +241,7 @@ export async function deleteParentChildLink(treeId, linkId, token) {
export async function rotateTreePasswords(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -290,39 +251,22 @@ export async function rotateTreePasswords(treeId, payload, token) {
export async function fetchSharePassword(treeId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
}
export async function rememberSharePassword(treeId, password, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords/remember`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ password }),
})
}
export async function fetchTreeStats(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/stats`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.stats || null
}
export async function fetchTreeSettings(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.settings || null
@@ -331,51 +275,18 @@ export async function fetchTreeSettings(treeId, token) {
export async function updateTreeSettings(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
return data?.settings || null
}
export async function listTreeAuditLogs(treeId, token, options = {}) {
const queryParams = new URLSearchParams()
if (options.limit) {
queryParams.set('limit', String(options.limit))
}
if (options.offset !== undefined && options.offset !== null) {
queryParams.set('offset', String(options.offset))
}
if (options.action) {
queryParams.set('action', options.action)
}
const query = queryParams.toString()
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/audit${query ? `?${query}` : ''}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
logs: Array.isArray(data?.logs) ? data.logs : [],
nextOffset: typeof data?.nextOffset === 'number' ? data.nextOffset : null,
}
}
// Journal des visites (administrateurs de l'arbre)
export async function listTreeVisits(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/visits`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -388,9 +299,7 @@ export async function submitContributionSession(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -401,9 +310,7 @@ export async function listContributionSessions(treeId, token, status = 'pending'
const query = status ? `?status=${encodeURIComponent(status)}` : ''
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return Array.isArray(data?.sessions) ? data.sessions : []
@@ -412,9 +319,7 @@ export async function listContributionSessions(treeId, token, status = 'pending'
export async function reviewContributionSession(treeId, sessionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/review`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -425,9 +330,7 @@ export async function reviewContributionSession(treeId, sessionId, payload, toke
export async function fetchContributionStatus(treeId, sessionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/status`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.status || null
@@ -437,9 +340,7 @@ export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -450,9 +351,7 @@ export async function uploadPersonMedia(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -462,9 +361,7 @@ export async function uploadPersonMedia(treeId, personId, payload, token) {
export async function reorderPersonMedia(treeId, personId, orderedMediaIds, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/reorder`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ orderedMediaIds }),
})
@@ -477,9 +374,7 @@ export async function deletePersonMedia(treeId, personId, mediaId, token, { keep
{
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
},
)
@@ -492,9 +387,7 @@ export async function updatePersonMedia(treeId, personId, mediaId, payload, toke
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
},
)
@@ -509,9 +402,7 @@ export async function updateMediaPeople(treeId, mediaId, payload, token) {
`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(mediaId)}/people`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
},
)
@@ -520,9 +411,7 @@ export async function updateMediaPeople(treeId, mediaId, payload, token) {
export async function uploadPersonAvatar(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -533,9 +422,7 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) {
export async function saveAvatarCrop(treeId, personId, crop, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar/crop`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ crop }),
})
@@ -545,9 +432,7 @@ export async function saveAvatarCrop(treeId, personId, crop, token) {
export async function deletePersonAvatar(treeId, personId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -556,9 +441,7 @@ export async function deletePersonAvatar(treeId, personId, token) {
export async function batchAnnotations(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotations/batch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -566,9 +449,8 @@ export async function batchAnnotations(treeId, payload, token) {
}
export async function exportGedcom(treeId, token) {
const { API_BASE_URL } = await import('./client')
const resp = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/export/gedcom`, {
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
if (!resp.ok) {
throw new Error('export_failed')
@@ -583,7 +465,7 @@ export async function exportGedcom(treeId, token) {
export async function importGedcom(treeId, fileContent, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/import/gedcom`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify({ content: fileContent }),
// Un gros arbre se crée en une transaction d'une minute au plus côté serveur
timeoutMs: 3 * 60_000,
+7 -9
View File
@@ -2,12 +2,10 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } f
import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } from '../data/mockData'
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_NODE_DURATION, ENTRANCE_CAMERA_DURATION, ENTRANCE_CAMERA_FROM_SCALE, ENTRANCE_REDUCED_DURATION, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants'
import { easeOutCubic } from './galaxy/utils'
import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
import { findPathBFS } from './galaxy/pathfinding'
import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers'
import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder'
@@ -277,7 +275,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [])
const canReorderNow = useCallback(() => (
reorderEnabledRef.current && USE_BLOCK_LAYOUT && Boolean(layoutDataRef.current) && Boolean(entranceRef.current?.finished)
reorderEnabledRef.current && Boolean(layoutDataRef.current) && Boolean(entranceRef.current?.finished)
), [])
const worldX = useCallback((clientX) => {
@@ -458,7 +456,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [canReorderNow, getSiblingGroups, swapLayout, layoutForOrder, commitOrder])
const getSiblingPlace = useCallback((personId) => {
if (!USE_BLOCK_LAYOUT || !layoutDataRef.current) return null
if (!layoutDataRef.current) return null
const group = getSiblingGroups().get(`p-${personId}`)
return group ? { index: group.ids.indexOf(`p-${personId}`), count: group.ids.length } : null
}, [getSiblingGroups])
@@ -640,7 +638,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
let freshTabs = null
// Liste en attente du worker : l'arbre affiché est celui de la famille de départ
let tabsPending = false
if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
if (familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
const pending = tabsRequestRef.current
// Dans un onglet d'une autre famille, ou à l'arrivée par le lien d'un onglet, l'arbre affiché dépend de la
// liste : elle est calculée sur place
@@ -682,10 +680,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
const departure = legacy && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
// Famille de départ sans propositions : son placement vient d'être calculé avec la liste des onglets
const kept = USE_BLOCK_LAYOUT && !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || (USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current))
const kept = !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs })
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
-50
View File
@@ -90,56 +90,6 @@
overflow: hidden;
}
.media-video-perfs {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 6px;
padding: 10px 0;
}
.media-video-perfs.right {
left: auto;
right: 8px;
}
.media-video-perf {
width: 8px;
height: 8px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.06);
}
.media-video-play {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.media-video-play:hover {
background: rgba(255, 255, 255, 0.18);
transform: scale(1.05);
}
.media-video-play-triangle {
width: 0;
height: 0;
border-left: 20px solid rgba(255, 255, 255, 0.7);
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
margin-left: 5px;
}
.media-video-text {
color: rgba(255, 255, 255, 0.35);
font-size: 14px;
+2 -40
View File
@@ -101,9 +101,9 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s
// commune à la fratrie, chaque enfant s'en détache par une courbe.
// Coins arrondis de rayon fixe (accolade franche, forme A).
// ============================================================
export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
// Proposé, pas encore relu (utils/ghosts.js) : pointillés terracotta, portrait délavé, étiquette au-dessus
export const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
const GHOST_LABELS = { draft: () => t('Brouillon'), sent: () => t('En attente'), pending: () => t('Proposé') }
// Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants
const BUS_DY = 100
@@ -1507,41 +1507,3 @@ function drawActiveDrawingPath(ctx, pathData) {
ctx.stroke()
ctx.restore()
}
// ============================================================
// Badge lien de parenté
// ============================================================
export function drawPathLabel(ctx, pathLabelRef, pathLabelAlpha, pathNodesRef, posMap) {
if (!pathLabelRef || pathLabelAlpha <= 0.02) return
const personNodesOnPath = pathNodesRef.filter(id => id.startsWith('p-'))
if (personNodesOnPath.length < 2) return
const midIdx = Math.floor(personNodesOnPath.length / 2)
const midPos1 = posMap.get(personNodesOnPath[midIdx - 1])
const midPos2 = posMap.get(personNodesOnPath[midIdx])
if (!midPos1 || !midPos2) return
const labelX = (midPos1.cx + midPos2.cx) / 2
const labelY = (midPos1.cy + midPos2.cy) / 2 - 20
ctx.save()
ctx.globalAlpha = pathLabelAlpha * 0.9
ctx.font = '500 11px "DM Sans", system-ui, sans-serif'
const metrics = ctx.measureText(pathLabelRef)
const pillW = metrics.width + 16
const pillH = 22
ctx.fillStyle = 'rgba(42, 38, 34, 0.85)'
ctx.beginPath()
ctx.roundRect(labelX - pillW / 2, labelY - pillH / 2, pillW, pillH, pillH / 2)
ctx.fill()
ctx.fillStyle = '#FFFFFF'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(pathLabelRef, labelX, labelY)
ctx.restore()
}
@@ -300,15 +300,6 @@
transition: all 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
/* --- Add person button --- */
.nav-annot-btn--add {
color: #93402A;
}
.nav-annot-btn--add:hover:not(:disabled) {
color: #3D3731;
}
/* --- Sub-panel (options) above navbar --- */
.navbar-annot-panel {
position: absolute;
@@ -1,41 +0,0 @@
/* Bouton rond de la barre d'actions (DA terracotta) */
.nav-button {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--pz-ink);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease, transform 140ms var(--pz-ease);
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) { .nav-button:hover:not(.disabled) { background: var(--pz-line-soft); } }
.nav-button:active:not(.disabled) { transform: scale(.94); }
.nav-button:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.nav-button.active { background: var(--pz-a100); color: var(--pz-a700); }
.nav-button.disabled { opacity: .35; cursor: not-allowed; }
.nav-button-icon { display: grid; place-items: center; line-height: 1; }
.nav-button-icon svg { width: 20px; height: 20px; }
.nav-button-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 999px;
border: 2px solid var(--pz-surface);
font: 600 10.5px/1 var(--pz-sans);
color: #fff;
box-sizing: content-box;
}
.nav-button-badge.orange { background: var(--pz-a500); }
.nav-button-badge.gray { background: var(--pz-faint); }
@@ -1,44 +0,0 @@
import './NavButton.css'
/**
* Bouton de navigation contextuel
* Style glassmorphism
*
* @param {Object} props
* @param {string} props.icon - Emoji ou texte de l'icône
* @param {string} props.label - Label au hover (tooltip)
* @param {Function} props.onClick - Handler du clic
* @param {boolean} props.active - État actif (pour toggle)
* @param {boolean} props.disabled - État désactivé
* @param {number} props.badge - Nombre à afficher dans le badge (optionnel)
* @param {string} props.badgeColor - Couleur du badge ('orange' | 'gray')
*/
function NavButton({
icon,
label,
onClick,
active = false,
disabled = false,
badge = null,
badgeColor = 'orange',
}) {
return (
<button
type="button"
className={`nav-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
<span className="nav-button-icon">{icon}</span>
{badge !== null && badge > 0 && (
<span className={`nav-button-badge ${badgeColor}`}>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
)
}
export default NavButton
-5
View File
@@ -1,5 +0,0 @@
// Export central pour les composants navbar
export { default as ContextualNavbar } from './ContextualNavbar'
export { default as NavButton } from './NavButton'
export { default as AvatarMenu } from './AvatarMenu'
export { default as TreeDropdown } from './TreeDropdown'
-3
View File
@@ -1,3 +0,0 @@
// Export central pour les composants search
export { default as ExpandableSearch } from './ExpandableSearch'
export { default as SearchResultsDropdown } from './SearchResultsDropdown'
-34
View File
@@ -355,28 +355,6 @@ export function getPersonById(personId) {
return personsById.get(String(personId)) || null
}
export function upsertPersonFromGraphPayload(person) {
if (!person?.id) {
return null
}
const index = persons.findIndex((candidate) => String(candidate.id) === String(person.id))
const current = index >= 0 ? persons[index] : null
const localPerson = mapRemotePersonToLocal(person, current)
if (index >= 0) {
persons = [
...persons.slice(0, index),
localPerson,
...persons.slice(index + 1),
]
} else {
persons = [...persons, localPerson]
}
return localPerson
}
export function getParents(personId) {
const fil = filiations.filter((filiation) => filiation.childId === personId)
const result = []
@@ -438,18 +416,6 @@ export function getPersonUnions(personId) {
.sort((a, b) => a.displayOrder - b.displayOrder)
}
export function getUnionChildren(unionId) {
return filiations
.filter((filiation) => filiation.unionId === unionId)
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((filiation) => persons.find((person) => person.id === filiation.childId))
.filter(Boolean)
}
export function getChildFiliation(childId) {
return filiations.find((filiation) => filiation.childId === childId) || null
}
// ---- Annotations helpers ----
let annotationIdCounter = 0
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Délai pendant lequel une suppression reste annulable avant de partir au serveur
export const UNDO_DELAY_MS = 6000
const UNDO_DELAY_MS = 6000
// Une suppression annulable à la fois. L'écran change tout de suite ; la requête ne part qu'à la fin du délai,
// à la suppression suivante, ou à la fermeture de la page (fetch keepalive).
+3 -7
View File
@@ -39,14 +39,10 @@
/* DM Sans pour les petits éléments UI : labels, meta, nav, badges */
button, input, select, textarea,
label,
.tree-meta, .admin-meta, .admin-stat-card span,
.contrib-invite-header span, .contrib-item-meta span,
.contrib-item-meta span,
.contrib-session-date, .contrib-role-badge,
.contrib-link-url, .contrib-pass-value, .contrib-pass-reveal,
.access-gate-tree-meta, .media-manager-meta span,
.admin-audit-main span, .admin-audit-meta,
.admin-person-item span,
.account-help {
.contrib-link-url, .contrib-pass-reveal,
.access-gate-tree-meta {
font-family: 'DM Sans', system-ui, sans-serif;
}
}
+1 -21
View File
@@ -31,7 +31,6 @@
--pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
--pz-ease: cubic-bezier(.23, 1, .32, 1);
--pz-ease-drawer: cubic-bezier(.32, .72, 0, 1);
--pz-ease-pop: cubic-bezier(.34, 1.4, .64, 1);
--pz-r-s: 10px;
--pz-r-m: 14px;
--pz-r-l: 22px;
@@ -46,8 +45,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-eyebrow { font: 500 12px/1.3 var(--pz-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--pz-muted); margin: 0; }
.pz-title { font: 400 30px/1.1 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); margin: 0; text-wrap: balance; }
.pz-title em { font-style: italic; font-weight: 300; }
.pz-title-lg { font: 300 clamp(36px, 5vw, 48px)/1.04 var(--pz-serif); letter-spacing: -.02em; color: var(--pz-ink); margin: 0; text-wrap: balance; }
.pz-title-lg em { font-style: italic; }
.pz-sub { font: 400 15px/1.55 var(--pz-sans); color: var(--pz-ink-2); margin: 0; }
.pz-small { font: 400 13px/1.5 var(--pz-sans); color: var(--pz-muted); margin: 0; }
.pz-small a, .pz-sub a { color: var(--pz-a700); text-underline-offset: 3px; }
@@ -190,8 +187,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-menu-item--danger { color: var(--pz-danger); }
.pz-menu-item--danger svg { color: var(--pz-danger); }
@media (hover: hover) and (pointer: fine) { .pz-menu-item:hover { background: var(--pz-line-soft); } .pz-menu-item.is-active:hover { background: var(--pz-a100); } }
.pz-menu-sep { height: 1px; margin: 4px 6px; background: var(--pz-line); }
.pz-menu-label { padding: 8px 12px 4px; font: 500 11.5px var(--pz-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--pz-faint); }
/* ── Le rond qui attend (motion validée le 25/09/2026) ────────── */
/* Le rond se divise en trois points qui respirent, puis se referme avec un léger rebond. */
@@ -200,13 +195,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-mark-c { fill: var(--pz-a500); transform-box: fill-box; transform-origin: center; }
.pz-mark-p { transition: transform 420ms var(--pz-ease); }
.pz-mark-s { transform-box: fill-box; transform-origin: center; transition: transform 420ms var(--pz-ease); }
.pz-mark.is-loading .pz-mark-p0 { transform: translateX(-300px); }
.pz-mark.is-loading .pz-mark-p2 { transform: translateX(300px); }
.pz-mark.is-loading .pz-mark-s { transform: scale(.27); }
.pz-mark.is-loading .pz-mark-c { animation: pz-souffle 1200ms ease-in-out 420ms infinite; }
.pz-mark.is-loading .pz-mark-p1 .pz-mark-c { animation-delay: 580ms; }
.pz-mark.is-loading .pz-mark-p2 .pz-mark-c { animation-delay: 740ms; }
.pz-mark.is-merging .pz-mark-p, .pz-mark.is-merging .pz-mark-s { transition-duration: 560ms; transition-timing-function: var(--pz-ease-pop); }
@keyframes pz-souffle { 0%, 100% { opacity: 1; transform: none; } 50% { opacity: .32; transform: scale(.78); } }
.pz-boot {
@@ -238,7 +226,7 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-btn:active:not(:disabled) { transform: none; }
/* les points apparaissent sur place et clignotent en fondu, sans déplacement */
.pz-mark-p, .pz-mark-s, .pz-boot .pz-mark { transition-duration: 1ms !important; }
.pz-mark.is-loading .pz-mark-c, .pz-busy.is-busy .pz-dots i { animation-name: pz-fade-pulse !important; }
.pz-busy.is-busy .pz-dots i { animation-name: pz-fade-pulse !important; }
.pz-busy.is-busy .pz-busy-lbl { filter: none; transform: none; }
}
@keyframes pz-fade-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
@@ -256,14 +244,6 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-lang .pz-lang-opt:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .pz-lang .pz-lang-opt:not(.is-active):hover { color: var(--pz-ink); background: var(--pz-line-soft); } }
.pz-lang-sep { font: 400 12px/1 var(--pz-sans); color: var(--pz-faint); user-select: none; }
/* Ligne de menu : libellé à gauche, choix à droite */
.pz-lang-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 40px; padding: 4px 6px 4px 12px;
font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink);
}
.pz-lang-row-label { display: inline-flex; align-items: center; gap: 10px; }
.pz-lang-row-label svg { color: var(--pz-muted); flex: none; }
/* Dans la barre d'actions, séparé des boutons par un filet */
/* Sous les écrans d'accès et de connexion */
.pz-lang--screen { justify-self: center; }
+4 -4
View File
@@ -1,8 +1,8 @@
export const DEFAULT_MAP_VIEWBOX_WIDTH = 420
export const DEFAULT_MAP_VIEWBOX_HEIGHT = 280
export const MAX_MERCATOR_LAT = 85.05112878
const DEFAULT_MAP_VIEWBOX_WIDTH = 420
const DEFAULT_MAP_VIEWBOX_HEIGHT = 280
const MAX_MERCATOR_LAT = 85.05112878
export function clamp(value, min, max) {
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
+1 -1
View File
@@ -118,7 +118,7 @@ export function ghostsFromChanges(changes = [], meta = {}, graph = {}) {
return { persons, unions, filiations, annotations, patches }
}
export function emptyGhosts() {
function emptyGhosts() {
return { persons: [], unions: [], filiations: [], annotations: [], patches: new Map() }
}
+1 -1
View File
@@ -25,7 +25,7 @@ export function kinshipFacts(path) {
}
// Suite des pas d'un chemin, pour reconnaître les liens que le compte des générations confond (#218)
export const movesOf = (path) => path.steps.map((step) => step.move).join(',')
const movesOf = (path) => path.steps.map((step) => step.move).join(',')
// Demi-frère ou demi-sœur : un seul parent en commun. Deux enfants du même couple passent par 'sibling'
// (le chemin le plus court) ; deux enfants d'un même parent avec deux conjoints passent par ce parent.
export const isHalfSibling = (path) => movesOf(path) === 'up,down'
-4
View File
@@ -7,10 +7,6 @@ export function setMediaToken(token) {
currentMediaToken = typeof token === 'string' ? token : ''
}
export function getMediaToken() {
return currentMediaToken
}
// Adresse d'un fichier servi par l'API. Une adresse externe (YouTube) ne reçoit jamais de jeton.
export function buildMediaUrl(pathOrUrl, apiBaseUrl = '', token = currentMediaToken) {
if (!pathOrUrl) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { t } from '../i18n/index.js'
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
export const MAX_FILE_BYTES = 20 * 1024 * 1024
const MAX_FILE_BYTES = 20 * 1024 * 1024
export function inferMediaTypeFromMimeType(mimeType) {
const normalized = String(mimeType || '').toLowerCase()
+2 -2
View File
@@ -2,14 +2,14 @@
// (1 = l'image entière recouvre le cadre, 4 = gros plan). Null = cadrage par défaut (centré, zoom 1).
// Ce module ne fait que des calculs : l'éditeur, la fiche et le canevas s'y réfèrent tous.
export const CROP_MAX_ZOOM = 4
const CROP_MAX_ZOOM = 4
export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 }
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
// Formes qu'on choisit au recadrage, gardées dans le cadrage (crop.shape). Sans choix, la forme suit la photo
// (très large ou très haute) ou le cadre tiré au hasard pour la personne.
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
export const cropShape = (crop) => (FRAME_SHAPES.includes(crop?.shape) ? crop.shape : null)
// Forme du cadre dans le canevas : même règle que resolveFrameType et drawFrame (renderers.js).
+3 -3
View File
@@ -7,10 +7,10 @@ export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
export const SHEET_WIDTH_QUERY = '(max-width: 768px)'
// Distance avant de trancher entre glisser vertical, horizontal et tap
export const DIRECTION_LOCK_PX = 8
const DIRECTION_LOCK_PX = 8
// Lâcher rapide vers le bas : vitesse en px/ms, et course minimale pour qu'il compte
export const FLICK_VELOCITY = 0.6
export const FLICK_MIN_OFFSET = 24
const FLICK_VELOCITY = 0.6
const FLICK_MIN_OFFSET = 24
// Deux points plus espacés que ça ne donnent pas une vitesse fiable
const VELOCITY_WINDOW_MS = 120
+2 -2
View File
@@ -33,7 +33,7 @@ function decodeName(bytes, utf8) {
return out
}
export function isZipSignature(bytes) {
function isZipSignature(bytes) {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05) && (bytes[3] === 0x04 || bytes[3] === 0x06)
}
@@ -104,7 +104,7 @@ const MIME_BY_EXTENSION = {
}
export const extensionOf = (name) => (String(name).match(/\.([a-z0-9]{1,5})$/i)?.[1] || '').toLowerCase()
export const mimeTypeOf = (name) => MIME_BY_EXTENSION[extensionOf(name)] || ''
const mimeTypeOf = (name) => MIME_BY_EXTENSION[extensionOf(name)] || ''
// Fichier du zip qu'on peut rapprocher comme un fichier de dossier (nom, chemin), et ne lire qu'au moment
// de l'envoyer : toFile() le décompresse et renvoie un vrai File