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
0ab29f443d
commit
648dbf466f
5 changed files with 150 additions and 30 deletions
|
|
@ -1,6 +1,6 @@
|
|||
PhotoPrism — Frontend CODEMAP
|
||||
|
||||
**Last Updated:** November 12, 2025
|
||||
**Last Updated:** November 17, 2025
|
||||
|
||||
Purpose
|
||||
- Help agents and contributors navigate the Vue 3 + Vuetify 3 app quickly and make safe changes.
|
||||
|
|
@ -43,6 +43,12 @@ Runtime & Plugins
|
|||
- PWA: Workbox registers a service worker after config load (see `src/app.js`); scope and registration URL derive from `$config.baseUri` so non-root deployments work. Workbox precache rules live in `frontend/webpack.config.js` (see the `GenerateSW` plugin); locale chunks and non-woff2 font variants are excluded there so we don’t force every user to download those assets on first visit.
|
||||
- WebSocket: `src/common/websocket.js` publishes `websocket.*` events, used by `$session` for client info
|
||||
|
||||
Lightbox Integration
|
||||
- Shared entry points live in `src/common/lightbox.js`; `$lightbox.open(options)` fires a `lightbox.open` event consumed by `component/lightbox.vue`.
|
||||
- Prefer `$lightbox.openView(this, index)` when a component or dialog already has the photos in memory. Implement `getLightboxContext(index)` on the view and return `{ models, index, context, allowEdit?, allowSelect? }` so the lightbox can build slides without requerying.
|
||||
- Set `allowEdit: false` when the caller shouldn’t expose inline editing (the edit button and `KeyE` shortcut are disabled automatically). Set `allowSelect: false` to hide the selection toggle and block the `.` shortcut so batch-edit dialogs don’t mutate the global clipboard.
|
||||
- Legacy `$lightbox.openModels(models, index, collection)` still accepts raw thumb arrays, but it cannot express the context flags—only use it when you truly don’t have a backing view.
|
||||
|
||||
HTTP Client
|
||||
- Axios instance: `src/common/api.js`
|
||||
- Base URL: `window.__CONFIG__.apiUri` (or `/api/v1` in tests)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ export class Lightbox {
|
|||
$event.publish("lightbox.open", options);
|
||||
}
|
||||
|
||||
openModels(models, index, collection, isBatchDialog) {
|
||||
$event.publish("lightbox.open", { models, index, collection, isBatchDialog });
|
||||
openModels(models, index, collection) {
|
||||
$event.publish("lightbox.open", { models, index, collection });
|
||||
}
|
||||
|
||||
openView(view, index) {
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@ export default {
|
|||
model: new Thumb(), // Current slide.
|
||||
models: [], // Slide models.
|
||||
index: 0, // Current slide index in models.
|
||||
isBatchDialog: false,
|
||||
contextAllowsEdit: true,
|
||||
contextAllowsSelect: true,
|
||||
subscriptions: [], // Event subscriptions.
|
||||
// Video properties for rendering the controls.
|
||||
video: {
|
||||
|
|
@ -265,8 +266,6 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
this.isBatchDialog = !!data.isBatchDialog;
|
||||
|
||||
if (data.view) {
|
||||
this.showView(data.view, data.index);
|
||||
} else {
|
||||
|
|
@ -422,6 +421,9 @@ export default {
|
|||
return Promise.reject();
|
||||
}
|
||||
|
||||
this.contextAllowsEdit = ctx?.allowEdit !== false;
|
||||
this.contextAllowsSelect = ctx?.allowSelect !== false;
|
||||
|
||||
// Check if at least one model was passed, as otherwise no content can be displayed.
|
||||
if (!Array.isArray(models) || models.length === 0 || index >= models.length) {
|
||||
this.log("model list passed to lightbox is empty:", models);
|
||||
|
|
@ -450,7 +452,29 @@ export default {
|
|||
return Promise.reject();
|
||||
}
|
||||
|
||||
if (view.loading || !view.listen || view.lightbox.loading || !view.results[index]) {
|
||||
if (view && typeof view.getLightboxContext === "function") {
|
||||
const ctx = view.getLightboxContext(index);
|
||||
|
||||
if (!ctx || !Array.isArray(ctx.models) || ctx.models.length === 0) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const targetIndex = this.normalizeIndex(
|
||||
typeof ctx.index === "number" ? ctx.index : typeof index === "number" ? index : 0,
|
||||
ctx.models.length
|
||||
);
|
||||
|
||||
return this.showThumbs(ctx.models, targetIndex, ctx);
|
||||
}
|
||||
|
||||
if (
|
||||
!view ||
|
||||
view.loading ||
|
||||
!view.listen ||
|
||||
view.lightbox?.loading ||
|
||||
!Array.isArray(view.results) ||
|
||||
!view.results[index]
|
||||
) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
|
|
@ -529,6 +553,28 @@ export default {
|
|||
view.lightbox.loading = false;
|
||||
});
|
||||
},
|
||||
// Keeps the requested slide index within the available bounds before opening the lightbox.
|
||||
normalizeIndex(idx, length) {
|
||||
let target = Number.isFinite(idx) ? idx : 0;
|
||||
|
||||
if (target < 0) {
|
||||
target = 0;
|
||||
}
|
||||
|
||||
const maxIndex = Math.max(length - 1, 0);
|
||||
|
||||
if (target > maxIndex) {
|
||||
target = maxIndex;
|
||||
}
|
||||
|
||||
return target;
|
||||
},
|
||||
shouldShowEditButton() {
|
||||
return this.canEdit && this.contextAllowsEdit;
|
||||
},
|
||||
shouldShowSelectionToggle() {
|
||||
return this.contextAllowsSelect;
|
||||
},
|
||||
getNumItems() {
|
||||
return this.models.length;
|
||||
},
|
||||
|
|
@ -1296,23 +1342,25 @@ export default {
|
|||
}
|
||||
|
||||
// Add selection toggle control.
|
||||
lightbox.pswp.ui.registerElement({
|
||||
name: "select-toggle",
|
||||
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
|
||||
title: this.$gettext("Select"),
|
||||
ariaLabel: this.$gettext("Select"),
|
||||
order: 10,
|
||||
isButton: true,
|
||||
html: {
|
||||
isCustomSVG: true,
|
||||
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
|
||||
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
|
||||
},
|
||||
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
|
||||
});
|
||||
if (this.shouldShowSelectionToggle()) {
|
||||
lightbox.pswp.ui.registerElement({
|
||||
name: "select-toggle",
|
||||
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
|
||||
title: this.$gettext("Select"),
|
||||
ariaLabel: this.$gettext("Select"),
|
||||
order: 10,
|
||||
isButton: true,
|
||||
html: {
|
||||
isCustomSVG: true,
|
||||
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
|
||||
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
|
||||
},
|
||||
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
|
||||
});
|
||||
}
|
||||
|
||||
// Add edit button control if user has permission to use it.
|
||||
if (this.canEdit && !this.isBatchDialog) {
|
||||
if (this.shouldShowEditButton()) {
|
||||
lightbox.pswp.ui.registerElement({
|
||||
name: "edit-button",
|
||||
className: "pswp__button--edit-button pswp__button--mdi hidden-shared-only", // Sets the icon style/size in lightbox.css.
|
||||
|
|
@ -1536,7 +1584,8 @@ export default {
|
|||
onReset() {
|
||||
this.resetControls();
|
||||
this.resetModels();
|
||||
this.isBatchDialog = false;
|
||||
this.contextAllowsEdit = true;
|
||||
this.contextAllowsSelect = true;
|
||||
},
|
||||
// Resets the state of the lightbox controls.
|
||||
resetControls() {
|
||||
|
|
@ -1847,6 +1896,9 @@ export default {
|
|||
},
|
||||
// Toggles the selection of the current picture in the global photo clipboard.
|
||||
toggleSelect() {
|
||||
if (!this.contextAllowsSelect) {
|
||||
return;
|
||||
}
|
||||
this.$clipboard.toggle(this.model);
|
||||
},
|
||||
// Returns the active HTMLMediaElement element in the lightbox, if any.
|
||||
|
|
@ -1956,6 +2008,9 @@ export default {
|
|||
this.close();
|
||||
return true;
|
||||
case "Period":
|
||||
if (!this.contextAllowsSelect) {
|
||||
return false;
|
||||
}
|
||||
this.onShowMenu();
|
||||
this.toggleSelect();
|
||||
return true;
|
||||
|
|
@ -1974,7 +2029,7 @@ export default {
|
|||
}
|
||||
return true;
|
||||
case "KeyE":
|
||||
if (this.canEdit) {
|
||||
if (this.canEdit && this.contextAllowsEdit) {
|
||||
this.onEdit();
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -548,6 +548,8 @@ import PLocationInput from "component/location/input.vue";
|
|||
import PInputChipSelector from "component/input/chip-selector.vue";
|
||||
import $util from "common/util";
|
||||
|
||||
// TODO: Handle cases where users have more than 10000 albums and/or labels.
|
||||
const MaxResults = 10000;
|
||||
const iconClear = "mdi-close-circle";
|
||||
const iconUndo = "mdi-undo";
|
||||
|
||||
|
|
@ -593,7 +595,6 @@ export default {
|
|||
selectionsFullInfo: [],
|
||||
selectedPhotosLength: 0,
|
||||
expanded: [0],
|
||||
isBatchDialog: true,
|
||||
isAllSelected: true,
|
||||
allSelectedLength: 0,
|
||||
options,
|
||||
|
|
@ -1239,7 +1240,30 @@ export default {
|
|||
}
|
||||
},
|
||||
openPhoto(index) {
|
||||
this.$lightbox.openModels(Thumb.fromPhotos([this.model.models[index]]), 0, null, this.isBatchDialog);
|
||||
const targetIndex = typeof index === "number" ? index : 0;
|
||||
this.$lightbox.openView(this, targetIndex);
|
||||
},
|
||||
getLightboxContext(index = 0) {
|
||||
const photos = Array.isArray(this.model?.models) ? this.model.models : [];
|
||||
|
||||
if (!photos.length) {
|
||||
return { models: [], index: 0, allowEdit: false };
|
||||
}
|
||||
|
||||
const thumbs = Thumb.fromPhotos(photos);
|
||||
let targetIndex = typeof index === "number" ? index : 0;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= thumbs.length) {
|
||||
targetIndex = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
models: thumbs,
|
||||
index: targetIndex,
|
||||
context: this.$gettext("Batch Edit"),
|
||||
allowEdit: false,
|
||||
allowSelect: false,
|
||||
};
|
||||
},
|
||||
isSelected(model) {
|
||||
return this.model.isSelected(model.UID);
|
||||
|
|
@ -1468,8 +1492,8 @@ export default {
|
|||
this.loading = true;
|
||||
|
||||
const [albumsResponse, labelsResponse] = await Promise.all([
|
||||
Album.search({ count: 1000, type: "album", order: "name" }),
|
||||
Label.search({ count: 1000, order: "name", all: true }),
|
||||
Album.search({ count: MaxResults, type: "album", order: "name" }),
|
||||
Label.search({ count: MaxResults, order: "name", all: true }),
|
||||
]);
|
||||
|
||||
this.availableAlbumOptions = (albumsResponse.models || []).map((album) => ({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { shallowMount } from "@vue/test-utils";
|
|||
import { nextTick } from "vue";
|
||||
import PPhotoBatchEdit from "component/photo/batch-edit.vue";
|
||||
import { Batch } from "model/batch-edit";
|
||||
import Thumb from "model/thumb";
|
||||
import { Deleted, Mixed } from "options/options";
|
||||
|
||||
// Mock the models and dependencies
|
||||
|
|
@ -151,7 +152,7 @@ describe("component/photo/edit/batch", () => {
|
|||
error: vi.fn(),
|
||||
},
|
||||
$lightbox: {
|
||||
openModels: vi.fn(),
|
||||
openView: vi.fn(),
|
||||
},
|
||||
$event: {
|
||||
subscribe: vi.fn(),
|
||||
|
|
@ -374,7 +375,41 @@ describe("component/photo/edit/batch", () => {
|
|||
|
||||
it("should handle photo opening", () => {
|
||||
wrapper.vm.openPhoto(0);
|
||||
expect(wrapper.vm.$lightbox.openModels).toHaveBeenCalled();
|
||||
expect(wrapper.vm.$lightbox.openView).toHaveBeenCalledWith(wrapper.vm, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lightbox context", () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.model = mockBatchInstance;
|
||||
});
|
||||
|
||||
it("should build context with thumbs and disable edit", () => {
|
||||
const thumbMock = [{ UID: "uid1" }, { UID: "uid2" }];
|
||||
const spy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue(thumbMock);
|
||||
|
||||
const ctx = wrapper.vm.getLightboxContext(1);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(mockBatchInstance.models);
|
||||
expect(ctx.models).toBe(thumbMock);
|
||||
expect(ctx.index).toBe(1);
|
||||
expect(ctx.allowEdit).toBe(false);
|
||||
expect(ctx.allowSelect).toBe(false);
|
||||
expect(ctx.context).toBe("Batch Edit");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should clamp invalid index to first photo", () => {
|
||||
const thumbMock = [{ UID: "uid1" }];
|
||||
const spy = vi.spyOn(Thumb, "fromPhotos").mockReturnValue(thumbMock);
|
||||
|
||||
const ctx = wrapper.vm.getLightboxContext(5);
|
||||
|
||||
expect(ctx.index).toBe(0);
|
||||
expect(ctx.allowSelect).toBe(false);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue