mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-02 15:50:27 +00:00
Merge d76505bb27
into de4081d0d2
This commit is contained in:
commit
02a80a6d25
8 changed files with 223 additions and 22 deletions
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="z-[999]">
|
||||
<div v-if="modal" class="z-[999]">
|
||||
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
|
||||
<div class="modal modal-bottom sm:modal-middle overflow-visible">
|
||||
<div class="modal-box overflow-visible relative">
|
||||
|
|
|
@ -41,16 +41,10 @@
|
|||
},
|
||||
});
|
||||
|
||||
const input = ref<HTMLElement | null>(null);
|
||||
|
||||
whenever(
|
||||
() => props.triggerFocus,
|
||||
() => {
|
||||
if (input.value) {
|
||||
input.value.focus();
|
||||
}
|
||||
}
|
||||
);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
whenever(input, () => {
|
||||
input.value!.focus();
|
||||
});
|
||||
|
||||
const value = useVModel(props, "modelValue");
|
||||
</script>
|
||||
|
|
34
frontend/components/QuickSearch.vue
Normal file
34
frontend/components/QuickSearch.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<BaseModal v-model="isActive">
|
||||
<template #title> Quick Search </template>
|
||||
<div class="flex flex-wrap md:flex-nowrap gap-4 items-end">
|
||||
<div class="w-full">
|
||||
<FormTextField v-model="query" placeholder="Search" trigger-focus @keyup.prevent.enter="quickSearch" />
|
||||
</div>
|
||||
<BaseButton class="btn-block md:w-auto" @click.prevent="quickSearch">
|
||||
<template #icon>
|
||||
<Icon name="mdi-search" />
|
||||
</template>
|
||||
Search
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const query = ref("");
|
||||
const { isActive } = useQuickSearch();
|
||||
const router = useRouter();
|
||||
|
||||
function quickSearch() {
|
||||
router.push({
|
||||
path: "/items",
|
||||
query: {
|
||||
q: query.value,
|
||||
},
|
||||
});
|
||||
|
||||
isActive.value = false;
|
||||
query.value = "";
|
||||
}
|
||||
</script>
|
135
frontend/composables/use-quick-search.ts
Normal file
135
frontend/composables/use-quick-search.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { getRealFormat, isValid } from "gtin";
|
||||
|
||||
// Convenient representations of gtin's format values.
|
||||
enum CodeFormat {
|
||||
EAN13 = "GTIN-13", // 13-digit EAN
|
||||
UPCA = "GTIN-12", // 12-digit UPC-A
|
||||
}
|
||||
|
||||
function isSearchShortcut(event: KeyboardEvent): boolean {
|
||||
return (event.metaKey || event.ctrlKey) && event.key === "f";
|
||||
}
|
||||
|
||||
/**
|
||||
* For our purposes, a valid barcode fragment is any digit, with no
|
||||
* standard modifiers enabled (shift, ctrl, meta):
|
||||
*/
|
||||
function isCodeFragment(event: KeyboardEvent): boolean {
|
||||
return !Number.isNaN(parseInt(event.key)) && !event.ctrlKey && !event.metaKey && !event.shiftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a check to ensure something is a EAN13 or UPC-A code.
|
||||
*/
|
||||
function isValidCode(code: string): boolean {
|
||||
try {
|
||||
// We check the format in advance, since we're only accepting EAN13 and UPC-A here.
|
||||
// There's no -real- reason for this limitation, besides protecting against feature
|
||||
// creep from supporting a larger number of formats.
|
||||
const realFormat = getRealFormat(code);
|
||||
|
||||
return realFormat === CodeFormat.EAN13 || (realFormat === CodeFormat.UPCA && isValid(code));
|
||||
} catch (err) {
|
||||
// gtin.getRealFormat will throw on codes that are absolutely invalid,
|
||||
// as opposed to returning false for codes that look correct but fail
|
||||
// a format-specific check.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore code input if the user is actually trying to write anywhere:
|
||||
*/
|
||||
function isEventTargetInput(event: KeyboardEvent) {
|
||||
const tagName = (event.target as HTMLElement).tagName;
|
||||
return tagName != null && tagName.toUpperCase() === "INPUT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides utilities to:
|
||||
*
|
||||
* - Quickly open a global search utility, that forwards to the item page.
|
||||
* - Transparently listens for UPC/EAN-like barcode input, and automatically
|
||||
* generates an appropriate search query for valid inputs; this allows consumer
|
||||
* USB barcode scanners to be used as a quick shortcut.
|
||||
*/
|
||||
export function useQuickSearch() {
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* Tracks if the quick search dialog is active.
|
||||
*
|
||||
* We 'steal' cmd/ctrl+F from the user, but allow them to go back to
|
||||
* the browser-default search by tapping the shortcut twice.
|
||||
*/
|
||||
const isActive = ref(false);
|
||||
|
||||
/**
|
||||
* codeBuffer acts as intermediate buffer state for a partial
|
||||
* EAN/UPC code.
|
||||
*/
|
||||
const codeBuffer = ref("");
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (isSearchShortcut(event)) {
|
||||
// If quick search is already active, and the user taps cmd+F, get it out of the way
|
||||
// and allow the default browser search to kick in:
|
||||
if (isActive.value) {
|
||||
isActive.value = false;
|
||||
} else {
|
||||
isActive.value = true;
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (isCodeFragment(event) && !isEventTargetInput(event)) {
|
||||
const fragment = event.key;
|
||||
|
||||
// Push this code fragment into our buffer. At this point we also
|
||||
// ensure we have no more than 13 numbers in the buffer, and if that
|
||||
// is the case, we clear it ahead of starting what we assume to be
|
||||
// a new code:
|
||||
if (codeBuffer.value.length < 13) {
|
||||
codeBuffer.value = `${codeBuffer.value}${fragment}`;
|
||||
} else {
|
||||
// Reset the buffer:
|
||||
codeBuffer.value = fragment;
|
||||
}
|
||||
} else if (event.key === "Enter" && isValidCode(codeBuffer.value)) {
|
||||
// If we have an active code buffer that seems valid, and the user presses Enter,
|
||||
// we want to generate a new search query from this code, as long as it seems valid.
|
||||
//
|
||||
// Consumer/most(?) USB barcode scanners will terminate valid codes with an Enter key press,
|
||||
// which is what we're reacting to here.
|
||||
const validCode = codeBuffer.value;
|
||||
|
||||
// Regardless of what we do next, we also clear the code buffer here:
|
||||
codeBuffer.value = "";
|
||||
|
||||
// TODO: Is there a good reason to not expose custom fields via search syntax?
|
||||
router.push({
|
||||
path: "/items",
|
||||
query: {
|
||||
q: "",
|
||||
fieldSelector: "true",
|
||||
// TODO: Barcode= is a temporary approach to support this behavior.
|
||||
fields: [encodeURIComponent(`Barcode=${validCode}`)],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Every other key press resets the buffer - this applies to non-code values,
|
||||
// and also to pressing the Enter key without a valid code;
|
||||
codeBuffer.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
isActive,
|
||||
};
|
||||
}
|
|
@ -9,20 +9,21 @@
|
|||
<ItemCreateModal v-model="modals.item" />
|
||||
<LabelCreateModal v-model="modals.label" />
|
||||
<LocationCreateModal v-model="modals.location" />
|
||||
<QuickSearch />
|
||||
<AppToast />
|
||||
<div class="drawer drawer-mobile">
|
||||
<input id="my-drawer-2" v-model="drawerToggle" type="checkbox" class="drawer-toggle" />
|
||||
<input id="nav-drawer" v-model="drawerToggle" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content justify-center bg-base-300 pt-20 lg:pt-0">
|
||||
<AppHeaderDecor class="-mt-10 hidden lg:block" />
|
||||
<!-- Button -->
|
||||
<div class="navbar z-[99] lg:hidden top-0 fixed bg-primary shadow-md drawer-button">
|
||||
<label for="my-drawer-2" class="btn btn-square btn-ghost text-base-100 drawer-button lg:hidden">
|
||||
<label for="nav-drawer" class="btn btn-square btn-ghost text-base-100 drawer-button lg:hidden">
|
||||
<Icon name="mdi-menu" class="h-6 w-6" />
|
||||
</label>
|
||||
<NuxtLink to="/home">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-base-100 flex">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-base-100 flex flex-row items-end">
|
||||
HomeB
|
||||
<AppLogo class="w-8 -mb-3" />
|
||||
<AppLogo class="w-8" />
|
||||
x
|
||||
</h2>
|
||||
</NuxtLink>
|
||||
|
@ -33,7 +34,7 @@
|
|||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side shadow-lg">
|
||||
<label for="my-drawer-2" class="drawer-overlay"></label>
|
||||
<label for="nav-drawer" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Top Section -->
|
||||
<div class="w-60 py-5 md:py-10 bg-base-200 flex flex-grow-1 flex-col">
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
"autoprefixer": "^10.4.8",
|
||||
"daisyui": "^2.24.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"gtin": "^1.0.2",
|
||||
"h3": "^1.7.1",
|
||||
"http-proxy": "^1.18.1",
|
||||
"markdown-it": "^14.0.0",
|
||||
|
|
|
@ -74,9 +74,17 @@
|
|||
queryParamsInitialized.value = true;
|
||||
searchLocked.value = false;
|
||||
|
||||
const qFields = route.query.fields as string[];
|
||||
if (qFields) {
|
||||
fieldTuples.value = qFields.map(f => f.split("=") as [string, string]);
|
||||
const qFields = route.query.fields as string[] | string;
|
||||
if (qFields != null) {
|
||||
// Ensure qFields are represented as an array of string, as expected:
|
||||
const qFieldsAsArray = Array.isArray(qFields) ? qFields : [qFields];
|
||||
const parsedFieldTuples = qFieldsAsArray.map(f => decodeURIComponent(f).split("=") as [string, string]);
|
||||
|
||||
// After loading for the first time, we keep track of the field tuples at this point,
|
||||
// and use them as possible field values even if they're not included in the list of values for
|
||||
// the given field:
|
||||
fieldTuples.value = parsedFieldTuples;
|
||||
fieldTuplesOnMount.value = parsedFieldTuples;
|
||||
|
||||
for (const t of fieldTuples.value) {
|
||||
if (t[0] && t[1]) {
|
||||
|
@ -139,6 +147,7 @@
|
|||
});
|
||||
|
||||
const fieldTuples = ref<[string, string][]>([]);
|
||||
const fieldTuplesOnMount = ref<[string, string][]>([]);
|
||||
const fieldValuesCache = ref<Record<string, string[]>>({});
|
||||
|
||||
const { data: allFields } = useAsyncData(async () => {
|
||||
|
@ -173,6 +182,22 @@
|
|||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary approach to arbitrary field values, which merges search query input
|
||||
* into the values cache for a given field.
|
||||
*
|
||||
* Since this relies on `onMounted`, and due to how search works, it's less than
|
||||
* ideal, and supremely confusing when a value you saw 2 seconds ago suddenly disappears.
|
||||
*/
|
||||
function fieldValuesFromCache(field: string) {
|
||||
const fieldValues = fieldValuesCache.value[field] ?? [];
|
||||
const valuesFromFirstMount = fieldTuplesOnMount.value
|
||||
.filter(([fieldName]) => fieldName === field)
|
||||
.map(([_, fieldValue]) => fieldValue);
|
||||
|
||||
return [...new Set([...valuesFromFirstMount, ...fieldValues])];
|
||||
}
|
||||
|
||||
watch(advanced, (v, lv) => {
|
||||
if (v === false && lv === true) {
|
||||
selectedLocations.value = [];
|
||||
|
@ -245,7 +270,10 @@
|
|||
initialSearch.value = false;
|
||||
}
|
||||
|
||||
watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], search, { debounce: 250, maxWait: 1000 });
|
||||
watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], search, {
|
||||
debounce: 250,
|
||||
maxWait: 1000,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
// Set URL Params
|
||||
|
@ -399,8 +427,8 @@
|
|||
<label class="label">
|
||||
<span class="label-text">Field Value</span>
|
||||
</label>
|
||||
<select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesCache[f[0]]">
|
||||
<option v-for="v in fieldValuesCache[f[0]]" :key="v" :value="v">{{ v }}</option>
|
||||
<select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesFromCache(f[0])">
|
||||
<option v-for="v in fieldValuesFromCache(f[0])" :key="v" :value="v">{{ v }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
|
|
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
|
@ -41,6 +41,9 @@ dependencies:
|
|||
dompurify:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
gtin:
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2
|
||||
h3:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
|
@ -5758,6 +5761,11 @@ packages:
|
|||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||
dev: true
|
||||
|
||||
/gtin@1.0.2:
|
||||
resolution: {integrity: sha512-jEsHMz16c3yz0rlM4TvUUU0022FTniIAcBfCDoch+38RJC32yGkdKFC9ixpBqPskYpCRrb614AjF8O0QQP0gPg==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/gzip-size@7.0.0:
|
||||
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue