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",
|
||||
"Center on selected person": "Center on selected person",
|
||||
"Move the chart": "Move the chart",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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