mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
3dcf3ca533
commit
0ab29f443d
17 changed files with 462 additions and 483 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ let themes = {
|
|||
"accent": "#232323",
|
||||
"card": "#242424",
|
||||
"on-card": "#fafafa",
|
||||
"selected": "#4f4f4f",
|
||||
"selected": "#424242",
|
||||
"on-selected": "#ffffff",
|
||||
"switch": "#6c6c6c",
|
||||
"button": "#303232",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
15
frontend/tests/vitest/helpers/gettext.js
Normal file
15
frontend/tests/vitest/helpers/gettext.js
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue