feat: add barcode detection support with zxing-wasm and barcode-detector
This commit is contained in:
+3
-1
@@ -22,10 +22,12 @@
|
|||||||
"@robonen/platform": "^0.0.5",
|
"@robonen/platform": "^0.0.5",
|
||||||
"@robonen/stdlib": "^0.0.12",
|
"@robonen/stdlib": "^0.0.12",
|
||||||
"@robonen/vue": "^0.2.0",
|
"@robonen/vue": "^0.2.0",
|
||||||
|
"barcode-detector": "^3.2.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"lucide": "^1.29.0",
|
"lucide": "^1.29.0",
|
||||||
"vue": "3.6.0-rc.2",
|
"vue": "3.6.0-rc.2",
|
||||||
"vue-sync-engine": "link:../vue-sync-engine/lib"
|
"vue-sync-engine": "link:../vue-sync-engine/lib",
|
||||||
|
"zxing-wasm": "3.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@robonen/eslint": "^0.0.1",
|
"@robonen/eslint": "^0.0.1",
|
||||||
|
|||||||
Generated
+44
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@robonen/vue':
|
'@robonen/vue':
|
||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))
|
version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))
|
||||||
|
barcode-detector:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.2.1(@types/emscripten@1.41.5)
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -35,6 +38,9 @@ importers:
|
|||||||
vue-sync-engine:
|
vue-sync-engine:
|
||||||
specifier: link:../vue-sync-engine/lib
|
specifier: link:../vue-sync-engine/lib
|
||||||
version: link:../vue-sync-engine/lib
|
version: link:../vue-sync-engine/lib
|
||||||
|
zxing-wasm:
|
||||||
|
specifier: 3.1.1
|
||||||
|
version: 3.1.1(@types/emscripten@1.41.5)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@robonen/eslint':
|
'@robonen/eslint':
|
||||||
specifier: ^0.0.1
|
specifier: ^0.0.1
|
||||||
@@ -1242,6 +1248,9 @@ packages:
|
|||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1':
|
'@types/esrecurse@4.3.1':
|
||||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||||
|
|
||||||
@@ -1699,6 +1708,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
|
barcode-detector@3.2.1:
|
||||||
|
resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==}
|
||||||
|
|
||||||
baseline-browser-mapping@2.11.12:
|
baseline-browser-mapping@2.11.12:
|
||||||
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
|
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -2949,6 +2961,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0:
|
||||||
|
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tailwindcss@4.3.3:
|
tailwindcss@4.3.3:
|
||||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||||
|
|
||||||
@@ -3007,6 +3023,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
|
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
type-fest@5.8.0:
|
||||||
|
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3355,6 +3375,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zxing-wasm@3.1.1:
|
||||||
|
resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/emscripten': '>=1.39.6'
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)':
|
'@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)':
|
||||||
@@ -4513,6 +4538,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1': {}
|
'@types/esrecurse@4.3.1': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@@ -4982,6 +5009,12 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
|
|
||||||
|
barcode-detector@3.2.1(@types/emscripten@1.41.5):
|
||||||
|
dependencies:
|
||||||
|
zxing-wasm: 3.1.1(@types/emscripten@1.41.5)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/emscripten'
|
||||||
|
|
||||||
baseline-browser-mapping@2.11.12: {}
|
baseline-browser-mapping@2.11.12: {}
|
||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
@@ -6325,6 +6358,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
tailwindcss@4.3.3: {}
|
tailwindcss@4.3.3: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
@@ -6375,6 +6410,10 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@0.16.0: {}
|
type-fest@0.16.0: {}
|
||||||
|
|
||||||
|
type-fest@5.8.0:
|
||||||
|
dependencies:
|
||||||
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -6786,3 +6825,8 @@ snapshots:
|
|||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zxing-wasm@3.1.1(@types/emscripten@1.41.5):
|
||||||
|
dependencies:
|
||||||
|
'@types/emscripten': 1.41.5
|
||||||
|
type-fest: 5.8.0
|
||||||
|
|||||||
@@ -1,20 +1,57 @@
|
|||||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||||
import { useRef } from 'vue-jsx-vapor';
|
import { useRef } from 'vue-jsx-vapor';
|
||||||
|
import type { BarcodeFormat, DetectedBarcode } from 'barcode-detector/ponyfill';
|
||||||
/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */
|
// Только URL: сам wasm в бандл не попадает, он эмитится отдельным файлом и
|
||||||
export function isBarcodeScanSupported(): boolean {
|
// скачивается лишь тогда, когда до него дойдёт дело. Версия zxing-wasm в
|
||||||
return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
// package.json приколочена намертво — обвязка и .wasm обязаны быть из одного
|
||||||
}
|
// релиза, иначе модуль не инициализируется.
|
||||||
|
import wasmUrl from 'zxing-wasm/reader/zxing_reader.wasm?url';
|
||||||
const SCAN_INTERVAL_MS = 300;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили
|
* Сканер доступен везде, где есть камера. Нативный Shape Detection API —
|
||||||
* камеру. Компонент монтируется только после isBarcodeScanSupported().
|
* только в Chromium; в WebKit (Safari и любой браузер на iOS, включая Chrome)
|
||||||
|
* BarcodeDetector не реализован, там работает wasm-сборка zxing.
|
||||||
|
*/
|
||||||
|
export function isBarcodeScanSupported(): boolean {
|
||||||
|
return !!navigator.mediaDevices?.getUserMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMATS: BarcodeFormat[] = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
|
||||||
|
const SCAN_INTERVAL_MS = 300;
|
||||||
|
|
||||||
|
interface Detector {
|
||||||
|
detect: (source: HTMLVideoElement) => Promise<DetectedBarcode[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeDetector = new (options?: { formats?: BarcodeFormat[] }) => Detector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нативный детектор бесплатен и энергоэффективнее, поэтому wasm подключается
|
||||||
|
* только там, где своего распознавания нет.
|
||||||
|
*/
|
||||||
|
async function createDetector(): Promise<Detector> {
|
||||||
|
const Native = (globalThis as { BarcodeDetector?: NativeDetector }).BarcodeDetector;
|
||||||
|
if (Native) return new Native({ formats: FORMATS });
|
||||||
|
|
||||||
|
const { BarcodeDetector, prepareZXingModule } = await import('barcode-detector/ponyfill');
|
||||||
|
// По умолчанию zxing тянет .wasm с CDN — это ломало бы сканер офлайн и
|
||||||
|
// упиралось бы в CSP, поэтому подставляем файл из собственной сборки.
|
||||||
|
await prepareZXingModule({
|
||||||
|
overrides: {
|
||||||
|
locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? wasmUrl : `${prefix}${path}`),
|
||||||
|
},
|
||||||
|
fireImmediately: true,
|
||||||
|
});
|
||||||
|
return new BarcodeDetector({ formats: FORMATS });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Камера + детектор: распознали EAN/UPC — отдали наверх и погасили камеру.
|
||||||
*/
|
*/
|
||||||
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
|
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
|
||||||
const videoEl = useRef();
|
const videoEl = useRef();
|
||||||
const error = shallowRef('');
|
const error = shallowRef('');
|
||||||
|
const loading = shallowRef(true);
|
||||||
|
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -32,15 +69,18 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: 'environment' },
|
video: { facingMode: { ideal: 'environment' } },
|
||||||
audio: false,
|
audio: false,
|
||||||
});
|
});
|
||||||
|
// Пока ждали разрешение, лист мог закрыться — поток нужно погасить.
|
||||||
|
if (finished) return;
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
await video.play();
|
await video.play();
|
||||||
|
|
||||||
const detector = new BarcodeDetector({
|
const detector = await createDetector();
|
||||||
formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'],
|
if (finished) return;
|
||||||
});
|
loading.value = false;
|
||||||
|
|
||||||
timer = setInterval(async () => {
|
timer = setInterval(async () => {
|
||||||
if (finished || video.readyState < 2) return;
|
if (finished || video.readyState < 2) return;
|
||||||
try {
|
try {
|
||||||
@@ -58,26 +98,37 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
|
|||||||
}, SCAN_INTERVAL_MS);
|
}, SCAN_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
catch (cause) {
|
catch (cause) {
|
||||||
error.value = cause instanceof DOMException && cause.name === 'NotAllowedError'
|
if (cause instanceof DOMException && cause.name === 'NotAllowedError') {
|
||||||
? 'Нет доступа к камере — разрешите его в настройках браузера.'
|
error.value = 'Нет доступа к камере — разрешите его в настройках браузера.';
|
||||||
: 'Не удалось включить камеру.';
|
}
|
||||||
|
else if (stream) {
|
||||||
|
// Камера уже работает, значит споткнулись на загрузке распознавания.
|
||||||
|
error.value = 'Не удалось загрузить распознавание штрихкодов. Введите цифры вручную.';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error.value = 'Не удалось включить камеру.';
|
||||||
|
}
|
||||||
|
stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(stop);
|
onUnmounted(() => {
|
||||||
|
finished = true;
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="overflow-hidden rounded-2xl border hairline bg-black/60">
|
<div class="overflow-hidden rounded-2xl border hairline bg-black/60">
|
||||||
{error.value === ''
|
{error.value === ''
|
||||||
? (
|
? (
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<video ref={videoEl} muted playsinline class="aspect-[4/3] w-full object-cover" />
|
<video ref={videoEl} muted playsinline class="aspect-4/3 w-full object-cover" />
|
||||||
{/* Рамка прицела */}
|
{/* Рамка прицела */}
|
||||||
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||||
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
|
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
|
||||||
</div>
|
</div>
|
||||||
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
|
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
|
||||||
Наведите на штрихкод упаковки
|
{loading.value ? 'Готовим распознавание…' : 'Наведите на штрихкод упаковки'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
-12
@@ -1,13 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация.
|
|
||||||
interface DetectedBarcode {
|
|
||||||
rawValue: string;
|
|
||||||
format: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare class BarcodeDetector {
|
|
||||||
constructor(options?: { formats?: string[] });
|
|
||||||
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
|
|
||||||
static getSupportedFormats(): Promise<string[]>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export default function StatsScreen() {
|
|||||||
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
|
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
|
||||||
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
|
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
<div class="flex h-full items-end gap-[3px]">
|
<div class="flex h-full items-end gap-0.75">
|
||||||
{days.value.map(day => (
|
{days.value.map(day => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -177,7 +177,7 @@ export default function StatsScreen() {
|
|||||||
onClick={() => (selectedBar.value = day.date)}
|
onClick={() => (selectedBar.value = day.date)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date
|
class={`w-full rounded-t-sm transition-colors ${day.kcal > 0 ? '' : 'min-h-0.5'} ${selectedBar.value === day.date
|
||||||
? 'bg-ember-bright'
|
? 'bg-ember-bright'
|
||||||
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
|
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
|
||||||
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
|
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
|
||||||
|
|||||||
+14
-1
@@ -36,8 +36,21 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
// woff тут дубли woff2 от fontsource — в прекеш идёт только woff2.
|
// woff тут дубли woff2 от fontsource — в прекеш идёт только woff2.
|
||||||
globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'],
|
globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'],
|
||||||
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
|
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
|
// Распознавание штрихкодов на WebKit — ~1 МБ wasm. В прекеш его класть
|
||||||
|
// жалко (нужен не всем), поэтому кэшируем после первого запуска
|
||||||
|
// сканера: дальше он работает и офлайн. Файл с хэшем в имени, так что
|
||||||
|
// CacheFirst безопасен.
|
||||||
|
{
|
||||||
|
urlPattern: /\.wasm$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'wasm',
|
||||||
|
expiration: { maxEntries: 4 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/,
|
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
|
|||||||
Reference in New Issue
Block a user