UX: Refactor batch edit dialog layout, styles, and inputs #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-17 01:15:42 +01:00
parent 3dcf3ca533
commit 0ab29f443d
17 changed files with 462 additions and 483 deletions

View file

@ -37,7 +37,7 @@ export function processAlbumSelection(selectedAlbums, availableAlbums) {
return {
processed,
changed: changed || processed.length !== selectedAlbums.length
changed: changed || processed.length !== selectedAlbums.length,
};
}
@ -52,9 +52,9 @@ export function createAlbumSelectionWatcher(albumsProperty) {
this.$nextTick(() => {
this.selectedAlbums = processed;
}).catch((error) => {
console.error('Error updating selectedAlbums:', error);
console.error("Error updating selectedAlbums:", error);
});
}
}
},
};
}

View file

@ -1,14 +1,31 @@
import { createGettext as vue3Gettext } from "vue3-gettext";
function interpolate(message, params = {}) {
if (message === null || message === undefined) {
return "";
}
const text = String(message);
if (!params || typeof params !== "object") {
return text;
}
return text.replace(/%\{(\w+)\}/g, (_, key) => {
if (!Object.prototype.hasOwnProperty.call(params, key)) {
return "";
}
const value = params[key];
return value === undefined || value === null ? "" : String(value);
});
}
export let gettext = {
$gettext: (msgid) => msgid,
$ngettext: (msgid, plural, n) => {
return n > 1 ? plural : msgid;
},
$pgettext: (context, msgid) => msgid,
$npgettext: (domain, context, msgid, plural, n) => {
return n > 1 ? plural : msgid;
},
$gettext: (msgid, params) => interpolate(msgid, params),
$ngettext: (msgid, plural, n, params) => interpolate(n > 1 ? plural : msgid, params),
$pgettext: (context, msgid, params) => interpolate(msgid, params),
$npgettext: (domain, context, msgid, plural, n, params) => interpolate(n > 1 ? plural : msgid, params),
};
export function T(msgid, params) {

View file

@ -10,6 +10,9 @@ import PSidebarInfo from "component/sidebar/info.vue";
import PMap from "component/map.vue";
import PLightbox from "component/lightbox.vue";
// Inputs.
import PInputChipSelector from "component/input/chip-selector.vue";
// Icons.
import IconLivePhoto from "component/icon/live-photo.vue";
import IconSponsor from "component/icon/sponsor.vue";
@ -79,13 +82,15 @@ export function install(app) {
app.component("PNotify", PNotify);
app.component("PScroll", PScroll);
app.component("PNavigation", PNavigation);
app.component("PUpdate", PUpdate);
app.component("PLoading", PLoading);
app.component("PLoadingBar", PLoadingBar);
app.component("PLightboxMenu", PLightboxMenu);
app.component("PSidebarInfo", PSidebarInfo);
app.component("PMap", PMap);
app.component("PLightbox", PLightbox);
app.component("PUpdate", PUpdate);
app.component("PInputChipSelector", PInputChipSelector);
app.component("IconLivePhoto", IconLivePhoto);
app.component("IconSponsor", IconSponsor);

View file

@ -1,5 +1,5 @@
<template>
<div class="chip-selector">
<div class="p-input-chip-selector chip-selector">
<div v-if="shouldRenderChips" class="chip-selector__chips">
<v-tooltip
v-for="item in processedItems"
@ -68,7 +68,7 @@
<script>
export default {
name: "ChipSelector",
name: "PInputChipSelector",
props: {
items: {
type: Array,

View file

@ -34,22 +34,22 @@
<v-icon v-if="isDeleted" variant="plain" icon="mdi-undo" class="action-undo" @click.stop="$emit('undo')"></v-icon>
<v-icon
v-else-if="isMixed"
:icon="iconClear"
variant="plain"
icon="mdi-delete"
class="action-delete"
@click.stop="$emit('delete')"
></v-icon>
<v-icon
v-else-if="showUndoButton"
variant="plain"
icon="mdi-undo"
:icon="iconUndo"
class="action-undo"
@click.stop="undoClear"
></v-icon>
<v-icon
v-else-if="coordinateInput"
:icon="iconClear"
variant="plain"
icon="mdi-delete"
class="action-delete"
@click.stop="clearCoordinates"
></v-icon>
@ -134,6 +134,8 @@ export default {
emits: ["update:latlng", "changed", "cleared", "open-map", "delete", "undo"],
data() {
return {
iconClear: "mdi-close-circle",
iconUndo: "mdi-undo",
coordinateInput: "",
inputTimeout: null,
wasCleared: false,

View file

@ -7,7 +7,7 @@
scrim
scrollable
persistent
class="p-dialog p-photo-batch-edit v-dialog--sidepanel v-dialog--sidepanel-wide"
class="p-dialog p-photo-batch-edit v-dialog--sidepanel v-dialog--batch-edit"
@click.stop="onClick"
@keydown.esc.exact="onClose"
@after-enter="afterEnter"
@ -37,7 +37,7 @@
hide-default-footer
item-key="ID"
density="comfortable"
class="elevation-0"
class="bg-transparent v-table--batch-edit"
>
<template #header.select>
<v-checkbox
@ -89,7 +89,7 @@
<template #item.name="{ item, index }">
<span
class="meta-data meta-title col-auto text-start clickable"
class="meta-data meta-title col-auto break-word text-start clickable"
:title="item.FileName"
@click.exact="openPhoto(index)"
>
@ -101,16 +101,16 @@
</v-col>
<!-- Mobile view -->
<v-col v-else cols="12">
<v-col v-else cols="12" class="px-0">
<div v-if="model.models" class="edit-batch photo-results list-view">
<v-expansion-panels
v-model="expanded"
variant="accordion"
density="compact"
rounded="6"
class="elevation-0"
class="elevation-0 ra-4 bg-transparent"
>
<v-expansion-panel title="Pictures" color="secondary" class="pa-0 elevation-0">
<v-expansion-panel color="secondary" class="pa-0 ra-4 bg-transparent elevation-0">
<v-expansion-panel-title class="px-4">{{ $gettext("Selection") }}</v-expansion-panel-title>
<v-expansion-panel-text>
<v-data-table
:headers="mobileTableHeaders"
@ -119,7 +119,7 @@
hide-default-footer
item-key="ID"
density="compact"
class="elevation-0"
class="elevation-0 ra-4"
>
<template #header.select>
<v-checkbox
@ -189,399 +189,328 @@
<v-form
ref="form"
validate-on="invalid-input"
class="p-form p-form-photo-details-meta"
class="p-form p-form-photo-details-meta pa-0"
accept-charset="UTF-8"
@submit.prevent="save"
>
<div class="form-body">
<div class="form-controls">
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Description</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
hide-details
:label="$gettext('Title')"
:model-value="formData.Title.value"
:placeholder="getFieldData('text-field', 'Title').placeholder"
:persistent-placeholder="getFieldData('text-field', 'Title').persistent"
:append-inner-icon="getIcon('text-field', 'Title')"
autocomplete="off"
density="comfortable"
class="input-title"
@click:append-inner="toggleField('Title', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'Title')"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('Subject')"
:model-value="formData.DetailsSubject.value"
:placeholder="getFieldData('text-field', 'DetailsSubject').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsSubject').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsSubject')"
:rows="1"
density="comfortable"
class="input-subject"
@click:append-inner="toggleField('DetailsSubject', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsSubject')"
></v-textarea>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('Caption')"
:model-value="formData.Caption.value"
:placeholder="getFieldData('text-field', 'Caption').placeholder"
:persistent-placeholder="getFieldData('text-field', 'Caption').persistent"
:append-inner-icon="getIcon('text-field', 'Caption')"
:rows="1"
density="comfortable"
class="input-caption"
@click:append-inner="toggleField('Caption', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'Caption')"
></v-textarea>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Date</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="6" md="2">
<v-autocomplete
v-model="formData.Day.value"
:label="$gettext('Day')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Day').items"
:placeholder="getFieldData('select-field', 'Day').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Day').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-day"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Day')"
>
</v-autocomplete>
</v-col>
<v-col cols="6" md="3">
<v-autocomplete
v-model="formData.Month.value"
:label="$gettext('Month')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Month').items"
:placeholder="getFieldData('select-field', 'Month').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Month').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-month"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Month')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-autocomplete
v-model="formData.Year.value"
:label="$gettext('Year')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Year').items"
:placeholder="getFieldData('select-field', 'Year').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Year').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-year"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Year')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-autocomplete
v-model="formData.TimeZone.value"
:label="$gettext('Time Zone')"
hide-no-data
:items="getFieldData('select-field', 'TimeZone').items"
:placeholder="getFieldData('select-field', 'TimeZone').placeholder"
:persistent-placeholder="getFieldData('select-field', 'TimeZone').persistent"
item-value="ID"
item-title="Name"
density="comfortable"
class="input-timezone"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'TimeZone')"
></v-autocomplete>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Location</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" md="6">
<p-location-input
:latlng="currentCoordinates"
:placeholder="locationPlaceholder"
:persistent-placeholder="true"
hide-details
:label="locationLabel"
density="comfortable"
validate-on="input"
:show-map-button="!placesDisabled"
:map-button-title="$gettext('Adjust Location')"
:map-button-disabled="placesDisabled"
:is-mixed="isLocationMixed"
:is-deleted="isLocationDeleted"
class="input-coordinates"
@update:latlng="updateLatLng"
@changed="onLocationChanged"
@open-map="adjustLocation"
@delete="onLocationDelete"
@undo="onLocationUndo"
></p-location-input>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-autocomplete
v-model="formData.Country.value"
:label="$gettext('Country')"
hide-details
hide-no-data
autocomplete="off"
item-value="Code"
item-title="Name"
:items="getFieldData('select-field', 'Country').items"
:placeholder="getFieldData('select-field', 'Country').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Country').persistent"
:readonly="isCountryReadOnly"
density="comfortable"
validate-on="input"
class="input-country"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Country')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
hide-details
flat
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Altitude (m)')"
:model-value="formData.Altitude.value"
:placeholder="getFieldData('input-field', 'Altitude').placeholder"
:persistent-placeholder="getFieldData('input-field', 'Altitude').persistent"
:append-inner-icon="getIcon('input-field', 'Altitude')"
color="surface-variant"
density="comfortable"
validate-on="input"
class="input-altitude"
@click:append-inner="toggleField('Altitude', $event)"
@update:model-value="(val) => changeValue(val, 'input-field', 'Altitude')"
></v-text-field>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Copyright</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
hide-details
autocomplete="off"
:label="$gettext('Artist')"
:model-value="formData.DetailsArtist.value"
:placeholder="getFieldData('text-field', 'DetailsArtist').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsArtist').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsArtist')"
density="comfortable"
class="input-artist"
@click:append-inner="toggleField('DetailsArtist', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsArtist')"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
hide-details
autocomplete="off"
:label="$gettext('Copyright')"
:model-value="formData.DetailsCopyright.value"
:placeholder="getFieldData('text-field', 'DetailsCopyright').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsCopyright').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsCopyright')"
density="comfortable"
class="input-copyright"
@click:append-inner="toggleField('DetailsCopyright', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsCopyright')"
></v-text-field>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('License')"
:model-value="formData.DetailsLicense.value"
:placeholder="getFieldData('text-field', 'DetailsLicense').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsLicense').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsLicense')"
:rows="1"
density="comfortable"
class="input-license"
@click:append-inner="toggleField('DetailsLicense', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsLicense')"
></v-textarea>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Albums</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<BatchChipSelector
v-model:items="albumItems"
:available-items="availableAlbumOptions"
:input-placeholder="$gettext('Enter album name...')"
:empty-text="$gettext('No albums assigned')"
:loading="loading"
:disabled="false"
@update:items="onAlbumsUpdate"
/>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>Labels</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<BatchChipSelector
v-model:items="labelItems"
:available-items="availableLabelOptions"
:resolve-item-from-text="resolveLabelFromText"
:normalize-title-for-compare="normalizeLabelTitleForCompare"
:input-placeholder="$gettext('Enter label name...')"
:empty-text="$gettext('No labels assigned')"
:loading="loading"
:disabled="false"
@update:items="onLabelsUpdate"
/>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col cols="12" md="6">
<p>File Type</p>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<v-autocomplete
v-model="formData.Type.value"
:label="$gettext('Type')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Type').items"
:placeholder="getFieldData('select-field', 'Type').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Type').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-type"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Type')"
>
</v-autocomplete>
</v-col>
</v-row>
</div>
<div>
<v-row dense>
<v-col
v-for="fieldName in toggleFieldsArray"
:key="fieldName"
cols="12"
sm="12"
md="6"
lg="6"
xl="3"
<v-row dense>
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Description`) }}</v-col>
<v-col cols="12">
<v-text-field
hide-details
:label="$pgettext(`Photo`, `Title`)"
:model-value="formData.Title.value"
:placeholder="getFieldData('text-field', 'Title').placeholder"
:persistent-placeholder="getFieldData('text-field', 'Title').persistent"
:append-inner-icon="getIcon('text-field', 'Title')"
autocomplete="off"
density="comfortable"
class="input-title"
@click:append-inner="toggleField('Title', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'Title')"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('Caption')"
:model-value="formData.Caption.value"
:placeholder="getFieldData('text-field', 'Caption').placeholder"
:persistent-placeholder="getFieldData('text-field', 'Caption').persistent"
:append-inner-icon="getIcon('text-field', 'Caption')"
:rows="1"
density="comfortable"
class="input-caption"
@click:append-inner="toggleField('Caption', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'Caption')"
></v-textarea>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Date & Time`) }}</v-col>
<v-col cols="6" md="3">
<v-autocomplete
v-model="formData.Day.value"
:label="$gettext('Day')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Day').items"
:placeholder="getFieldData('select-field', 'Day').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Day').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-day"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Day')"
>
<div class="d-flex flex-column">
<label class="form-label mb-2">{{ getFieldDisplayName(fieldName) }}</label>
<v-btn-toggle
v-model="formData[fieldName].value"
mandatory
color="primary"
density="comfortable"
</v-autocomplete>
</v-col>
<v-col cols="6" md="3">
<v-autocomplete
v-model="formData.Month.value"
:label="$gettext('Month')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Month').items"
:placeholder="getFieldData('select-field', 'Month').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Month').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-month"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Month')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-autocomplete
v-model="formData.Year.value"
:label="$gettext('Year')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Year').items"
:placeholder="getFieldData('select-field', 'Year').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Year').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-year"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Year')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-autocomplete
v-model="formData.TimeZone.value"
:label="$gettext('Time Zone')"
hide-no-data
:items="getFieldData('select-field', 'TimeZone').items"
:placeholder="getFieldData('select-field', 'TimeZone').placeholder"
:persistent-placeholder="getFieldData('select-field', 'TimeZone').persistent"
item-value="ID"
item-title="Name"
density="comfortable"
class="input-timezone"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'TimeZone')"
></v-autocomplete>
</v-col>
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Location`) }}</v-col>
<v-col cols="12" md="6">
<p-location-input
:latlng="currentCoordinates"
:placeholder="locationPlaceholder"
:persistent-placeholder="true"
hide-details
:label="locationLabel"
density="comfortable"
validate-on="input"
:show-map-button="!placesDisabled"
:map-button-title="$gettext('Adjust Location')"
:map-button-disabled="placesDisabled"
:is-mixed="isLocationMixed"
:is-deleted="isLocationDeleted"
class="input-coordinates"
@update:latlng="updateLatLng"
@changed="onLocationChanged"
@open-map="adjustLocation"
@delete="onLocationDelete"
@undo="onLocationUndo"
></p-location-input>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-autocomplete
v-model="formData.Country.value"
:label="$gettext('Country')"
hide-details
hide-no-data
autocomplete="off"
item-value="Code"
item-title="Name"
:items="getFieldData('select-field', 'Country').items"
:placeholder="getFieldData('select-field', 'Country').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Country').persistent"
:readonly="isCountryReadOnly"
density="comfortable"
validate-on="input"
class="input-country"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Country')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-text-field
hide-details
flat
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Altitude (m)')"
:model-value="formData.Altitude.value"
:placeholder="getFieldData('input-field', 'Altitude').placeholder"
:persistent-placeholder="getFieldData('input-field', 'Altitude').persistent"
:append-inner-icon="getIcon('input-field', 'Altitude')"
color="surface-variant"
density="comfortable"
validate-on="input"
class="input-altitude"
@click:append-inner="toggleField('Altitude', $event)"
@update:model-value="(val) => changeValue(val, 'input-field', 'Altitude')"
></v-text-field>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" class="text-subtitle-2">{{ $pgettext(`Edit`, `Content`) }}</v-col>
<v-col cols="12" sm="8">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('Subject')"
:model-value="formData.DetailsSubject.value"
:placeholder="getFieldData('text-field', 'DetailsSubject').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsSubject').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsSubject')"
:rows="1"
density="comfortable"
class="input-subject"
@click:append-inner="toggleField('DetailsSubject', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsSubject')"
></v-textarea>
</v-col>
<v-col cols="12" sm="4">
<v-autocomplete
v-model="formData.Type.value"
:label="$gettext('Type')"
autocomplete="off"
hide-details
hide-no-data
:items="getFieldData('select-field', 'Type').items"
:placeholder="getFieldData('select-field', 'Type').placeholder"
:persistent-placeholder="getFieldData('select-field', 'Type').persistent"
item-title="text"
item-value="value"
density="comfortable"
validate-on="input"
class="input-type"
@update:model-value="(val) => changeSelectValue(val, 'select-field', 'Type')"
>
</v-autocomplete>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
hide-details
autocomplete="off"
:label="$gettext('Copyright')"
:model-value="formData.DetailsCopyright.value"
:placeholder="getFieldData('text-field', 'DetailsCopyright').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsCopyright').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsCopyright')"
density="comfortable"
class="input-copyright"
@click:append-inner="toggleField('DetailsCopyright', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsCopyright')"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
hide-details
autocomplete="off"
:label="$gettext('Artist')"
:model-value="formData.DetailsArtist.value"
:placeholder="getFieldData('text-field', 'DetailsArtist').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsArtist').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsArtist')"
density="comfortable"
class="input-artist"
@click:append-inner="toggleField('DetailsArtist', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsArtist')"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
hide-details
autocomplete="off"
auto-grow
:label="$gettext('License')"
:model-value="formData.DetailsLicense.value"
:placeholder="getFieldData('text-field', 'DetailsLicense').placeholder"
:persistent-placeholder="getFieldData('text-field', 'DetailsLicense').persistent"
:append-inner-icon="getIcon('text-field', 'DetailsLicense')"
:rows="1"
density="comfortable"
class="input-license"
@click:append-inner="toggleField('DetailsLicense', $event)"
@update:model-value="(val) => changeValue(val, 'text-field', 'DetailsLicense')"
></v-textarea>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Labels`) }}</v-col>
<v-col cols="12">
<p-input-chip-selector
v-model:items="labelItems"
:available-items="availableLabelOptions"
:resolve-item-from-text="resolveLabelFromText"
:normalize-title-for-compare="normalizeLabelTitleForCompare"
:input-placeholder="$gettext('Select or create labels')"
:empty-text="$gettext('No labels assigned')"
:loading="loading"
:disabled="false"
@update:items="onLabelsUpdate"
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" class="text-subtitle-2">{{ $gettext(`Albums`) }}</v-col>
<v-col cols="12">
<p-input-chip-selector
v-model:items="albumItems"
:available-items="availableAlbumOptions"
:input-placeholder="$gettext('Select or create albums')"
:empty-text="$gettext('No albums assigned')"
:loading="loading"
:disabled="false"
@update:items="onAlbumsUpdate"
/>
</v-col>
</v-row>
<v-row dense>
<v-col v-for="fieldName in toggleFieldsArray" :key="fieldName" cols="12" sm="12" md="6" lg="6" xl="3">
<div class="d-flex flex-column">
<label class="form-label mb-3 text-subtitle-2">{{ getFieldDisplayName(fieldName) }}</label>
<v-btn-toggle v-model="formData[fieldName].value" mandatory color="primary" density="compact">
<v-btn
v-for="option in toggleOptions(fieldName)"
:key="option.value"
:value="option.value"
size="small"
density="compact"
@click="changeToggleValue(option.value, fieldName)"
>
<v-btn
v-for="option in toggleOptions(fieldName)"
:key="option.value"
:value="option.value"
size="small"
@click="changeToggleValue(option.value, fieldName)"
>
{{ option.text }}
</v-btn>
</v-btn-toggle>
</div>
</v-col>
</v-row>
</div>
{{ option.text }}
</v-btn>
</v-btn-toggle>
</div>
</v-col>
</v-row>
</div>
</div>
<div class="form-actions form-actions--sticky">
<div class="action-buttons">
<v-btn color="button" variant="flat" class="action-close" @click.stop="close">
{{ $gettext(`Close`) }}
<span v-if="hasUnsavedChanges()">{{ $gettext(`Discard`) }}</span>
<span v-else>{{ $gettext(`Close`) }}</span>
</v-btn>
<v-btn
color="highlight"
@ -591,7 +520,7 @@
:disabled="saving || !hasUnsavedChanges()"
@click.stop="save(false)"
>
<span>{{ $gettext(`Apply`) }}</span>
<span>{{ $gettext(`Save`) }}</span>
</v-btn>
</div>
</div>
@ -613,19 +542,22 @@ import IconLivePhoto from "../icon/live-photo.vue";
import { Batch } from "model/batch-edit";
import Album from "model/album";
import Label from "model/label";
import Thumb from "../../model/thumb";
import Thumb from "model/thumb";
import PLocationDialog from "component/location/dialog.vue";
import PLocationInput from "component/location/input.vue";
import BatchChipSelector from "component/file/chip-selector.vue";
import PInputChipSelector from "component/input/chip-selector.vue";
import $util from "common/util";
const iconClear = "mdi-close-circle";
const iconUndo = "mdi-undo";
export default {
name: "PPhotoBatchEdit",
components: {
IconLivePhoto,
PLocationDialog,
PLocationInput,
BatchChipSelector,
PInputChipSelector,
},
props: {
visible: {
@ -680,15 +612,15 @@ export default {
actions: { none: "none", update: "update", add: "add", remove: "remove" },
locationDialog: false,
placesDisabled: !this.$config.feature("places"),
locationLabel: this.$gettext("Location"),
locationLabel: this.$gettext(`Location`),
availableAlbumOptions: [],
availableLabelOptions: [],
albumItems: [],
labelItems: [],
tableHeaders: [
{ key: "select", title: "", sortable: false, width: "50px" },
{ key: "preview", title: "Pictures", sortable: false },
{ key: "name", title: "Name", sortable: false },
{ key: "preview", title: this.$gettext(`Select`), headerProps: { class: "px-2" }, sortable: false },
{ key: "name", title: this.$gettext(`File Name`), headerProps: { class: "break-word" }, sortable: false },
],
mobileTableHeaders: [
{
@ -698,14 +630,20 @@ export default {
width: "50px",
align: "center",
},
{ key: "preview", title: "Pictures", sortable: false, width: "80px" },
{ key: "name", title: "Name", sortable: false, align: "start" },
{ key: "preview", title: this.$gettext(`Select`), sortable: false, width: "80px" },
{
key: "name",
title: this.$gettext(`File Name`),
headerProps: { class: "break-word" },
sortable: false,
align: "start",
},
],
};
},
computed: {
formTitle() {
return this.$gettext(`Batch Edit (${this.allSelectedLength})`);
return this.$gettext(`Edit Photos (%{n})`, { n: this.allSelectedLength });
},
currentCoordinates() {
if (this.isLocationMixed || this.deletedFields.Lat || this.deletedFields.Lng) {
@ -1141,7 +1079,7 @@ export default {
toggleField(fieldName, event) {
const classList = event.target.classList;
if (classList.contains("mdi-undo")) {
if (classList.contains(iconUndo)) {
this.deletedFields[fieldName] = false;
this.formData[fieldName].action = this.actions.none;
this.formData[fieldName].value = this.previousFormData[fieldName]?.value || "";
@ -1150,7 +1088,7 @@ export default {
if (this.previousFormData[fieldName]?.mixed !== undefined) {
this.formData[fieldName].mixed = this.previousFormData[fieldName].mixed;
}
} else if (classList.contains("mdi-delete")) {
} else if (classList.contains(iconClear)) {
this.deletedFields[fieldName] = true;
if (fieldName === "Altitude") {
@ -1170,18 +1108,18 @@ export default {
const previousField = this.previousFormData[fieldName];
if (this.formData[fieldName].value !== previousField?.value || isDeleted) {
return "mdi-undo";
return iconUndo;
} else if (fieldData.mixed) {
return "mdi-delete";
return iconClear;
} else if (fieldType === "text-field" && fieldData.value !== null && fieldData.value !== "") {
return "mdi-delete";
return iconClear;
} else if (
fieldType === "input-field" &&
fieldData.value !== 0 &&
fieldData.value !== null &&
fieldData.value !== ""
) {
return "mdi-delete";
return iconClear;
}
},
getFieldData(fieldType, fieldName) {
@ -1507,17 +1445,15 @@ export default {
};
return displayNames[fieldName] || fieldName;
},
// BatchChipSelector methods
// PInputChipSelector methods
onAlbumsUpdate(updatedItems) {
this.albumItems = updatedItems;
this.updateCollectionItems("Albums", updatedItems);
},
onLabelsUpdate(updatedItems) {
this.labelItems = updatedItems;
this.updateCollectionItems("Labels", updatedItems);
},
updateCollectionItems(collectionType, items) {
// Check if there are any actual changes (add or remove actions)
const hasChanges = items.some((item) => item.action === "add" || item.action === "remove");
@ -1526,7 +1462,6 @@ export default {
this.formData[collectionType].items = items;
this.formData[collectionType].action = hasChanges ? this.actions.update : this.actions.none;
},
// Fetch available options for dropdowns
async fetchAvailableOptions() {
try {

View file

@ -84,7 +84,7 @@
</tr>
<tr>
<td>
<span>{{ $gettext(`Title`) }}</span>
<span>{{ $pgettext(`Photo`, `Title`) }}</span>
</td>
<td>
<div v-tooltip="sourceName(view.model?.TitleSrc, $gettext('Generated'))" class="text-flex text-break">

View file

@ -62,12 +62,7 @@
group
class="ms-1"
>
<v-btn
value="cards"
icon="mdi-view-column"
class="ps-1 action-view-cards"
@click="setView('cards')"
></v-btn>
<v-btn value="cards" icon="mdi-view-column" class="ps-1 action-view-cards" @click="setView('cards')"></v-btn>
<v-btn
v-if="listView"
value="list"
@ -92,11 +87,7 @@
>
</v-btn>
<p-action-menu
v-if="$vuetify.display.mdAndUp"
:items="menuActions"
button-class="ms-1"
></p-action-menu>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
</template>
<template v-else>
<v-spacer></v-spacer>

View file

@ -6,12 +6,12 @@
:title="$gettext('Close')"
@click.stop="close()"
></v-btn>
<v-toolbar-title>{{ $gettext("Information") }}</v-toolbar-title>
<v-toolbar-title>{{ $gettext(`Information`) }}</v-toolbar-title>
</v-toolbar>
<div v-if="model.UID">
<v-list nav slim tile density="compact" class="metadata__list mt-2">
<v-list-item v-if="model.Title" class="metadata__item">
<div v-tooltip="$gettext('Title')" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
<div v-tooltip="$pgettext(`Photo`, `Title`)" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
<!-- v-text-field
:model-value="modelValue.Title"
:placeholder="$gettext('Add a title')"
@ -37,7 +37,7 @@
</v-list-item>
<v-divider v-if="model.Title || model.Caption" class="my-4"></v-divider>
<v-list-item
v-tooltip="$gettext('Taken')"
v-tooltip="$gettext(`Taken`)"
:title="formatTime(model)"
prepend-icon="mdi-calendar"
class="metadata__item"
@ -48,7 +48,7 @@
</v-list-item>
<v-list-item
v-tooltip="$gettext('Size')"
v-tooltip="$gettext(`Size`)"
:title="model.getTypeInfo()"
:prepend-icon="model.getTypeIcon()"
class="metadata__item"
@ -58,7 +58,7 @@
<template v-if="model.Lat && model.Lng">
<v-divider class="my-4"></v-divider>
<v-list-item
v-tooltip="$gettext('Location')"
v-tooltip="$gettext(`Location`)"
prepend-icon="mdi-map-marker"
:title="model.getLatLng()"
class="clickable metadata__item"

View file

@ -137,6 +137,12 @@ body > .v-overlay-container .v-overlay__scrim {
width: 56vw;
}
.v-overlay.v-dialog.v-dialog--sidepanel.v-dialog--batch-edit:not(.v-dialog--fullscreen) .v-overlay__content {
min-width: 1240px;
max-width: 1600px;
width: 72vw;
}
.v-overlay.v-dialog > .v-overlay__content > .v-card,
.v-overlay.v-dialog > .v-overlay__content > form > .v-card {
border-radius: 8px;
@ -538,6 +544,17 @@ div.v-dialog.v-dialog--fullscreen > div.v-card {
margin-bottom: 4px;
}
.v-table.v-table--batch-edit {
background-color: transparent;
box-shadow: none;
}
.v-table.v-table--batch-edit tr,
.v-table.v-table--batch-edit th,
.v-table.v-table--batch-edit tr td {
border-bottom: none !important;
}
/* Flex Grids */
.v-row {

View file

@ -72,30 +72,22 @@ export class Batch extends Model {
}
async getData(selection) {
try {
const response = await $api.post("batch/photos/edit", { photos: selection });
const models = response.data.models || [];
const response = await $api.post("batch/photos/edit", { photos: selection });
const models = response.data.models || [];
this.models = models.map((m) => {
const modelInstance = new Photo();
return modelInstance.setValues(m);
});
this.models = models.map((m) => {
const modelInstance = new Photo();
return modelInstance.setValues(m);
});
this.values = response.data.values;
this.setSelections(selection);
} catch (error) {
throw error;
}
this.values = response.data.values;
this.setSelections(selection);
}
async getValuesForSelection(selection) {
try {
const response = await $api.post("batch/photos/edit", { photos: selection });
this.values = response.data.values;
return this.values;
} catch (error) {
throw error;
}
const response = await $api.post("batch/photos/edit", { photos: selection });
this.values = response.data.values;
return this.values;
}
setSelections(selection) {
@ -129,4 +121,8 @@ export class Batch extends Model {
element.selected = isToggledAll;
});
}
wasChanged() {
return super.wasChanged();
}
}

View file

@ -602,7 +602,7 @@ let themes = {
"accent": "#232323",
"card": "#242424",
"on-card": "#fafafa",
"selected": "#4f4f4f",
"selected": "#424242",
"on-selected": "#ffffff",
"switch": "#6c6c6c",
"button": "#303232",

View file

@ -236,12 +236,12 @@ describe("common/form", () => {
it("validates text length with labels", () => {
const requiredText = rules.text(true, 2, 4, "Name");
expect(requiredText[0]("")).toBe("This field is required");
expect(requiredText[1]("a")).toBe("%{s} is too short");
expect(requiredText[2]("abcde")).toBe("%{s} is too long");
expect(requiredText[1]("a")).toBe("Name is too short");
expect(requiredText[2]("abcde")).toBe("Name is too long");
expect(requiredText[1]("abc")).toBe(true);
const optionalText = rules.text(false, 2, 4, "Name");
expect(optionalText[0]("a")).toBe("%{s} is too short");
expect(optionalText[1]("abcde")).toBe("%{s} is too long");
expect(optionalText[0]("a")).toBe("Name is too short");
expect(optionalText[1]("abcde")).toBe("Name is too long");
expect(optionalText[0]("abc")).toBe(true);
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { nextTick } from "vue";
import ChipSelector from "component/file/chip-selector.vue";
import PInputChipSelector from "component/input/chip-selector.vue";
describe("component/file/chip-selector", () => {
let wrapper;
@ -32,7 +32,7 @@ describe("component/file/chip-selector", () => {
template: '<div class="v-tooltip-stub"><slot name="activator" :props="{}"></slot><slot /></div>',
};
wrapper = mount(ChipSelector, {
wrapper = mount(PInputChipSelector, {
props: {
items: mockItems,
availableItems: mockAvailableItems,
@ -238,7 +238,7 @@ describe("component/file/chip-selector", () => {
describe("Label resolver and normalization", () => {
it("resolves 'cat' → 'Katze' and sets mixed chip to add", async () => {
const wrapper = mount(ChipSelector, {
const wrapper = mount(PInputChipSelector, {
props: {
items: [{ value: "l1", title: "Katze", mixed: true, action: "none" }],
availableItems: [{ value: "l1", title: "Katze" }],
@ -263,7 +263,7 @@ describe("component/file/chip-selector", () => {
it("re-typing existing chip clears input and does not add a duplicate", async () => {
vi.useFakeTimers();
const wrapper = mount(ChipSelector, {
const wrapper = mount(PInputChipSelector, {
props: {
items: [{ value: "l1", title: "Katze", mixed: false, action: "add" }],
availableItems: [{ value: "l1", title: "Katze" }],
@ -285,7 +285,7 @@ describe("component/file/chip-selector", () => {
});
it("normalizes 'fire+station' → 'Fire Station' via resolver and sets to add", async () => {
const wrapper = mount(ChipSelector, {
const wrapper = mount(PInputChipSelector, {
props: {
items: [{ value: "l2", title: "Fire Station", mixed: true, action: "none" }],
availableItems: [{ value: "l2", title: "Fire Station" }],
@ -315,7 +315,7 @@ describe("component/file/chip-selector", () => {
});
it("adds unmatched free text as isNew: true with action 'add'", async () => {
const wrapper = mount(ChipSelector, {
const wrapper = mount(PInputChipSelector, {
props: {
items: [],
availableItems: [],

View file

@ -181,8 +181,8 @@ describe("component/photo/edit/batch", () => {
props: ["visible", "latlng"],
emits: ["close", "confirm"],
},
BatchChipSelector: {
template: '<div class="batch-chip-selector"></div>',
PInputChipSelector: {
template: '<div class="p-input-chip-selector"></div>',
props: ["items", "availableItems"],
emits: ["update:items"],
},
@ -221,7 +221,7 @@ describe("component/photo/edit/batch", () => {
});
it("should compute form title correctly", () => {
expect(wrapper.vm.formTitle).toBe("Batch Edit (3)");
expect(wrapper.vm.formTitle).toBe("Edit Photos (3)");
});
it("should compute current coordinates correctly", () => {
@ -511,10 +511,10 @@ describe("component/photo/edit/batch", () => {
it("shows delete icon for text field, then shows <deleted> + undo after delete", () => {
// Delete icon visible before deleting
expect(wrapper.vm.getIcon("text-field", "Title")).toBe("mdi-delete");
expect(wrapper.vm.getIcon("text-field", "Title")).toBe("mdi-close-circle");
// Click delete icon
wrapper.vm.toggleField("Title", makeEvent("mdi-delete"));
wrapper.vm.toggleField("Title", makeEvent("mdi-close-circle"));
// Now undo icon should be visible and placeholder should show <deleted>
expect(wrapper.vm.getIcon("text-field", "Title")).toBe("mdi-undo");
@ -527,15 +527,15 @@ describe("component/photo/edit/batch", () => {
wrapper.vm.toggleField("Title", makeEvent("mdi-undo"));
expect(wrapper.vm.deletedFields.Title).toBe(false);
expect(wrapper.vm.formData.Title.action).toBe("none");
expect(wrapper.vm.getIcon("text-field", "Title")).toBe("mdi-delete");
expect(wrapper.vm.getIcon("text-field", "Title")).toBe("mdi-close-circle");
});
it("shows delete icon for numeric field, then undo after delete", () => {
// Delete icon visible before deleting
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-delete");
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-close-circle");
// Click delete icon
wrapper.vm.toggleField("Altitude", makeEvent("mdi-delete"));
wrapper.vm.toggleField("Altitude", makeEvent("mdi-close-circle"));
// Now undo icon should be visible and value should be zeroed
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-undo");
@ -544,7 +544,7 @@ describe("component/photo/edit/batch", () => {
// Undo
wrapper.vm.toggleField("Altitude", makeEvent("mdi-undo"));
expect(wrapper.vm.formData.Altitude.value).toBe(123);
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-delete");
expect(wrapper.vm.getIcon("input-field", "Altitude")).toBe("mdi-close-circle");
});
});
});

View file

@ -0,0 +1,15 @@
export const mockGettext = (msg, params = {}) => {
if (typeof msg !== "string") {
msg = String(msg ?? "");
}
return msg.replace(/%\{(\w+)\}/g, (_, key) => {
if (params && Object.prototype.hasOwnProperty.call(params, key)) {
const value = params[key];
return value === undefined || value === null ? "" : String(value);
}
return "";
});
};
export const mockPgettext = (_ctx, msg, params = {}) => mockGettext(msg, params);

View file

@ -8,6 +8,7 @@ import "vuetify/styles";
import clientConfig from "./config";
import { $config } from "app/session";
import { mockGettext, mockPgettext } from "./helpers/gettext";
$config.setValues(clientConfig);
@ -37,8 +38,8 @@ if (typeof global.ResizeObserver === "undefined") {
// Configure Vue Test Utils global configuration
config.global.mocks = {
$gettext: (text) => text,
$pgettext: (_ctx, text) => text,
$gettext: mockGettext,
$pgettext: mockPgettext,
$isRtl: false,
$config: {
feature: () => true,