mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Merge 5dde7c590f into 26b5cbafcd
This commit is contained in:
commit
93e129a377
2 changed files with 373 additions and 32 deletions
|
|
@ -8,52 +8,104 @@
|
|||
<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="$pgettext(`Photo`, `Title`)" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
|
||||
<!-- v-text-field
|
||||
:model-value="modelValue.Title"
|
||||
:placeholder="$gettext('Add a title')"
|
||||
density="comfortable"
|
||||
variant="solo-filled"
|
||||
hide-details
|
||||
class="pa-0 font-weight-bold"
|
||||
></v-text-field -->
|
||||
</v-list-item>
|
||||
<v-list-item v-if="model.Caption" class="metadata__item">
|
||||
<div v-tooltip="$gettext('Caption')" class="text-body-2 meta-caption">{{ model.Caption }}</div>
|
||||
<!-- v-textarea
|
||||
:model-value="modelValue.Caption"
|
||||
:placeholder="$gettext('Add a caption')"
|
||||
density="comfortable"
|
||||
variant="solo-filled"
|
||||
hide-details
|
||||
autocomplete="off"
|
||||
auto-grow
|
||||
:rows="1"
|
||||
class="pa-0"
|
||||
></v-textarea -->
|
||||
</v-list-item>
|
||||
<v-divider v-if="model.Title || model.Caption" class="my-4"></v-divider>
|
||||
<v-list-item v-tooltip="$gettext(`Taken`)" :title="formatTime(model)" prepend-icon="mdi-calendar" class="metadata__item">
|
||||
<!-- template #append>
|
||||
<v-icon icon="mdi-pencil" size="20"></v-icon>
|
||||
</template -->
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-tooltip="$gettext(`Size`)" :title="model.getTypeInfo()" :prepend-icon="model.getTypeIcon()" class="metadata__item"> </v-list-item>
|
||||
<!-- Date and Time -->
|
||||
<v-list-item v-tooltip="$gettext('Taken')" :title="formatTime(model)" prepend-icon="mdi-calendar" class="metadata__item"> </v-list-item>
|
||||
|
||||
<!-- File Info -->
|
||||
<v-list-item v-if="typeInfo" v-tooltip="$gettext('Size')" :title="typeInfo" :prepend-icon="typeIcon" class="metadata__item"> </v-list-item>
|
||||
|
||||
<!-- Camera Section (only shown when full photo data is loaded) -->
|
||||
<template v-if="hasCamera">
|
||||
<v-divider class="my-4"></v-divider>
|
||||
<v-list-item v-if="cameraName" v-tooltip="$gettext('Camera')" :title="cameraName" prepend-icon="mdi-camera" class="metadata__item"> </v-list-item>
|
||||
<v-list-item v-if="lensInfo" v-tooltip="$gettext('Lens')" :title="lensInfo" prepend-icon="mdi-camera-iris" class="metadata__item"> </v-list-item>
|
||||
<v-list-item v-if="exposureInfo" v-tooltip="$gettext('Exposure')" :title="exposureInfo" prepend-icon="mdi-tune" class="metadata__item"> </v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- Location Section -->
|
||||
<template v-if="model.Lat && model.Lng">
|
||||
<v-divider class="my-4"></v-divider>
|
||||
<v-list-item v-if="locationLabel" v-tooltip="$gettext('Location')" :title="locationLabel" prepend-icon="mdi-map-marker" class="metadata__item">
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-tooltip="$gettext(`Location`)"
|
||||
prepend-icon="mdi-map-marker"
|
||||
:title="model.getLatLng()"
|
||||
v-tooltip="$gettext('Coordinates')"
|
||||
prepend-icon="mdi-crosshairs-gps"
|
||||
:title="latLng"
|
||||
class="clickable metadata__item"
|
||||
@click.stop="model.copyLatLng()"
|
||||
@click.stop="copyLatLng"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="featPlaces" class="mx-0 px-0">
|
||||
<v-list-item
|
||||
v-if="model.Altitude"
|
||||
v-tooltip="$gettext('Altitude')"
|
||||
:title="model.Altitude + ' m'"
|
||||
prepend-icon="mdi-elevation-rise"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="featPlaces" class="mx-0 px-0" style="margin-top: 0.5rem">
|
||||
<p-map :latlng="[model.Lat, model.Lng]"></p-map>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- Details Section (only shown when full photo data is loaded) -->
|
||||
<template v-if="hasDetails">
|
||||
<v-divider class="my-4"></v-divider>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.Artist"
|
||||
v-tooltip="$gettext('Artist')"
|
||||
:title="photo.Details.Artist"
|
||||
prepend-icon="mdi-account"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.Copyright"
|
||||
v-tooltip="$gettext('Copyright')"
|
||||
:title="photo.Details.Copyright"
|
||||
prepend-icon="mdi-copyright"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.License"
|
||||
v-tooltip="$gettext('License')"
|
||||
:title="photo.Details.License"
|
||||
prepend-icon="mdi-certificate"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.Subject"
|
||||
v-tooltip="$gettext('Subject')"
|
||||
:title="photo.Details.Subject"
|
||||
prepend-icon="mdi-tag"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.Keywords"
|
||||
v-tooltip="$gettext('Keywords')"
|
||||
:title="photo.Details.Keywords"
|
||||
prepend-icon="mdi-tag-multiple"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="photo.Details && photo.Details.Notes"
|
||||
v-tooltip="$gettext('Notes')"
|
||||
:title="photo.Details.Notes"
|
||||
prepend-icon="mdi-note-text"
|
||||
class="metadata__item"
|
||||
>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,8 +114,10 @@
|
|||
<script>
|
||||
import { DateTime } from "luxon";
|
||||
import * as formats from "options/formats";
|
||||
import $util from "common/util";
|
||||
|
||||
import PMap from "component/map.vue";
|
||||
import Photo from "model/photo";
|
||||
|
||||
export default {
|
||||
name: "PSidebarInfo",
|
||||
|
|
@ -89,17 +143,135 @@ export default {
|
|||
return {
|
||||
actions: [],
|
||||
featPlaces: this.$config.feature("places"),
|
||||
photo: null,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
model() {
|
||||
return this.modelValue;
|
||||
},
|
||||
typeInfo() {
|
||||
if (typeof this.model.getTypeInfo === "function") {
|
||||
return this.model.getTypeInfo();
|
||||
}
|
||||
return "";
|
||||
},
|
||||
typeIcon() {
|
||||
if (typeof this.model.getTypeIcon === "function") {
|
||||
return this.model.getTypeIcon();
|
||||
}
|
||||
return "mdi-image";
|
||||
},
|
||||
latLng() {
|
||||
if (typeof this.model.getLatLng === "function") {
|
||||
return this.model.getLatLng();
|
||||
}
|
||||
if (this.model.Lat && this.model.Lng) {
|
||||
return `${this.model.Lat.toFixed(5)}°N\u2003${this.model.Lng.toFixed(5)}°E`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
hasCamera() {
|
||||
if (!this.photo) return false;
|
||||
return this.photo.CameraID > 1 || this.photo.CameraMake || this.photo.CameraModel || this.photo.Iso || this.photo.Exposure;
|
||||
},
|
||||
cameraName() {
|
||||
if (!this.photo) return "";
|
||||
if (this.photo.CameraMake && this.photo.CameraModel) {
|
||||
return `${this.photo.CameraMake} ${this.photo.CameraModel}`;
|
||||
} else if (this.photo.Camera && this.photo.Camera.Make && this.photo.Camera.Model) {
|
||||
return `${this.photo.Camera.Make} ${this.photo.Camera.Model}`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
lensInfo() {
|
||||
if (!this.photo) return "";
|
||||
const parts = [];
|
||||
if (this.photo.LensModel) {
|
||||
parts.push(this.photo.LensModel.replace("f/", "ƒ/"));
|
||||
} else if (this.photo.Lens && this.photo.Lens.Model) {
|
||||
parts.push(this.photo.Lens.Model.replace("f/", "ƒ/"));
|
||||
}
|
||||
if (this.photo.FocalLength && !parts.some((p) => p.includes("mm"))) {
|
||||
parts.push(`${this.photo.FocalLength}mm`);
|
||||
}
|
||||
if (this.photo.FNumber && !parts.some((p) => p.includes("ƒ/"))) {
|
||||
parts.push(`ƒ/${this.photo.FNumber}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
},
|
||||
exposureInfo() {
|
||||
if (!this.photo) return "";
|
||||
const parts = [];
|
||||
if (this.photo.Iso) {
|
||||
parts.push(`ISO ${this.photo.Iso}`);
|
||||
}
|
||||
if (this.photo.FNumber) {
|
||||
parts.push(`ƒ/${this.photo.FNumber}`);
|
||||
}
|
||||
if (this.photo.Exposure) {
|
||||
parts.push(this.photo.Exposure);
|
||||
}
|
||||
if (this.photo.FocalLength) {
|
||||
parts.push(`${this.photo.FocalLength}mm`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
},
|
||||
locationLabel() {
|
||||
if (this.photo) {
|
||||
if (this.photo.PlaceLabel) {
|
||||
return this.photo.PlaceLabel;
|
||||
}
|
||||
if (this.photo.Place && this.photo.Place.Label) {
|
||||
return this.photo.Place.Label;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
},
|
||||
hasDetails() {
|
||||
if (!this.photo) return false;
|
||||
const d = this.photo.Details;
|
||||
return d && (d.Artist || d.Copyright || d.License || d.Subject || d.Keywords || d.Notes);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"model.UID": {
|
||||
immediate: true,
|
||||
handler(uid) {
|
||||
if (uid) {
|
||||
this.loadPhoto(uid);
|
||||
} else {
|
||||
this.photo = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit("close");
|
||||
},
|
||||
copyLatLng() {
|
||||
if (typeof this.model.copyLatLng === "function") {
|
||||
this.model.copyLatLng();
|
||||
} else if (this.model.Lat && this.model.Lng) {
|
||||
$util.copyText(`${this.model.Lat.toString()},${this.model.Lng.toString()}`);
|
||||
}
|
||||
},
|
||||
loadPhoto(uid) {
|
||||
if (!uid || this.loading) return;
|
||||
this.loading = true;
|
||||
new Photo()
|
||||
.find(uid)
|
||||
.then((p) => {
|
||||
this.photo = p;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.photo = null;
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
formatTime(model) {
|
||||
if (!model || !model.TakenAtLocal) {
|
||||
return this.$gettext("Unknown");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import PSidebarInfo from "component/sidebar/info.vue";
|
||||
import * as contexts from "options/contexts";
|
||||
import { DateTime } from "luxon";
|
||||
|
|
@ -19,6 +19,23 @@ vi.mock("options/formats", () => ({
|
|||
DATETIME_MED_TZ: "DATETIME_MED_TZ",
|
||||
}));
|
||||
|
||||
// Mock Photo model
|
||||
const mockPhotoFind = vi.fn();
|
||||
vi.mock("model/photo", () => ({
|
||||
default: class Photo {
|
||||
find(uid) {
|
||||
return mockPhotoFind(uid);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock $util
|
||||
vi.mock("common/util", () => ({
|
||||
default: {
|
||||
copyText: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PSidebarInfo component", () => {
|
||||
let wrapper;
|
||||
let originalFromISO;
|
||||
|
|
@ -31,12 +48,34 @@ describe("PSidebarInfo component", () => {
|
|||
TimeZone: "UTC",
|
||||
Lat: 52.52,
|
||||
Lng: 13.405,
|
||||
Altitude: 100,
|
||||
getTypeInfo: vi.fn().mockReturnValue("JPEG, 1920x1080"),
|
||||
getTypeIcon: vi.fn().mockReturnValue("mdi-file-image"),
|
||||
getLatLng: vi.fn().mockReturnValue("52.5200, 13.4050"),
|
||||
copyLatLng: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPhotoData = {
|
||||
UID: "abc123",
|
||||
CameraID: 2,
|
||||
CameraMake: "Fujifilm",
|
||||
CameraModel: "X-T4",
|
||||
LensModel: "XF 35mm f/1.4 R",
|
||||
Iso: 400,
|
||||
FNumber: 1.4,
|
||||
Exposure: "1/250",
|
||||
FocalLength: 35,
|
||||
PlaceLabel: "Berlin, Germany",
|
||||
Details: {
|
||||
Artist: "John Doe",
|
||||
Copyright: "2023 John Doe",
|
||||
License: "CC BY-NC 4.0",
|
||||
Subject: "Street Photography",
|
||||
Keywords: "street, urban, city",
|
||||
Notes: "Test notes",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
|
@ -50,6 +89,9 @@ describe("PSidebarInfo component", () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock Photo.find() to resolve with photo data
|
||||
mockPhotoFind.mockResolvedValue(mockPhotoData);
|
||||
|
||||
wrapper = mount(PSidebarInfo, {
|
||||
props: {
|
||||
modelValue: mockModel,
|
||||
|
|
@ -126,4 +168,131 @@ describe("PSidebarInfo component", () => {
|
|||
const formattedTime = wrapper.vm.formatTime(modelWithoutTime);
|
||||
expect(formattedTime).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("should fetch full photo data when model UID is present", async () => {
|
||||
await flushPromises();
|
||||
expect(mockPhotoFind).toHaveBeenCalledWith("abc123");
|
||||
expect(wrapper.vm.photo).toEqual(mockPhotoData);
|
||||
});
|
||||
|
||||
it("should display camera name when photo data is loaded", async () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.hasCamera).toBe(true);
|
||||
expect(wrapper.vm.cameraName).toBe("Fujifilm X-T4");
|
||||
});
|
||||
|
||||
it("should display lens info when photo data is loaded", async () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.lensInfo).toContain("XF 35mm");
|
||||
});
|
||||
|
||||
it("should display exposure info when photo data is loaded", async () => {
|
||||
await flushPromises();
|
||||
const exposureInfo = wrapper.vm.exposureInfo;
|
||||
expect(exposureInfo).toContain("ISO 400");
|
||||
expect(exposureInfo).toContain("ƒ/1.4");
|
||||
expect(exposureInfo).toContain("1/250");
|
||||
expect(exposureInfo).toContain("35mm");
|
||||
});
|
||||
|
||||
it("should display location label when photo data is loaded", async () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.locationLabel).toBe("Berlin, Germany");
|
||||
});
|
||||
|
||||
it("should detect when photo has details", async () => {
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.hasDetails).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not show camera section when photo has no camera data", async () => {
|
||||
mockPhotoFind.mockResolvedValue({
|
||||
UID: "abc123",
|
||||
CameraID: 1,
|
||||
Details: {},
|
||||
});
|
||||
|
||||
const modelNoCam = { ...mockModel, UID: "nocam123" };
|
||||
const wrapperNoCamera = mount(PSidebarInfo, {
|
||||
props: {
|
||||
modelValue: modelNoCam,
|
||||
context: contexts.Photos,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PMap: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapperNoCamera.vm.hasCamera).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not show details section when photo has no details", async () => {
|
||||
mockPhotoFind.mockResolvedValue({
|
||||
UID: "abc123",
|
||||
CameraID: 1,
|
||||
Details: {},
|
||||
});
|
||||
|
||||
const modelNoDetails = { ...mockModel, UID: "nodetails123" };
|
||||
const wrapperNoDetails = mount(PSidebarInfo, {
|
||||
props: {
|
||||
modelValue: modelNoDetails,
|
||||
context: contexts.Photos,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PMap: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapperNoDetails.vm.hasDetails).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should handle Photo.find() rejection gracefully", async () => {
|
||||
mockPhotoFind.mockRejectedValue(new Error("API error"));
|
||||
|
||||
const wrapperError = mount(PSidebarInfo, {
|
||||
props: {
|
||||
modelValue: mockModel,
|
||||
context: contexts.Photos,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PMap: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapperError.vm.photo).toBeNull();
|
||||
expect(wrapperError.vm.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("should use fallback latLng when model has no getLatLng method", () => {
|
||||
const modelWithoutMethods = {
|
||||
UID: "xyz789",
|
||||
Lat: 48.8566,
|
||||
Lng: 2.3522,
|
||||
};
|
||||
|
||||
const wrapperFallback = mount(PSidebarInfo, {
|
||||
props: {
|
||||
modelValue: modelWithoutMethods,
|
||||
context: contexts.Photos,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PMap: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapperFallback.vm.latLng).toContain("48.85660");
|
||||
expect(wrapperFallback.vm.latLng).toContain("2.35220");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue