Add profile picture controls (#1525)

This commit is contained in:
lukasganster
2026-10-16 10:02:55 +02:00
committed by GitHub
parent 112b8cf261
commit 807c620e67
5 changed files with 247 additions and 5 deletions
+2
View File
@@ -1,4 +1,6 @@
{
"Profile picture": "Profile picture",
"Add profile picture": "Add profile picture",
"Fit to window": "Fit to window",
"Center on selected person": "Center on selected person",
"Move the chart": "Move the chart",
+33 -4
View File
@@ -45,6 +45,26 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
inset: 0;
}
.profile-picture-badge {
position: absolute;
top: 4px;
left: 4px;
right: 4px;
width: fit-content;
max-width: calc(100% - 8px);
box-sizing: border-box;
padding: 4px 8px;
border-radius: var(--md-sys-shape-corner-small, 8px);
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
font-size: var(--md-sys-typescale-label-medium-size, 12px);
line-height: var(--md-sys-typescale-label-medium-line-height, 16px);
font-weight: var(--md-sys-typescale-label-medium-weight, 500);
letter-spacing: 0.5px;
overflow-wrap: anywhere;
pointer-events: none;
}
.tile-overlay {
position: absolute;
bottom: 4px;
@@ -74,6 +94,7 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
media: {type: Array},
dialogContent: {type: String},
edit: {type: Boolean},
profilePicture: {type: Boolean},
_lightboxSelected: {type: Number},
}
}
@@ -83,6 +104,7 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
this.mediaRef = []
this.media = []
this.edit = false
this.profilePicture = false
this.dialogContent = ''
this._lightboxSelected = 0
}
@@ -96,13 +118,13 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
${this.edit
? html`
<div class="edit-actions">
<md-icon-button @click="${this._handleShareClick}">
<md-icon-button @click="${this.openMediaRef}">
<grampsjs-icon
path="${mdiLinkPlus}"
color="var(--mdc-theme-secondary)"
></grampsjs-icon>
</md-icon-button>
<md-icon-button @click="${this._handleAddClick}">
<md-icon-button @click="${this.openNewMedia}">
<grampsjs-icon
path="${mdiPlus}"
color="var(--mdc-theme-secondary)"
@@ -143,6 +165,11 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
checksum="${checksum}"
@click="${() => this._handleClick(i)}"
></grampsjs-img>
${this.edit && this.profilePicture && i === 0
? html`<span class="profile-picture-badge"
>${this._('Profile picture')}</span
>`
: ''}
${this.edit
? html`
<div class="tile-overlay">
@@ -244,7 +271,8 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
fireEvent(this, 'edit:action', {action: 'downMediaRef', index})
}
_handleAddClick() {
openNewMedia() {
if (!this.edit) return
this.dialogContent = html`
<grampsjs-form-new-media
@object:save="${this._handleNewMediaSave}"
@@ -255,7 +283,8 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
`
}
_handleShareClick() {
openMediaRef() {
if (!this.edit) return
this.dialogContent = html`
<grampsjs-form-mediaref
new
+1
View File
@@ -834,6 +834,7 @@ export class GrampsjsObject extends GrampsjsAppStateMixin(LitElement) {
.appState="${this.appState}"
.media=${this.data?.extended?.media}
.mediaRef=${this.data?.media_list}
?profilePicture=${this._objectEndpoint === 'people'}
?edit="${this.edit}"
></grampsjs-gallery>`
case 'metadata':
+108 -1
View File
@@ -1,9 +1,13 @@
import {html, css} from 'lit'
import '@material/web/button/outlined-button'
import '@material/web/button/text-button.js'
import '@material/web/iconbutton/outlined-icon-button'
import '@material/web/menu/menu.js'
import '@material/web/menu/menu-item.js'
import '@material/web/chips/chip-set'
import '@material/web/chips/filter-chip'
import {
mdiAccountPlus,
mdiFamilyTree,
mdiDna,
mdiSearchWeb,
@@ -16,13 +20,53 @@ import './GrampsjsImg.js'
import './GrampsjsEditGender.js'
import './GrampsjsPersonRelationship.js'
import './GrampsjsFormExternalSearch.js'
import {fireEvent, objectIconPath} from '../util.js'
import {fireEvent, objectIconPath, menuSelectionHandler} from '../util.js'
export class GrampsjsPerson extends GrampsjsObject {
static get styles() {
return [
super.styles,
css`
.profile-picture-actions {
position: relative;
}
.profile-picture-placeholder {
width: 200px;
height: 200px;
border: 2px dashed var(--mdc-theme-secondary);
border-radius: 50%;
background: transparent;
color: var(--mdc-theme-secondary);
--md-text-button-container-shape: 50%;
--md-text-button-label-text-color: var(--mdc-theme-secondary);
--md-text-button-hover-label-text-color: var(--mdc-theme-secondary);
--md-text-button-focus-label-text-color: var(--mdc-theme-secondary);
--md-text-button-pressed-label-text-color: var(--mdc-theme-secondary);
box-sizing: border-box;
}
.profile-picture-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 150px;
white-space: normal;
}
.profile-picture-placeholder:hover {
background: var(--md-sys-color-secondary-container);
--md-text-button-hover-label-text-color: var(
--md-sys-color-on-secondary-container
);
}
.profile-picture-placeholder:focus-visible {
outline: 2px solid var(--mdc-theme-secondary);
outline-offset: 4px;
}
span.event {
display: inline-block;
}
@@ -83,6 +127,69 @@ export class GrampsjsPerson extends GrampsjsObject {
this._showRelatedEvents = false
}
renderPicture() {
if (this.data?.media_list?.length) {
return super.renderPicture()
}
if (!this.edit || this.preview) {
return html``
}
return html`
<div class="profile-picture-actions">
<md-text-button
id="add-profile-picture"
type="button"
class="profile-picture-placeholder"
aria-label="${this._('Add profile picture')}"
aria-haspopup="menu"
@click=${this._openProfilePictureMenu}
>
<span class="profile-picture-content">
<grampsjs-icon
path=${mdiAccountPlus}
height="48"
width="48"
color="currentColor"
></grampsjs-icon>
<span>${this._('Add profile picture')}</span>
</span>
</md-text-button>
<md-menu
id="profile-picture-menu"
anchor="add-profile-picture"
positioning="fixed"
@close-menu=${menuSelectionHandler(item =>
this._addProfilePicture(item.dataset.action)
)}
>
<md-menu-item data-action="upload">
<div slot="headline">${this._('Add a new media object')}</div>
</md-menu-item>
<md-menu-item data-action="link">
<div slot="headline">
${this._('Select an existing media object')}
</div>
</md-menu-item>
</md-menu>
</div>
`
}
_openProfilePictureMenu() {
const menu = this.renderRoot.querySelector('#profile-picture-menu')
menu.open = true
}
_addProfilePicture(action) {
if (!this.edit || this.preview) return
const gallery = this.renderRoot.querySelector('grampsjs-gallery')
if (action === 'upload') {
gallery?.openNewMedia()
} else if (action === 'link') {
gallery?.openMediaRef()
}
}
renderProfile() {
return html`
<h2>
+103
View File
@@ -0,0 +1,103 @@
import {describe, it, expect, vi} from 'vitest'
import {GrampsjsPerson} from '../../src/components/GrampsjsPerson.js'
import {GrampsjsGallery} from '../../src/components/GrampsjsGallery.js'
const appState = {i18n: {strings: {}}}
const saveEvent = data => ({
detail: {data},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})
const makeGallery = () => {
const gallery = new GrampsjsGallery()
gallery.appState = appState
gallery.edit = true
gallery.renderRoot = gallery.createRenderRoot()
return gallery
}
describe('person profile picture actions', () => {
it('opens the existing upload and link flows only in edit mode', () => {
const person = new GrampsjsPerson()
person.renderRoot = person.createRenderRoot()
const gallery = makeGallery()
person.renderRoot.append(gallery)
const upload = vi.spyOn(gallery, 'openNewMedia')
const link = vi.spyOn(gallery, 'openMediaRef')
person._addProfilePicture('upload')
expect(upload).not.toHaveBeenCalled()
person.edit = true
person._addProfilePicture('upload')
expect(upload).toHaveBeenCalledOnce()
person._addProfilePicture('link')
expect(link).toHaveBeenCalledOnce()
person.preview = true
person._addProfilePicture('link')
expect(link).toHaveBeenCalledOnce()
})
it('leaves media unchanged when either form is cancelled', () => {
const gallery = makeGallery()
const action = vi.fn()
gallery.addEventListener('edit:action', action)
gallery.openNewMedia()
gallery._handleMediaRefCancel()
gallery.openMediaRef()
gallery._handleMediaRefCancel()
expect(action).not.toHaveBeenCalled()
expect(gallery.dialogContent).toBe('')
})
it('links existing media with its selected crop', () => {
const gallery = makeGallery()
const action = vi.fn()
gallery.addEventListener('edit:action', action)
const mediaRef = {ref: 'existing', rect: [10, 20, 80, 90]}
gallery._handleMediaRefSave(saveEvent(mediaRef))
expect(action.mock.calls[0][0].detail).toEqual({
action: 'addMediaRef',
data: mediaRef,
})
})
it('links newly uploaded media only after successful upload', async () => {
const gallery = makeGallery()
const form = document.createElement('grampsjs-form-new-media')
let resolveUpload
form.upload = vi.fn(
() =>
new Promise(resolve => {
resolveUpload = resolve
})
)
gallery.renderRoot.append(form)
const action = vi.fn()
gallery.addEventListener('edit:action', action)
const submitted = {desc: 'Portrait'}
gallery._handleNewMediaSave(saveEvent(submitted))
expect(form.upload).toHaveBeenCalledWith(submitted)
expect(action).not.toHaveBeenCalled()
resolveUpload({data: {handle: 'new-picture'}})
await Promise.resolve()
expect(action.mock.calls[0][0].detail).toEqual({
action: 'addMediaRef',
data: {ref: 'new-picture'},
})
})
it('does not link media if its upload fails', async () => {
const gallery = makeGallery()
const form = document.createElement('grampsjs-form-new-media')
form.upload = vi.fn().mockResolvedValue({error: 'Upload failed'})
gallery.renderRoot.append(form)
const action = vi.fn()
gallery.addEventListener('edit:action', action)
gallery._handleNewMediaSave(saveEvent({desc: 'Portrait'}))
await Promise.resolve()
expect(action).not.toHaveBeenCalled()
})
})