mirror of
https://github.com/gramps-project/gramps-web.git
synced 2026-10-16 21:01:38 +08:00
Add profile picture controls (#1525)
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"Profile picture": "Profile picture",
|
||||||
|
"Add profile picture": "Add profile picture",
|
||||||
"Fit to window": "Fit to window",
|
"Fit to window": "Fit to window",
|
||||||
"Center on selected person": "Center on selected person",
|
"Center on selected person": "Center on selected person",
|
||||||
"Move the chart": "Move the chart",
|
"Move the chart": "Move the chart",
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
inset: 0;
|
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 {
|
.tile-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
@@ -74,6 +94,7 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
media: {type: Array},
|
media: {type: Array},
|
||||||
dialogContent: {type: String},
|
dialogContent: {type: String},
|
||||||
edit: {type: Boolean},
|
edit: {type: Boolean},
|
||||||
|
profilePicture: {type: Boolean},
|
||||||
_lightboxSelected: {type: Number},
|
_lightboxSelected: {type: Number},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +104,7 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
this.mediaRef = []
|
this.mediaRef = []
|
||||||
this.media = []
|
this.media = []
|
||||||
this.edit = false
|
this.edit = false
|
||||||
|
this.profilePicture = false
|
||||||
this.dialogContent = ''
|
this.dialogContent = ''
|
||||||
this._lightboxSelected = 0
|
this._lightboxSelected = 0
|
||||||
}
|
}
|
||||||
@@ -96,13 +118,13 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
${this.edit
|
${this.edit
|
||||||
? html`
|
? html`
|
||||||
<div class="edit-actions">
|
<div class="edit-actions">
|
||||||
<md-icon-button @click="${this._handleShareClick}">
|
<md-icon-button @click="${this.openMediaRef}">
|
||||||
<grampsjs-icon
|
<grampsjs-icon
|
||||||
path="${mdiLinkPlus}"
|
path="${mdiLinkPlus}"
|
||||||
color="var(--mdc-theme-secondary)"
|
color="var(--mdc-theme-secondary)"
|
||||||
></grampsjs-icon>
|
></grampsjs-icon>
|
||||||
</md-icon-button>
|
</md-icon-button>
|
||||||
<md-icon-button @click="${this._handleAddClick}">
|
<md-icon-button @click="${this.openNewMedia}">
|
||||||
<grampsjs-icon
|
<grampsjs-icon
|
||||||
path="${mdiPlus}"
|
path="${mdiPlus}"
|
||||||
color="var(--mdc-theme-secondary)"
|
color="var(--mdc-theme-secondary)"
|
||||||
@@ -143,6 +165,11 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
checksum="${checksum}"
|
checksum="${checksum}"
|
||||||
@click="${() => this._handleClick(i)}"
|
@click="${() => this._handleClick(i)}"
|
||||||
></grampsjs-img>
|
></grampsjs-img>
|
||||||
|
${this.edit && this.profilePicture && i === 0
|
||||||
|
? html`<span class="profile-picture-badge"
|
||||||
|
>${this._('Profile picture')}</span
|
||||||
|
>`
|
||||||
|
: ''}
|
||||||
${this.edit
|
${this.edit
|
||||||
? html`
|
? html`
|
||||||
<div class="tile-overlay">
|
<div class="tile-overlay">
|
||||||
@@ -244,7 +271,8 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
fireEvent(this, 'edit:action', {action: 'downMediaRef', index})
|
fireEvent(this, 'edit:action', {action: 'downMediaRef', index})
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleAddClick() {
|
openNewMedia() {
|
||||||
|
if (!this.edit) return
|
||||||
this.dialogContent = html`
|
this.dialogContent = html`
|
||||||
<grampsjs-form-new-media
|
<grampsjs-form-new-media
|
||||||
@object:save="${this._handleNewMediaSave}"
|
@object:save="${this._handleNewMediaSave}"
|
||||||
@@ -255,7 +283,8 @@ export class GrampsjsGallery extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleShareClick() {
|
openMediaRef() {
|
||||||
|
if (!this.edit) return
|
||||||
this.dialogContent = html`
|
this.dialogContent = html`
|
||||||
<grampsjs-form-mediaref
|
<grampsjs-form-mediaref
|
||||||
new
|
new
|
||||||
|
|||||||
@@ -834,6 +834,7 @@ export class GrampsjsObject extends GrampsjsAppStateMixin(LitElement) {
|
|||||||
.appState="${this.appState}"
|
.appState="${this.appState}"
|
||||||
.media=${this.data?.extended?.media}
|
.media=${this.data?.extended?.media}
|
||||||
.mediaRef=${this.data?.media_list}
|
.mediaRef=${this.data?.media_list}
|
||||||
|
?profilePicture=${this._objectEndpoint === 'people'}
|
||||||
?edit="${this.edit}"
|
?edit="${this.edit}"
|
||||||
></grampsjs-gallery>`
|
></grampsjs-gallery>`
|
||||||
case 'metadata':
|
case 'metadata':
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {html, css} from 'lit'
|
import {html, css} from 'lit'
|
||||||
import '@material/web/button/outlined-button'
|
import '@material/web/button/outlined-button'
|
||||||
|
import '@material/web/button/text-button.js'
|
||||||
import '@material/web/iconbutton/outlined-icon-button'
|
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/chip-set'
|
||||||
import '@material/web/chips/filter-chip'
|
import '@material/web/chips/filter-chip'
|
||||||
import {
|
import {
|
||||||
|
mdiAccountPlus,
|
||||||
mdiFamilyTree,
|
mdiFamilyTree,
|
||||||
mdiDna,
|
mdiDna,
|
||||||
mdiSearchWeb,
|
mdiSearchWeb,
|
||||||
@@ -16,13 +20,53 @@ import './GrampsjsImg.js'
|
|||||||
import './GrampsjsEditGender.js'
|
import './GrampsjsEditGender.js'
|
||||||
import './GrampsjsPersonRelationship.js'
|
import './GrampsjsPersonRelationship.js'
|
||||||
import './GrampsjsFormExternalSearch.js'
|
import './GrampsjsFormExternalSearch.js'
|
||||||
import {fireEvent, objectIconPath} from '../util.js'
|
import {fireEvent, objectIconPath, menuSelectionHandler} from '../util.js'
|
||||||
|
|
||||||
export class GrampsjsPerson extends GrampsjsObject {
|
export class GrampsjsPerson extends GrampsjsObject {
|
||||||
static get styles() {
|
static get styles() {
|
||||||
return [
|
return [
|
||||||
super.styles,
|
super.styles,
|
||||||
css`
|
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 {
|
span.event {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -83,6 +127,69 @@ export class GrampsjsPerson extends GrampsjsObject {
|
|||||||
this._showRelatedEvents = false
|
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() {
|
renderProfile() {
|
||||||
return html`
|
return html`
|
||||||
<h2>
|
<h2>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user