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>
|
<template>
|
||||||
<div class="z-[999]">
|
<div v-if="modal" class="z-[999]">
|
||||||
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
|
<input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
|
||||||
<div class="modal modal-bottom sm:modal-middle overflow-visible">
|
<div class="modal modal-bottom sm:modal-middle overflow-visible">
|
||||||
<div class="modal-box overflow-visible relative">
|
<div class="modal-box overflow-visible relative">
|
||||||
|
|
|
@ -41,16 +41,10 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = ref<HTMLElement | null>(null);
|
const input = ref<HTMLInputElement | null>(null);
|
||||||
|
whenever(input, () => {
|
||||||
whenever(
|
input.value!.focus();
|
||||||
() => props.triggerFocus,
|
});
|
||||||
() => {
|
|
||||||
if (input.value) {
|
|
||||||
input.value.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useVModel(props, "modelValue");
|
const value = useVModel(props, "modelValue");
|
||||||
</script>
|
</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" />
|
<ItemCreateModal v-model="modals.item" />
|
||||||
<LabelCreateModal v-model="modals.label" />
|
<LabelCreateModal v-model="modals.label" />
|
||||||
<LocationCreateModal v-model="modals.location" />
|
<LocationCreateModal v-model="modals.location" />
|
||||||
|
<QuickSearch />
|
||||||
<AppToast />
|
<AppToast />
|
||||||
<div class="drawer drawer-mobile">
|
<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">
|
<div class="drawer-content justify-center bg-base-300 pt-20 lg:pt-0">
|
||||||
<AppHeaderDecor class="-mt-10 hidden lg:block" />
|
<AppHeaderDecor class="-mt-10 hidden lg:block" />
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
<div class="navbar z-[99] lg:hidden top-0 fixed bg-primary shadow-md drawer-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" />
|
<Icon name="mdi-menu" class="h-6 w-6" />
|
||||||
</label>
|
</label>
|
||||||
<NuxtLink to="/home">
|
<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
|
HomeB
|
||||||
<AppLogo class="w-8 -mb-3" />
|
<AppLogo class="w-8" />
|
||||||
x
|
x
|
||||||
</h2>
|
</h2>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
@ -33,7 +34,7 @@
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="drawer-side shadow-lg">
|
<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 -->
|
<!-- Top Section -->
|
||||||
<div class="w-60 py-5 md:py-10 bg-base-200 flex flex-grow-1 flex-col">
|
<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",
|
"autoprefixer": "^10.4.8",
|
||||||
"daisyui": "^2.24.0",
|
"daisyui": "^2.24.0",
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
|
"gtin": "^1.0.2",
|
||||||
"h3": "^1.7.1",
|
"h3": "^1.7.1",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
|
|
|
@ -74,9 +74,17 @@
|
||||||
queryParamsInitialized.value = true;
|
queryParamsInitialized.value = true;
|
||||||
searchLocked.value = false;
|
searchLocked.value = false;
|
||||||
|
|
||||||
const qFields = route.query.fields as string[];
|
const qFields = route.query.fields as string[] | string;
|
||||||
if (qFields) {
|
if (qFields != null) {
|
||||||
fieldTuples.value = qFields.map(f => f.split("=") as [string, string]);
|
// 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) {
|
for (const t of fieldTuples.value) {
|
||||||
if (t[0] && t[1]) {
|
if (t[0] && t[1]) {
|
||||||
|
@ -139,6 +147,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const fieldTuples = ref<[string, string][]>([]);
|
const fieldTuples = ref<[string, string][]>([]);
|
||||||
|
const fieldTuplesOnMount = ref<[string, string][]>([]);
|
||||||
const fieldValuesCache = ref<Record<string, string[]>>({});
|
const fieldValuesCache = ref<Record<string, string[]>>({});
|
||||||
|
|
||||||
const { data: allFields } = useAsyncData(async () => {
|
const { data: allFields } = useAsyncData(async () => {
|
||||||
|
@ -173,6 +182,22 @@
|
||||||
return data;
|
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) => {
|
watch(advanced, (v, lv) => {
|
||||||
if (v === false && lv === true) {
|
if (v === false && lv === true) {
|
||||||
selectedLocations.value = [];
|
selectedLocations.value = [];
|
||||||
|
@ -245,7 +270,10 @@
|
||||||
initialSearch.value = false;
|
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() {
|
async function submit() {
|
||||||
// Set URL Params
|
// Set URL Params
|
||||||
|
@ -399,8 +427,8 @@
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Field Value</span>
|
<span class="label-text">Field Value</span>
|
||||||
</label>
|
</label>
|
||||||
<select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesCache[f[0]]">
|
<select v-model="fieldTuples[idx][1]" class="select-bordered select" :items="fieldValuesFromCache(f[0])">
|
||||||
<option v-for="v in fieldValuesCache[f[0]]" :key="v" :value="v">{{ v }}</option>
|
<option v-for="v in fieldValuesFromCache(f[0])" :key="v" :value="v">{{ v }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
|
@ -41,6 +41,9 @@ dependencies:
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
gtin:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
h3:
|
h3:
|
||||||
specifier: ^1.7.1
|
specifier: ^1.7.1
|
||||||
version: 1.7.1
|
version: 1.7.1
|
||||||
|
@ -5758,6 +5761,11 @@ packages:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/gtin@1.0.2:
|
||||||
|
resolution: {integrity: sha512-jEsHMz16c3yz0rlM4TvUUU0022FTniIAcBfCDoch+38RJC32yGkdKFC9ixpBqPskYpCRrb614AjF8O0QQP0gPg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/gzip-size@7.0.0:
|
/gzip-size@7.0.0:
|
||||||
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
|
resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue