Merge branch 'gallery_filter' into 'v3'

Adds filtering to the gallery

See merge request openflexure/openflexure-microscope-server!621
This commit is contained in:
Joe Knapper 2026-06-25 09:54:17 +00:00
commit 3e38722d45
10 changed files with 387 additions and 63 deletions

View file

@ -249,6 +249,48 @@
}
}
// A button styled like text. For use in menus
.ofm-text-button {
display: block;
background: none;
border: none;
padding: 8px 10px;
cursor: pointer;
color: @global-color;
width: 100%;
text-align: left;
font-weight: 600;
}
.hook-inverse() {
.ofm-text-button{
color: @inverse-global-color;
}
}
/*
* Opaque - button-like colours
*
* The buttons are transparent. If an element overlays others it should
* Look the same as the buttons, but have no transparency.
*/
.ofm-opaque-element{
background: #f5f5f5;
}
.hook-inverse() {
.ofm-opaque-element{
background: #282626;
}
}
.ofm-opaque-highlight:hover {
background: #fff;
}
.hook-inverse() {
.ofm-opaque-highlight:hover {
background: #000;
}
}
/*
* Accordion
*/

View file

@ -267,6 +267,7 @@ export default {
#container-left {
overflow: auto;
scrollbar-gutter: stable;
background-color: rgba(180, 180, 180, 0.025);
width: 100%;
height: 100%;

View file

@ -0,0 +1,150 @@
<template>
<div ref="multiselect" class="multi-select">
<div
class="dropdown-top ofm-opaque-element ofm-opaque-highlight"
:class="{ open: isOpen }"
@click="isOpen = !isOpen"
>
<span class="dropdown-top-text">{{ title }}</span>
<span>{{ arrow }}</span>
</div>
<div v-if="isOpen" class="dropdown-menu ofm-opaque-element">
<button class="ofm-text-button ofm-opaque-highlight" @click="selectAll">Select All</button>
<button class="ofm-text-button ofm-opaque-highlight" @click="selectNone">Select None</button>
<hr class="dropdown-separator" />
<label v-for="option in options" :key="option" class="dropdown-item ofm-opaque-highlight">
<input
type="checkbox"
class="uk-checkbox"
:value="option"
:checked="modelValue.includes(option)"
@change="toggleOption(option)"
/>
{{ option }}
</label>
</div>
</div>
</template>
<script>
export default {
name: "MultiSelectDropdown",
props: {
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
title: {
type: String,
default: "Filter",
},
},
emits: ["update:modelValue"],
data() {
return {
isOpen: false,
};
},
computed: {
arrow() {
return this.isOpen ? "▲" : "▼";
},
},
mounted() {
window.addEventListener("click", this.handleClickOutside);
},
beforeUnmount() {
window.removeEventListener("click", this.handleClickOutside);
},
methods: {
handleClickOutside(event) {
if (!this.$refs.multiselect.contains(event.target)) {
this.isOpen = false;
}
},
toggleOption(value) {
const selected = [...this.modelValue];
const index = selected.indexOf(value);
if (index > -1) {
selected.splice(index, 1);
} else {
selected.push(value);
}
this.$emit("update:modelValue", selected);
},
selectAll() {
this.$emit("update:modelValue", this.options);
},
selectNone() {
this.$emit("update:modelValue", []);
},
},
};
</script>
<style lang="less" scoped>
@import url("../../assets/less/variables.less");
.multi-select {
position: relative;
width: 250px;
}
.dropdown-top {
border: 1px solid #ccc;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.dropdown-top-text {
color: @global-primary-background;
}
.dropdown-top.open {
border-radius: 4px 4px 0 0;
}
.dropdown-menu {
position: absolute;
left: 0;
right: 0;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-radius: 0 0 4px 4px;
max-height: 250px;
overflow-y: auto;
z-index: 1000;
}
.dropdown-item {
display: block;
padding: 8px 12px;
cursor: pointer;
}
.dropdown-separator {
margin: 5px;
}
</style>

View file

@ -1,10 +1,17 @@
<template>
<div ref="galleryDisplay" class="galleryDisplay uk-padding uk-padding-remove-top">
<div ref="galleryDisplay" class="gallery-display uk-padding uk-padding-remove-top">
<!-- Gallery nav bar -->
<nav class="gallery-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click">
<!-- Right side buttons -->
<div class="uk-navbar-right">
<div class="uk-grid">
<div class="gallery-button">
<multi-select-dropdown
v-model="selectedCardTypes"
:options="allCardTypes"
title="Filter Gallery"
/>
</div>
<div class="gallery-button">
<action-button
class="uk-width-1-1"
@ -25,11 +32,13 @@
thing="gallery"
action="delete_all_data"
submit-label="Delete All"
:submit-data="{ card_types: selectedCardTypes }"
:is-disabled="totalPages == 0"
:can-terminate="true"
:button-primary="false"
:modal-progress="true"
:requires-confirmation="true"
:confirmation-message="'<p>Are you sure you want to delete all gallery data from the microscope?</p><p>This is <b>irreversible</b>!</p>'"
:confirmation-message="deleteAllConfirmationMessage"
@error="modalError"
/>
</div>
@ -54,7 +63,7 @@
<div class="gallery-grid uk-grid-match" uk-grid>
<div v-if="noItems">
<h2>Nothing to show</h2>
<p>There is no captured data to show..</p>
<p>There is no captured data to show.</p>
</div>
<div v-for="itemData in paginatedItems" :key="itemData.id">
<gallery-card
@ -75,14 +84,14 @@
<script>
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
import MultiSelectDropdown from "@/components/genericComponents/multiSelectDropdown.vue";
import actionButton from "../labThingsComponents/actionButton.vue";
import galleryCard from "./galleryComponents/galleryCard.vue";
import galleryModal from "./galleryComponents/galleryViewer.vue";
import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState, storeToRefs } from "pinia";
import { watch } from "vue";
import { mapState } from "pinia";
// Export main app
export default {
@ -92,6 +101,7 @@ export default {
galleryCard,
galleryModal,
PaginateLinks,
MultiSelectDropdown,
},
emits: ["scrollTop"],
@ -103,33 +113,49 @@ export default {
osdViewer: null,
currentPage: 1,
itemsPerPage: 18,
selectedCardTypes: [],
allCardTypes: [],
};
},
computed: {
...mapState(useSettingsStore, ["baseUri", "ready"]),
filtered_items() {
return this.all_items.filter((item) => this.selectedCardTypes.includes(item.card_type));
},
noItems() {
return !this.all_items || this.all_items?.length === 0;
return !this.filtered_items || this.filtered_items?.length === 0;
},
totalPages() {
return Math.ceil((this.all_items?.length || 0) / this.itemsPerPage);
return Math.ceil((this.filtered_items?.length || 0) / this.itemsPerPage);
},
paginatedItems() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return (this.all_items || []).slice(start, start + this.itemsPerPage);
return (this.filtered_items || []).slice(start, start + this.itemsPerPage);
},
deleteAllConfirmationMessage() {
return `
<p>Are you sure you want to delete all gallery data with the following types</p>
<ul>
${this.selectedCardTypes.map((type) => `<li>${type}</li>`).join("\n")}
</ul>
<p>from the microscope?</p>
<p>This is <b>irreversible</b>!</p>
`;
},
},
watch: {
totalPages(newPageCount) {
if (this.currentPage > newPageCount) {
this.currentPage = Math.max(1, newPageCount);
} else if (this.currentPage < 1) {
this.currentPage == 1;
}
},
},
async mounted() {
const store = useSettingsStore();
const { ready } = storeToRefs(store);
this.unwatchStoreFunction = watch(ready, (isReady) => {
if (isReady) {
this.refreshGallery();
} else {
this.all_items = [];
}
});
useIntersectionObserver(
this.$refs.galleryDisplay,
([{ isIntersecting }]) => {
@ -139,6 +165,8 @@ export default {
threshold: 0.0, // Adjust as needed
},
);
this.allCardTypes = await this.readThingProperty("gallery", "card_types");
this.selectedCardTypes = this.allCardTypes;
// Update on mount (does nothing if not connected)
await this.refreshGallery();
// A global signal listener to perform a gallery refresh