Merge pull request #94 from IliesAllali/feat/modifier-souvenir

Modifier un souvenir (administrateur)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-18 23:47:50 +02:00
commit c24be211e1
9 files changed
+635 -6

No files matched your search

+114
View File
@@ -94,6 +94,13 @@ const reorderMediaSchema = z.object({
orderedMediaIds: z.array(z.string().min(1)).min(1),
})
const MAX_CAPTION_LENGTH = 280
const MAX_CITATION_LENGTH = 2000
const updateMediaSchema = z.object({
caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH),
lifeEventId: z.string().min(1).max(64).nullable().optional(),
})
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
@@ -753,6 +760,113 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true })
})
// Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte
// du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques.
app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const params = z
.object({
id: z.string().min(1),
personId: z.string().min(1),
mediaId: z.string().min(1),
})
.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_params' })
}
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
if (!membership) {
return reply.code(403).send({ error: 'forbidden' })
}
const payload = updateMediaSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({ error: 'invalid_payload' })
}
const existingMedia = await app.prisma.mediaItem.findFirst({
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
select: { id: true, type: true, filePath: true },
})
if (!existingMedia) {
return reply.code(404).send({ error: 'media_not_found' })
}
const isCitation = existingMedia.type === 'citation'
if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) {
return reply.code(400).send({ error: 'caption_too_long' })
}
const lifeEventId = payload.data.lifeEventId
if (lifeEventId) {
const event = await app.prisma.lifeEvent.findFirst({
where: { id: lifeEventId, treeId: params.data.id, deletedAt: null },
select: { personId: true, unionId: true, status: true },
})
let ownsEvent = event?.personId === params.data.personId
if (event && !ownsEvent && event.unionId) {
const union = await app.prisma.union.findFirst({
where: { id: event.unionId, treeId: params.data.id },
select: { partner1PersonId: true, partner2PersonId: true },
})
ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId
}
if (!event || !ownsEvent || event.status !== 'approved') {
return reply.code(400).send({ error: 'invalid_life_event_reference' })
}
}
const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = {
caption: payload.data.caption,
}
if (lifeEventId !== undefined) data.lifeEventId = lifeEventId
if (isCitation) {
const buffer = Buffer.from(payload.data.caption, 'utf8')
const storageRoot = getStorageRoot()
const absolutePath = path.resolve(storageRoot, existingMedia.filePath)
if (!absolutePath.startsWith(storageRoot + path.sep)) {
return reply.code(400).send({ error: 'invalid_media_path' })
}
await writeFile(absolutePath, buffer)
data.sizeBytes = buffer.length
}
await app.prisma.mediaItem.updateMany({
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
data,
})
await app.prisma.auditLog.create({
data: {
treeId: params.data.id,
actorType: 'user',
actorId: request.actor.userId,
action: 'media_updated',
entityType: 'media_item',
entityId: params.data.mediaId,
payloadJson: {
personId: params.data.personId,
lifeEventChanged: lifeEventId !== undefined,
},
},
})
return reply.send({
ok: true,
media: {
id: params.data.mediaId,
caption: payload.data.caption,
lifeEventId,
},
})
})
app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
+279
View File
@@ -0,0 +1,279 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
create: vi.fn(),
update: vi.fn(),
},
tree: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
treeMembership: {
count: vi.fn(async () => 0),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
treeSettings: {
create: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
treeAccessPasswords: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
treeSharePassword: {
findUnique: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
userTreeAccess: {
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
person: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
union: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
parentChildLink: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
mediaItem: {
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
lifeEvent: {
findFirst: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
annotation: {
findMany: vi.fn(),
},
contributionSession: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
count: vi.fn(),
},
contributionChange: {
create: vi.fn(),
update: vi.fn(),
},
auditLog: {
create: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let createApp: () => FastifyInstance
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) }
})
function applyTestEnv(): void {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
}
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
await app.ready()
return (app as any).jwt.sign(
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
{ expiresIn: '1h' },
)
}
const URL_MEDIA = '/trees/tree-1/persons/person-1/media/media-1'
beforeAll(async () => {
applyTestEnv()
const module = await import('../src/app.js')
createApp = module.createApp
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
prismaMock.treeMembership.findUnique.mockResolvedValue({
treeId: 'tree-1',
userId: 'user-1',
role: 'owner',
tree: { deletedAt: null, rootPersonId: null },
})
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', type: 'photo', filePath: 'tree-1/person-1/media-1.jpg' })
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 })
prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, status: 'approved' })
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
})
describe('PATCH media (modifier un souvenir)', () => {
it('refuses a visitor', async () => {
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'visitor', tree: { deletedAt: null } })
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
expect(response.statusCode).toBe(403)
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('refuses a contributor', async () => {
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'contributor', tree: { deletedAt: null } })
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
expect(response.statusCode).toBe(403)
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('accepts an admin and updates title and life event', async () => {
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({
method: 'PATCH',
url: URL_MEDIA,
headers: { authorization: `Bearer ${token}` },
payload: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ id: 'media-1', treeId: 'tree-1', personId: 'person-1' }),
data: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
}))
expect(response.json().media).toEqual({ id: 'media-1', caption: 'Mariage à Caen', lifeEventId: 'event-1' })
await app.close()
})
it('clears the life event with null and keeps it when the key is absent', async () => {
const app = createApp()
const token = await createUserToken(app)
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: null } })
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo', lifeEventId: null })
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo' })
await app.close()
})
it('rejects an empty title and a photo title above 280 characters', async () => {
const app = createApp()
const token = await createUserToken(app)
const empty = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: ' ' } })
expect(empty.statusCode).toBe(400)
const long = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'a'.repeat(281) } })
expect(long.statusCode).toBe(400)
expect(long.json().error).toBe('caption_too_long')
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('refuses a life event from another tree or another person', async () => {
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null)
const app = createApp()
const token = await createUserToken(app)
const otherTree = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-other-tree' } })
expect(otherTree.statusCode).toBe(400)
expect(otherTree.json().error).toBe('invalid_life_event_reference')
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: 'person-2', unionId: null, status: 'approved' })
const otherPerson = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-2' } })
expect(otherPerson.statusCode).toBe(400)
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
await app.close()
})
it('accepts a life event of a union the person belongs to', async () => {
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: null, unionId: 'union-1', status: 'approved' })
prismaMock.union.findFirst.mockResolvedValueOnce({ partner1PersonId: 'person-1', partner2PersonId: 'person-9' })
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-union' } })
expect(response.statusCode).toBe(200)
await app.close()
})
it('returns 404 for a media that does not belong to the person', async () => {
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
expect(response.statusCode).toBe(404)
await app.close()
})
it('rewrites the citation file with the same text as caption', async () => {
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
const { writeFile } = await import('node:fs/promises')
const app = createApp()
const token = await createUserToken(app)
const text = 'Il aimait la mer, « toujours »'
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: text } })
expect(response.statusCode).toBe(200)
const writes = (writeFile as unknown as ReturnType<typeof vi.fn>).mock.calls
expect(writes.length).toBe(1)
expect(String(writes[0][0])).toMatch(/media-1\.txt$/)
expect(Buffer.from(writes[0][1] as Buffer).toString('utf8')).toBe(text)
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({
caption: text,
sizeBytes: Buffer.byteLength(text, 'utf8'),
})
await app.close()
})
it('accepts a citation longer than 280 characters', async () => {
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
const app = createApp()
const token = await createUserToken(app)
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'b'.repeat(500) } })
expect(response.statusCode).toBe(200)
await app.close()
})
})
+12
View File
@@ -3047,11 +3047,23 @@ function App() {
onMomentSaved={media.refreshMomentMedia}
/>
)}
{/* Crayon de modification masqué en démo : rien n'y est enregistré */}
{tree.selectedMedia && (
<MediaViewer
media={tree.selectedMedia.media}
person={tree.selectedMedia.person}
onClose={() => tree.setSelectedMedia(null)}
canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)}
onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)}
onSave={async (payload) => {
const { person, media: current } = tree.selectedMedia
await media.handleUpdatePersonMedia(person.id, current.id, payload)
// Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié
tree.setSelectedMedia({
person,
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
})
}}
/>
)}
<MediaManagerPanel
+16
View File
@@ -441,6 +441,22 @@ export async function deletePersonMedia(treeId, personId, mediaId, token) {
return data
}
// Modifier un souvenir (titre, moment de vie, texte de citation) : administrateurs seulement
export async function updatePersonMedia(treeId, personId, mediaId, payload, token) {
const data = await apiRequest(
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
},
)
return data
}
export async function uploadPersonAvatar(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'POST',
+49
View File
@@ -450,3 +450,52 @@
.mv-title { font-size: 22px; }
.media-citation-text { font-size: 22px; }
}
/* Modifier un souvenir : crayon discret, visible au survol sur ordinateur et au focus clavier,
toujours visible sur écran tactile (pointeur grossier) */
.mv-title-row { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }
.mv-title-row .mv-title { min-width: 0; }
.mv-edit-btn {
flex: none;
width: 26px;
height: 26px;
display: inline-grid;
place-items: center;
margin-top: 2px;
padding: 0;
border: 1px solid var(--pz-a100);
border-radius: 999px;
background: transparent;
color: var(--pz-a700);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.mv-caption:hover .mv-edit-btn,
.mv-caption:focus-within .mv-edit-btn,
.mv-edit-btn:focus-visible { opacity: 1; }
.mv-edit-btn:hover { background: var(--pz-a100); }
.mv-edit-btn:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 2px; }
@media (hover: none) {
.mv-edit-btn { opacity: 1; }
}
.mv-edit-form { display: grid; gap: 12px; }
.mv-edit-field { display: grid; gap: 4px; }
.mv-edit-label { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
.mv-edit-title,
.mv-edit-quote,
.mv-edit-select {
width: 100%;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid var(--pz-a100);
border-radius: 10px;
background: #fff;
color: var(--pz-ink);
font: 400 16px/1.3 var(--pz-sans);
}
.mv-edit-title { font-family: var(--pz-serif); font-size: 22px; }
.mv-edit-quote { font-family: var(--pz-serif); font-size: 18px; resize: vertical; }
.mv-edit-error { margin: 0; font: 400 13px var(--pz-sans); color: var(--pz-a700); }
.mv-edit-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+137 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { Pencil, X } from 'lucide-react'
import {
buildOpenStreetMapEmbedUrl,
buildOpenStreetMapUrl,
@@ -10,11 +10,26 @@ import {
import './MediaViewer.css'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { eventTitle } from '../utils/lifeTimeline.js'
import { getAccountErrorMessage } from '../utils/errorMessages.js'
const HTTP_URL_RE = /^https?:\/\/\S+$/i
const MAX_TITLE_LENGTH = 280
const MAX_CITATION_LENGTH = 2000
const MediaViewer = ({ media, person, onClose }) => {
// Moment de vie qui porte ce souvenir (mediaIds des événements de la route /life)
const findLinkedEventId = (events, mediaId) => {
const id = String(mediaId)
return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null
}
const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => {
const [mapState, setMapState] = useState({ loading: false, error: '', data: null })
const [editing, setEditing] = useState(false)
const [lifeEvents, setLifeEvents] = useState([])
const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null })
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const svgUid = useId().replace(/:/g, '')
const mapGridId = `media-map-grid-${svgUid}`
const mapGlowId = `media-map-glow-${svgUid}`
@@ -23,6 +38,109 @@ const MediaViewer = ({ media, person, onClose }) => {
if (event.target === event.currentTarget) onClose()
}
const isCitation = media?.type === 'citation'
const startEditing = async () => {
setSaveError('')
const linkedInitially = 'lifeEventId' in (media || {}) ? media.lifeEventId ?? null : null
setDraft({ title: media?.label || '', citationText: media?.label || '', lifeEventId: linkedInitially })
setEditing(true)
if (!onLoadLifeEvents) return
try {
const data = await onLoadLifeEvents()
const events = Array.isArray(data?.events) ? data.events : []
setLifeEvents(events)
if (!('lifeEventId' in (media || {}))) {
setDraft((current) => ({ ...current, lifeEventId: findLinkedEventId(events, media.id) }))
}
} catch {
setSaveError(t('Impossible de charger les moments de vie.'))
}
}
const cancelEditing = () => {
setEditing(false)
setSaveError('')
}
const handleSubmit = async (event) => {
event.preventDefault()
const caption = (isCitation ? draft.citationText : draft.title).trim()
if (!caption) {
setSaveError(isCitation ? t('Saisissez le texte de la citation.') : t('Donnez un titre au souvenir.'))
return
}
if (caption.length > (isCitation ? MAX_CITATION_LENGTH : MAX_TITLE_LENGTH)) {
setSaveError(isCitation ? t('Le texte de la citation est trop long.') : t('Le titre est trop long.'))
return
}
setSaving(true)
setSaveError('')
try {
await onSave({ caption, lifeEventId: draft.lifeEventId ?? null })
setEditing(false)
} catch (error) {
setSaveError(getAccountErrorMessage(error))
} finally {
setSaving(false)
}
}
const renderEditForm = () => (
<form className="mv-edit-form" onSubmit={handleSubmit}>
{isCitation ? (
<label className="mv-edit-field">
<span className="mv-edit-label">{t('Texte de la citation')}</span>
<textarea
className="mv-edit-quote"
rows={3}
maxLength={MAX_CITATION_LENGTH}
value={draft.citationText}
onChange={(event) => setDraft((current) => ({ ...current, citationText: event.target.value }))}
/>
</label>
) : (
<label className="mv-edit-field">
<span className="mv-edit-label">{t('Titre')}</span>
<input
className="mv-edit-title"
type="text"
maxLength={MAX_TITLE_LENGTH}
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
/>
</label>
)}
<label className="mv-edit-field">
<span className="mv-edit-label">{t('Moment de sa vie')}</span>
<select
className="mv-edit-select"
value={draft.lifeEventId ?? ''}
onChange={(event) => setDraft((current) => ({ ...current, lifeEventId: event.target.value || null }))}
>
<option value="">{t('Aucun')}</option>
{lifeEvents.map((lifeEvent) => {
const year = lifeEvent.date ? String(lifeEvent.date).slice(0, 4) : ''
return (
<option key={lifeEvent.id} value={lifeEvent.id}>
{year ? `${eventTitle(lifeEvent)} (${year})` : eventTitle(lifeEvent)}
</option>
)
})}
</select>
</label>
{saveError && <p className="mv-edit-error" role="alert">{saveError}</p>}
<div className="mv-edit-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={cancelEditing} disabled={saving}>
{t('Annuler')}
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={saving}>
{t('Enregistrer')}
</button>
</div>
</form>
)
const birthYearLabel = person?.birthYear ?? '-'
const personYears = person
? person.deathYear
@@ -387,8 +505,22 @@ const MediaViewer = ({ media, person, onClose }) => {
</div>
<div className="mv-caption">
{mediaType !== 'citation' && media?.label && (
<h2 className="mv-title">{media.label}</h2>
{editing ? renderEditForm() : (
<div className="mv-title-row">
{mediaType !== 'citation' && media?.label && (
<h2 className="mv-title">{media.label}</h2>
)}
{canEdit && onSave && (
<button
type="button"
className="mv-edit-btn"
onClick={startEditing}
aria-label={t('Modifier le souvenir')}
>
<Pencil size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
)}
<div className="mv-meta">
<span className="mv-person">
@@ -402,7 +534,7 @@ const MediaViewer = ({ media, person, onClose }) => {
</span>
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
</div>
{mediaSource && (
{mediaSource && !editing && (
<p className="mv-source">
{t('Source')}{' '}
{isMediaSourceUrl ? (
+15 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia } from '../api/treeApi'
import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia, updatePersonMedia, fetchPersonLife } from '../api/treeApi'
import { getPersonMedias } from '../data/mockData'
import { fileToBase64, getAccountErrorMessage } from '../utils/errorMessages'
import { getUploadMimeType, resolveUploadMediaType, validateMediaFile } from '../utils/mediaUpload'
@@ -273,6 +273,18 @@ export function useMediaManager({
}
}
// Modifier un souvenir (visionneuse) : le serveur refuse tout autre rôle que l'administrateur
const handleUpdatePersonMedia = async (personId, mediaId, payload) => {
if (!canEditCurrentTree || !treeContext.treeId || !treeContext.accessToken) {
throw new Error('forbidden')
}
await updatePersonMedia(treeContext.treeId, personId, mediaId, payload, treeContext.accessToken)
await refreshAfterMediaMutation(personId)
}
const loadPersonLifeEvents = (personId) => fetchPersonLife(treeContext.treeId, personId, treeContext.accessToken)
const resetMediaManager = () => {
setMediaManagerState({ visible: false, loading: false, error: '' })
}
@@ -287,6 +299,8 @@ export function useMediaManager({
uploadMomentFile,
refreshMomentMedia,
handleDeletePersonMedia,
handleUpdatePersonMedia,
loadPersonLifeEvents,
resetMediaManager,
}
}
+2
View File
@@ -5,6 +5,7 @@ import fiche from './fiche.js'
import contribution from './contribution.js'
import compte from './compte.js'
import photos from './photos.js'
import souvenir from './souvenir.js'
export default {
...app,
@@ -13,5 +14,6 @@ export default {
...contribution,
...compte,
...photos,
...souvenir,
}
+11
View File
@@ -0,0 +1,11 @@
// Lot « souvenir » : modifier un souvenir depuis la visionneuse (administrateurs, mode modification).
export default {
"Modifier le souvenir": "Edit memory",
"Texte de la citation": "Quote text",
"Moment de sa vie": "Moment in their life",
"Aucun": "None",
"Donnez un titre au souvenir.": "Give the memory a title.",
"Le texte de la citation est trop long.": "The quote is too long.",
"Le titre est trop long.": "The title is too long.",
"Impossible de charger les moments de vie.": "Could not load the life moments.",
}