Spaces:
Running
Running
viewer: name, describe and link every photo the search finds
#3
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .gitattributes +0 -13
- PLACEHOLDER +0 -1
- README.md +3 -6
- app.html +2 -2
- dist/app.html +2 -2
- dist/assets/app.css +1 -1
- dist/assets/index.js +0 -0
- dist/assets/neon-frame.css +0 -170
- dist/assets/share-fix.js +0 -193
- dist/assets/{worker-Bz7I_x9L.js → worker-BEj2THkI.js} +0 -0
- dist/assets/worker-Bq7vdIsj.js +0 -0
- dist/assets/worker-hEU_3YGf.js +0 -0
- dist/assets/worker-hGPAZ_gn.js +0 -0
- dist/assets/worker-n0zt6z0K.js +0 -0
- dist/assets/worker-nnOdWzlm.js +0 -0
- dist/brand/vaistudio_center.png +0 -3
- dist/catalogue/products.json +0 -0
- dist/catalogue/promos.json +0 -0
- dist/share/og-image.png +0 -3
- index.html +8 -58
- package-lock.json +0 -30
- package.json +1 -4
- public/brand/vaistudio_center.png +0 -3
- public/catalogue/products.json +0 -0
- public/catalogue/promos.json +0 -0
- public/share/og-image.png +0 -3
- repo/rebuild.py +0 -56
- scripts/gen-thumbnail.py +0 -108
- scripts/refresh-catalogue.mjs +6 -46
- scripts/refresh.mjs +0 -74
- scripts/test-avatar-interiors.mjs +0 -287
- scripts/test-merge.mjs +2 -48
- scripts/test-neon-ui.mjs +0 -256
- scripts/test-product-search.mjs +0 -173
- scripts/test-semantic-search.mjs +0 -123
- scripts/test-ui.mjs +0 -201
- scripts/test-viewer-ui.mjs +0 -151
- src/App.jsx +6 -89
- src/ImageField.jsx +20 -216
- src/PhotoViewer.jsx +37 -201
- src/assets.js +27 -110
- src/avatar-interiors.js +0 -224
- src/catalogue.js +0 -122
- src/detail.js +0 -369
- src/image-loader.js +3 -3
- src/index.css +3 -162
- src/layout.js +12 -96
- src/merge.js +94 -225
- src/neon-frame.css +0 -131
- src/product-search.js +0 -312
.gitattributes
CHANGED
|
@@ -36,16 +36,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 36 |
products-index.json filter=lfs diff=lfs merge=lfs -text
|
| 37 |
dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 38 |
products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 39 |
-
# The V.AI STUDIO backdrop, copied from vai-avatar's public/avatars/ folder.
|
| 40 |
-
# Named explicitly rather than with a *.png pattern: a wildcard would also
|
| 41 |
-
# catch the BlurHash and thumbnail PNGs under dist/prodimgs, which are small,
|
| 42 |
-
# numerous and regenerated by the catalogue scripts -- routing ~6,400 of them
|
| 43 |
-
# through LFS would make every catalogue refresh slow and would balloon the
|
| 44 |
-
# repo. Two files, one rule, and the reason is recorded here.
|
| 45 |
-
public/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
-
dist/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
-
|
| 48 |
-
# Social-share thumbnail (generated by scripts/gen-thumbnail.py). Both copies in
|
| 49 |
-
# LFS: the hub og:image resolves via resolve/main/dist/share/og-image.png.
|
| 50 |
-
public/share/og-image.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
-
dist/share/og-image.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 36 |
products-index.json filter=lfs diff=lfs merge=lfs -text
|
| 37 |
dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 38 |
products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PLACEHOLDER
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
null
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,20 +1,17 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🛋️
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
-
short_description:
|
| 9 |
-
thumbnail: dist/share/og-image.png
|
| 10 |
header: mini
|
| 11 |
tags:
|
| 12 |
- ml-intern
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
Web App AI thuộc hệ sinh thái **VIBE CODE AI** của **V.AI STUDIO**.
|
| 18 |
|
| 19 |
Phái sinh từ [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field):
|
| 20 |
thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Semantic Image Field — Nội thất
|
| 3 |
emoji: 🛋️
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Tìm ảnh nội thất bằng tiếng Việt, chạy trong máy
|
|
|
|
| 9 |
header: mini
|
| 10 |
tags:
|
| 11 |
- ml-intern
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Semantic Image Field — Nội thất
|
|
|
|
|
|
|
| 15 |
|
| 16 |
Phái sinh từ [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field):
|
| 17 |
thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
|
app.html
CHANGED
|
@@ -4,10 +4,10 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
-
<title>
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<!--
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
+
<meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
+
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<!--
|
dist/app.html
CHANGED
|
@@ -4,10 +4,10 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
-
<title>
|
| 11 |
<script type="module" crossorigin src="./assets/index.js"></script>
|
| 12 |
<link rel="stylesheet" crossorigin href="./assets/app.css">
|
| 13 |
</head>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
+
<meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
+
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
<script type="module" crossorigin src="./assets/index.js"></script>
|
| 12 |
<link rel="stylesheet" crossorigin href="./assets/app.css">
|
| 13 |
</head>
|
dist/assets/app.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
| 2 |
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial;--neon-angle:0deg}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.border{border-style:var(--tw-border-style);border-width:1px}.capitalize{text-transform:capitalize}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}@property --neon-angle{syntax:"<angle>";inherits:false;initial-value:0deg}.image-tile{--neon:#22f5d0;--neon-soft:#7ef9ff;--neon-run:2.6s}.image-tile.exact,.image-tile.featured{--relevance-opacity:1!important;--relevance-blur:0px!important}.image-tile.exact:before,.image-tile.featured:before{content:"";z-index:2;background:conic-gradient(from var(--neon-angle,0deg), var(--neon) 0deg, var(--neon-soft) 38deg, #fff 70deg, var(--neon-soft) 102deg, var(--neon) 148deg, #22f5d05c 250deg, #22f5d000 344deg, var(--neon) 360deg);-webkit-mask-composite:xor;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:1px;position:absolute;inset:0;transform:none;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.image-tile.exact:after,.image-tile.featured:after{content:"";z-index:1;background:conic-gradient(from var(--neon-angle,0deg), #22f5d000 0deg, #22f5d085 62deg, #b6fdffc4 92deg, #22f5d05e 138deg, #22f5d01f 250deg, #22f5d000 348deg, #22f5d000 360deg);-webkit-mask-composite:xor;filter:blur(4px);opacity:.82;box-shadow:none;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:2.5px;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}@keyframes neon-run{0%{--neon-angle:0deg}to{--neon-angle:360deg}}@supports at-rule(@property){.image-tile.exact:before,.image-tile.featured:before,.image-tile.exact:after,.image-tile.featured:after{animation:neon-run var(--neon-run) linear infinite}}.image-tile.featured{--neon-run:2s}.image-tile.featured:before{padding:1.5px}@media (prefers-reduced-motion:reduce){.image-tile.exact:before,.image-tile.exact:after,.image-tile.featured:before,.image-tile.featured:after{animation:none}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.brand-backdrop{z-index:0;pointer-events:none;opacity:.3;background-color:#10110f;background-image:url(../brand/vaistudio_center.png);background-position:50%;background-repeat:no-repeat;background-size:cover;background-attachment:fixed;position:fixed;inset:0}@media (width<=768px){.brand-backdrop{background-position:top;background-repeat:repeat;background-size:100%;background-attachment:scroll}}.app{background:0 0;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s;position:absolute;inset:0}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-share{z-index:1;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#e6ebe0;opacity:0;background:#0d0f0bb8;border-radius:6px;place-items:center;width:22px;height:22px;transition:opacity .16s,transform .16s,background .16s,color .16s;display:grid;position:absolute;top:5px;right:5px;transform:scale(.82)}.image-share svg{width:13px;height:13px}.image-share:hover{color:#fff;background:#0d0f0be6}.image-share[hidden]{display:none}.image-tile:hover .image-share,.image-tile:focus-within .image-share,.image-tile[data-rank="1"] .image-share,.image-tile[data-rank="2"] .image-share,.image-tile[data-rank="3"] .image-share{opacity:1;transform:scale(1)}@media (prefers-reduced-motion:reduce){.image-share{transition:opacity .12s linear}}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.image-tile:hover,.image-tile:focus-within{z-index:2147483647}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}.photo-detail{z-index:2;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#0f130ce8;border:1px solid #ffffff1f;border-radius:12px;width:min(460px,100vw - 48px);position:absolute;bottom:18px;left:24px;overflow:hidden}.photo-detail-summary{cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:10px;padding:11px 14px;list-style:none;display:flex}.photo-detail-summary::-webkit-details-marker{display:none}.photo-detail-summary:hover{background:#ffffff0a}.photo-detail-title{color:#e4e9dc;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden}.photo-detail-price{color:#9fd6a4;font-variant-numeric:tabular-nums;flex:none;font-size:13px;font-weight:600}.photo-detail-toggle{color:#99a291;text-transform:uppercase;letter-spacing:.05em;border:1px solid #ffffff1f;border-radius:999px;flex:none;padding:3px 9px;font-size:11px;transition:color .18s}.photo-detail[open] .photo-detail-toggle{color:#cdd6c4;border-color:#fff3}.photo-detail-body{border-top:1px solid #ffffff12;padding:0 14px 13px}.photo-detail-desc{color:#c3cabb;margin:11px 0 0;font-size:13px;line-height:1.6}.photo-detail-tags{flex-wrap:wrap;gap:6px;margin:11px 0 0;padding:0;list-style:none;display:flex}.photo-detail-tags li{color:#b6c2ab;background:#ffffff0a;border:1px solid #ffffff17;border-radius:999px;padding:3px 9px;font-size:11px}.photo-detail-facts{gap:4px;margin:11px 0 0;font-size:12px;display:grid}.photo-detail-facts>div{grid-template-columns:minmax(0,38%) minmax(0,62%);gap:10px;display:grid}.photo-detail-facts dt{color:#939d8a;overflow-wrap:anywhere}.photo-detail-facts dd{color:#d5dbcd;overflow-wrap:anywhere;margin:0}.photo-viewer:has(.photo-caption) .photo-detail{top:76px;bottom:auto}@media (width<=650px){.photo-detail{width:auto;bottom:12px;left:12px;right:12px}.photo-viewer:has(.photo-caption) .photo-detail{top:70px}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
|
|
|
|
| 1 |
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
| 2 |
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.block{display:block}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.capitalize{text-transform:capitalize}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.app{background:#11120f;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{display:none}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
|
dist/assets/index.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/neon-frame.css
DELETED
|
@@ -1,170 +0,0 @@
|
|
| 1 |
-
/* ── Neon frame: a light that runs around the picture ──────────────────────
|
| 2 |
-
*
|
| 3 |
-
* Loaded from index.html AFTER dist/assets/app.css, and deliberately overriding
|
| 4 |
-
* the ring that bundle paints. It exists because that ring is wrong in a way
|
| 5 |
-
* only shows up on non-square tiles.
|
| 6 |
-
*
|
| 7 |
-
* THE BUG IT UNDOES
|
| 8 |
-
* -----------------
|
| 9 |
-
* The @keyframes neon-run shipped in app.css rotated the whole ::before layer
|
| 10 |
-
* by one turn. That layer carries BOTH the conic gradient and the mask, so the
|
| 11 |
-
* mask rotated with it and stopped lining up with the tile's edges. On a 16:9
|
| 12 |
-
* photograph the painted border tilted off the frame and the highlight read as
|
| 13 |
-
* a skewed smear rather than a light going round the picture -- which is
|
| 14 |
-
* exactly what "a static neon border" looked like on screen. A tile that
|
| 15 |
-
* happened to be nearly square hid the bug, so the effect only ever looked
|
| 16 |
-
* broken on real photographs.
|
| 17 |
-
*
|
| 18 |
-
* WHAT MOVES NOW
|
| 19 |
-
* --------------
|
| 20 |
-
* The layer never transforms. What animates is the conic gradient's own start
|
| 21 |
-
* angle, --neon-angle, so the bright head walks the perimeter and follows the
|
| 22 |
-
* rectangle exactly: it runs down the long edge, turns the corner and comes
|
| 23 |
-
* back along the short edge, like a light circling a picture frame.
|
| 24 |
-
*
|
| 25 |
-
* `--neon-angle` is REGISTERED with @property because an unregistered custom
|
| 26 |
-
* property is an untyped token the engine cannot interpolate -- a 0deg to
|
| 27 |
-
* 360deg keyframe on one flips once at the midpoint instead of sweeping. That
|
| 28 |
-
* flip is worse than the static ring, which is why the animation is gated on
|
| 29 |
-
* @supports below: a browser without registration gets the gradient and no
|
| 30 |
-
* motion at all, i.e. a lit frame with the head parked at the top.
|
| 31 |
-
*
|
| 32 |
-
* `inherits: false` keeps the angle on the ring itself. The tile is transformed
|
| 33 |
-
* every frame by the field engine, and an inherited animated property would be
|
| 34 |
-
* recomputed against a moving box -- which is how the gradient ends up sliding
|
| 35 |
-
* relative to the picture it is supposed to be travelling around.
|
| 36 |
-
*
|
| 37 |
-
* TWO LAYERS, ONE CLOCK
|
| 38 |
-
* ---------------------
|
| 39 |
-
* ::before is the crisp stroke. ::after is the halo: the same sweep, spread
|
| 40 |
-
* over a wider arc and blurred, so the light blooms off the edge instead of
|
| 41 |
-
* being cut flat against it. They are one pseudo-element apart because a
|
| 42 |
-
* pseudo-element can only carry one background, and a neon frame needs both the
|
| 43 |
-
* stroke and the glow.
|
| 44 |
-
*
|
| 45 |
-
* Both animate over the same duration from the same start angle, so the halo
|
| 46 |
-
* stays locked to the head of the stroke on every lap rather than drifting
|
| 47 |
-
* ahead of it.
|
| 48 |
-
*
|
| 49 |
-
* `mask` is what turns the full-size gradient into a ring. Without the composite
|
| 50 |
-
* the gradient would fill the whole tile and hide the photograph. The -webkit-
|
| 51 |
-
* forms are repeated because Safari needs the prefixed one, and the standard
|
| 52 |
-
* alone would leave Safari with an opaque tile.
|
| 53 |
-
*
|
| 54 |
-
* src/neon-frame.css carries the same rules, so a rebuild produces the same
|
| 55 |
-
* frame and this file becomes a no-op rather than a divergence.
|
| 56 |
-
*/
|
| 57 |
-
@property --neon-angle {
|
| 58 |
-
syntax: '<angle>';
|
| 59 |
-
inherits: false;
|
| 60 |
-
initial-value: 0deg;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
.image-tile {
|
| 64 |
-
--neon: #22f5d0;
|
| 65 |
-
--neon-soft: #7ef9ff;
|
| 66 |
-
--neon-run: 2.6s;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
/* The crisp stroke: a white-hot head at 0deg, a comet tail fading back round
|
| 70 |
-
to 250deg, and a clear gap before the head returns -- so the eye reads a
|
| 71 |
-
single light travelling, not a ring that pulses in place. */
|
| 72 |
-
.image-tile.exact::before,
|
| 73 |
-
.image-tile.featured::before {
|
| 74 |
-
padding: 1px;
|
| 75 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 76 |
-
var(--neon) 0deg,
|
| 77 |
-
var(--neon-soft) 38deg,
|
| 78 |
-
#ffffff 70deg,
|
| 79 |
-
var(--neon-soft) 102deg,
|
| 80 |
-
var(--neon) 148deg,
|
| 81 |
-
#22f5d05c 250deg,
|
| 82 |
-
#22f5d000 344deg,
|
| 83 |
-
var(--neon) 360deg);
|
| 84 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 85 |
-
-webkit-mask-composite: xor;
|
| 86 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 87 |
-
mask-composite: exclude;
|
| 88 |
-
/* Any transform here drags the mask off the frame, which is the bug above.
|
| 89 |
-
Stated explicitly so a later edit cannot reintroduce it by accident. */
|
| 90 |
-
transform: none;
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
/* The halo. Same sweep from the same angle, spread wider so the light spills
|
| 94 |
-
both inward off the edge and outward into the dark around the tile. */
|
| 95 |
-
.image-tile.exact::after,
|
| 96 |
-
.image-tile.featured::after {
|
| 97 |
-
padding: 2.5px;
|
| 98 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 99 |
-
#22f5d000 0deg,
|
| 100 |
-
#22f5d085 62deg,
|
| 101 |
-
#b6fdffc4 92deg,
|
| 102 |
-
#22f5d05e 138deg,
|
| 103 |
-
#22f5d01f 250deg,
|
| 104 |
-
#22f5d000 348deg,
|
| 105 |
-
#22f5d000 360deg);
|
| 106 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 107 |
-
-webkit-mask-composite: xor;
|
| 108 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 109 |
-
mask-composite: exclude;
|
| 110 |
-
filter: blur(4px);
|
| 111 |
-
opacity: .82;
|
| 112 |
-
/* The stock rule animated box-shadow here instead. A box-shadow breathes in
|
| 113 |
-
place -- it cannot run along the perimeter, because the shadow's shape is
|
| 114 |
-
the tile's shape and never travels round it. */
|
| 115 |
-
box-shadow: none;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
/* The head sweeps one full turn clockwise; the halo sweeps the same turn over
|
| 119 |
-
the same duration, so the two re-align every lap instead of drifting apart.
|
| 120 |
-
*
|
| 121 |
-
* NOTE this is a REPLACEMENT for app.css's neon-run, not an addition. Keyframes
|
| 122 |
-
* are global and the last definition of a name wins, so this block takes the
|
| 123 |
-
* name over from the built one for the whole page -- which is exactly what
|
| 124 |
-
* retires its `rotate(1turn)`, and also why the ungated reset below matters.
|
| 125 |
-
*/
|
| 126 |
-
@keyframes neon-run {
|
| 127 |
-
from { --neon-angle: 0deg; }
|
| 128 |
-
to { --neon-angle: 360deg; }
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
/* Both pseudo-elements default to NO animation, outside any @supports gate.
|
| 132 |
-
*
|
| 133 |
-
* This has to be unconditional, and it is the subtle part of this file.
|
| 134 |
-
* app.css applies `animation: neon-run ...` to these selectors with no feature
|
| 135 |
-
* test of its own, so a browser that cannot register @property would otherwise
|
| 136 |
-
* run the keyframes above -- animating an untyped token, which snaps at the
|
| 137 |
-
* halfway point. The ring would blink once per cycle and look broken in a way
|
| 138 |
-
* the old static ring never did.
|
| 139 |
-
*
|
| 140 |
-
* Setting `animation: none` first and enabling it only inside @supports makes
|
| 141 |
-
* the unsupported case the safe one: a lit frame, head parked at the top, no
|
| 142 |
-
* motion. It also has to win on order against app.css, which this file loads
|
| 143 |
-
* after, so the @supports rule below re-asserts the animation.
|
| 144 |
-
*/
|
| 145 |
-
.image-tile.exact::before,
|
| 146 |
-
.image-tile.featured::before,
|
| 147 |
-
.image-tile.exact::after,
|
| 148 |
-
.image-tile.featured::after { animation: none; }
|
| 149 |
-
|
| 150 |
-
@supports at-rule(@property) {
|
| 151 |
-
.image-tile.exact::before,
|
| 152 |
-
.image-tile.featured::before,
|
| 153 |
-
.image-tile.exact::after,
|
| 154 |
-
.image-tile.featured::after {
|
| 155 |
-
animation: neon-run var(--neon-run) linear infinite;
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
/* A featured tile -- the exact answer to the query, or a photograph just added
|
| 160 |
-
on vai-avatar -- runs faster and thinner, so the newest photographs are
|
| 161 |
-
distinguishable from an exact hit by pace as well as by size. */
|
| 162 |
-
.image-tile.featured { --neon-run: 2s; }
|
| 163 |
-
.image-tile.featured::before { padding: 1.5px; }
|
| 164 |
-
|
| 165 |
-
/* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
|
| 166 |
-
and this frame marks a correct answer rather than an error. */
|
| 167 |
-
@media (prefers-reduced-motion: reduce) {
|
| 168 |
-
.image-tile.exact::before, .image-tile.exact::after,
|
| 169 |
-
.image-tile.featured::before, .image-tile.featured::after { animation: none; }
|
| 170 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/assets/share-fix.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Repairs the share link on vai-avatar interior photographs, in the browser.
|
| 3 |
-
* =========================================================================
|
| 4 |
-
*
|
| 5 |
-
* THE SYMPTOM
|
| 6 |
-
* A photograph published on vai-avatar opens its share page at
|
| 7 |
-
*
|
| 8 |
-
* https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e
|
| 9 |
-
*
|
| 10 |
-
* which is vai-avatar's "Not Found" page. Every interior photograph is
|
| 11 |
-
* affected, on the tile's share button and on the viewer's "Xem trang" alike.
|
| 12 |
-
*
|
| 13 |
-
* WHY IT HAPPENS
|
| 14 |
-
* avatar-interiors.js gives every such photograph a namespaced `sku` of
|
| 15 |
-
* `avatarimg:<image_id>`, so it travels the same code path as a catalogue
|
| 16 |
-
* product: same viewer, same detail panel, same neon. Namespacing is
|
| 17 |
-
* deliberate -- it is what stops a 16-hex image_id colliding with a baked
|
| 18 |
-
* interior's id in merge.js's de-duplication set.
|
| 19 |
-
*
|
| 20 |
-
* But `avatarimg:...` is not an identity the product catalogue knows, and
|
| 21 |
-
* vai-avatar's `/share/p/` resolves against exactly that catalogue. So the
|
| 22 |
-
* photograph was sent to the product route, where no such SKU exists, and the
|
| 23 |
-
* Space answered Not Found. There is nothing to fix on vai-avatar's side: the
|
| 24 |
-
* record it would need to find does not exist. The photograph is an interior,
|
| 25 |
-
* and the interior route is `/share/i/<image_id>`.
|
| 26 |
-
*
|
| 27 |
-
* WHY THIS IS A FILE AND NOT ONLY THE SOURCE FIX
|
| 28 |
-
* src/assets.js now routes on `avatarImageId` first, and that is the real fix.
|
| 29 |
-
* It is not, though, what the browser runs yet: this Space is `sdk: static`, so
|
| 30 |
-
* the page is served from dist/, and dist/assets/index.js was built before that
|
| 31 |
-
* fix existed. The neon frame has the same problem -- dist/assets/app.css still
|
| 32 |
-
* carries the old `@keyframes neon-run { transform: rotate(1turn) }`, which
|
| 33 |
-
* rotated the mask off the frame. Rebuilding needs a shell.
|
| 34 |
-
*
|
| 35 |
-
* So this corrects the URL where it is consumed, with no rebuild, and the
|
| 36 |
-
* source fix carries the same logic forward. The two agree by construction:
|
| 37 |
-
* both send `avatarimg:<id>` to `/share/i/<id>`.
|
| 38 |
-
*
|
| 39 |
-
* WHAT IT COSTS
|
| 40 |
-
* One capture-phase listener and one MutationObserver. The observer watches
|
| 41 |
-
* only `href` on the whole document -- the field already rebuilds 80 tiles per
|
| 42 |
-
* keystroke, but share hrefs change on layout passes, not on the per-frame
|
| 43 |
-
* transform writes, so this sees a handful of callbacks rather than 60/s. The
|
| 44 |
-
* work per callback is one regex against an attribute value that is almost
|
| 45 |
-
* always not a share href.
|
| 46 |
-
*
|
| 47 |
-
* Once the bundle is rebuilt from src/assets.js this repairs nothing, because
|
| 48 |
-
* the hrefs it would find are already correct. It stays loaded rather than
|
| 49 |
-
* un-loading itself: it is a few regexes against a handful of anchors, and a
|
| 50 |
-
* self-disarming timer is a far worse thing to reason about than inert code.
|
| 51 |
-
*/
|
| 52 |
-
|
| 53 |
-
const STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 54 |
-
|
| 55 |
-
/** Anchors worth checking at all: the viewer plus the tile share buttons. */
|
| 56 |
-
const SHARE_ANCHORS = 'a[href*="/share/"]';
|
| 57 |
-
|
| 58 |
-
/**
|
| 59 |
-
* A share href that names an interior photograph by its namespaced sku.
|
| 60 |
-
*
|
| 61 |
-
* Matches the literal `avatarimg` immediately after `/share/p/`, then either
|
| 62 |
-
* separator: the value may not have been encoded (`avatarimg:`) or may have
|
| 63 |
-
* been (`avatarimg%3A`). Only that one segment is taken as the separator, so an
|
| 64 |
-
* id that itself contains a colon still comes through whole.
|
| 65 |
-
*/
|
| 66 |
-
const BROKEN = /\/share\/p\/(avatarimg)(?::|%3A)([^/?#]+)/i;
|
| 67 |
-
|
| 68 |
-
/**
|
| 69 |
-
* The same URL, routed as the interior it is.
|
| 70 |
-
*
|
| 71 |
-
* The id is decoded first: it arrived percent-encoded inside the URL, and
|
| 72 |
-
* re-encoding the raw `%3A` would produce `%253A`. `decodeURIComponent` throws
|
| 73 |
-
* on a malformed escape, so a bad id is left alone rather than taking the whole
|
| 74 |
-
* listener down -- one broken href must not break the page it sits on.
|
| 75 |
-
*
|
| 76 |
-
* An empty id is rejected too: `/share/i/` with nothing after it is its own
|
| 77 |
-
* 404, so redirecting there would trade one dead link for another.
|
| 78 |
-
*/
|
| 79 |
-
function interiorURL(href) {
|
| 80 |
-
const match = BROKEN.exec(href);
|
| 81 |
-
if (!match) return null;
|
| 82 |
-
let id;
|
| 83 |
-
try {
|
| 84 |
-
id = decodeURIComponent(match[2]);
|
| 85 |
-
} catch {
|
| 86 |
-
return null;
|
| 87 |
-
}
|
| 88 |
-
id = id.trim();
|
| 89 |
-
return id ? `${STUDIO}/share/i/${encodeURIComponent(id)}` : null;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
/** Rewrite one anchor's href if it is mis-routed. Returns true if it changed. */
|
| 93 |
-
function repair(anchor) {
|
| 94 |
-
const href = anchor?.getAttribute?.('href');
|
| 95 |
-
if (!href) return false;
|
| 96 |
-
const fixed = interiorURL(href);
|
| 97 |
-
if (!fixed || fixed === href) return false;
|
| 98 |
-
anchor.setAttribute('href', fixed);
|
| 99 |
-
// Assigning the IDL property as well is redundant with setAttribute for a
|
| 100 |
-
// fully-qualified URL, and deliberately so: it keeps `anchor.href` on the
|
| 101 |
-
// corrected string for any later read, however the property resolves.
|
| 102 |
-
anchor.href = fixed;
|
| 103 |
-
return true;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
/**
|
| 107 |
-
* Sweep `root` and everything under it. `root` defaults to the document.
|
| 108 |
-
*
|
| 109 |
-
* `root` ITSELF is checked, not only its descendants, and that is not
|
| 110 |
-
* redundancy. React sets props on a detached element and then inserts it, so
|
| 111 |
-
* the viewer's "Xem trang" anchor arrives in the tree already carrying its
|
| 112 |
-
* href -- which means the `href` mutation happened off-tree and no observer ever
|
| 113 |
-
* saw it. The only record that describes it is "this node was added", so a
|
| 114 |
-
* descendant-only sweep would miss it and the visible link would stay wrong
|
| 115 |
-
* until it was clicked.
|
| 116 |
-
*
|
| 117 |
-
* The tile share anchors take the opposite path and are covered either way:
|
| 118 |
-
* ImageField appends the anchor empty and fills the href later on a live
|
| 119 |
-
* element, which the `attributes` branch below sees.
|
| 120 |
-
*/
|
| 121 |
-
function scan(root = document) {
|
| 122 |
-
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(SHARE_ANCHORS)) {
|
| 123 |
-
repair(root);
|
| 124 |
-
}
|
| 125 |
-
for (const anchor of root.querySelectorAll?.(SHARE_ANCHORS) ?? []) {
|
| 126 |
-
repair(anchor);
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
const observer = new MutationObserver(records => {
|
| 131 |
-
for (const record of records) {
|
| 132 |
-
if (record.type === 'attributes') {
|
| 133 |
-
// The href changed on an element already in the field.
|
| 134 |
-
repair(record.target);
|
| 135 |
-
continue;
|
| 136 |
-
}
|
| 137 |
-
for (const node of record.addedNodes) {
|
| 138 |
-
// Both places a share href is rendered: the anchor on a field tile, and
|
| 139 |
-
// the viewer's "Xem trang".
|
| 140 |
-
if (node.nodeType === Node.ELEMENT_NODE) scan(node);
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
function start() {
|
| 146 |
-
scan();
|
| 147 |
-
observer.observe(document.documentElement, {
|
| 148 |
-
// href is the only attribute either the React viewer or the field engine
|
| 149 |
-
// sets to move a share link, so nothing else needs watching.
|
| 150 |
-
attributes: true,
|
| 151 |
-
attributeFilter: ['href'],
|
| 152 |
-
subtree: true,
|
| 153 |
-
childList: true,
|
| 154 |
-
});
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
if (document.readyState === 'loading') {
|
| 158 |
-
document.addEventListener('DOMContentLoaded', start, { once: true });
|
| 159 |
-
} else {
|
| 160 |
-
start();
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
/**
|
| 164 |
-
* A capture-phase click handler, which is what actually guarantees the fix.
|
| 165 |
-
*
|
| 166 |
-
* The observer corrects the visible href for the reader, but observer callbacks
|
| 167 |
-
* run as microtasks after the mutation -- and a click can be dispatched on an
|
| 168 |
-
* anchor before any callback has run for the node that inserted it. Repairing
|
| 169 |
-
* during capture makes the corrected value the one the browser navigates to,
|
| 170 |
-
* because the default action resolves the URL at activation time, after capture
|
| 171 |
-
* has run.
|
| 172 |
-
*
|
| 173 |
-
* This is also the backstop that makes the fix independent of the observer
|
| 174 |
-
* catching anything at all: however the link got into the document, clicking it
|
| 175 |
-
* resolves it correctly.
|
| 176 |
-
*
|
| 177 |
-
* The event is deliberately NOT prevented: the aim is to redirect the
|
| 178 |
-
* navigation, not suppress it, so middle-click, ctrl-click and "open in new
|
| 179 |
-
* tab" all carry the corrected URL exactly as they would on a right one.
|
| 180 |
-
*/
|
| 181 |
-
document.addEventListener('click', event => {
|
| 182 |
-
const anchor = event.target?.closest?.('a[href]');
|
| 183 |
-
if (anchor) repair(anchor);
|
| 184 |
-
}, true);
|
| 185 |
-
|
| 186 |
-
/**
|
| 187 |
-
* Exposed for scripts/test-neon-ui.mjs.
|
| 188 |
-
*
|
| 189 |
-
* Not a debug hook: that test builds an anchor by hand with a deliberately
|
| 190 |
-
* broken href, because the field's own links depend on a live network read of
|
| 191 |
-
* the vai-avatar interior index that may not have landed when the test runs.
|
| 192 |
-
*/
|
| 193 |
-
globalThis.__repairInteriorShareLinks = () => scan();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/assets/{worker-Bz7I_x9L.js → worker-BEj2THkI.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-Bq7vdIsj.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-hEU_3YGf.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-hGPAZ_gn.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-n0zt6z0K.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-nnOdWzlm.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/brand/vaistudio_center.png
DELETED
Git LFS Details
|
dist/catalogue/products.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/catalogue/promos.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/share/og-image.png
DELETED
Git LFS Details
|
index.html
CHANGED
|
@@ -4,26 +4,10 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="
|
| 8 |
-
<meta name="robots" content="index, follow" />
|
| 9 |
-
<meta name="keywords" content="tìm ảnh theo ngữ nghĩa, tìm kiếm ảnh nội thất, AI tìm ảnh, V.AI STUDIO, VIBE CODE AI, Malloca, Bosch, Hafele, ảnh nội thất Việt Nam" />
|
| 10 |
-
<meta property="og:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
|
| 11 |
-
<meta property="og:type" content="website" />
|
| 12 |
-
<meta property="og:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Đồng bộ tự động ảnh mới nhất từ vai-avatar. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 13 |
-
<meta property="og:url" content="https://huggingface.co/spaces/bep40/semantic-image-field" />
|
| 14 |
-
<meta property="og:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
|
| 15 |
-
<meta property="og:image:width" content="1200" />
|
| 16 |
-
<meta property="og:image:height" content="675" />
|
| 17 |
-
<meta property="og:image:alt" content="12 ảnh nội thất và sản phẩm mới nhất từ V.AI STUDIO" />
|
| 18 |
-
<meta property="og:site_name" content="V.AI STUDIO" />
|
| 19 |
-
<meta name="twitter:card" content="summary_large_image" />
|
| 20 |
-
<meta name="twitter:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
|
| 21 |
-
<meta name="twitter:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 22 |
-
<meta name="twitter:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
|
| 23 |
-
<link rel="canonical" href="https://huggingface.co/spaces/bep40/semantic-image-field" />
|
| 24 |
<link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
|
| 25 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 26 |
-
<title>
|
| 27 |
<!--
|
| 28 |
This is the page the Space actually serves. It is NOT a Vite build input
|
| 29 |
(that is app.html), so `vite build` never rewrites it.
|
|
@@ -32,51 +16,17 @@
|
|
| 32 |
bundle, so they must always name files that EXIST.
|
| 33 |
|
| 34 |
vite.config.js pins entryFileNames/assetsFileNames to stable names
|
| 35 |
-
(assets/index.js, assets/app.css), so `npm run build` is sufficient on
|
| 36 |
-
own -- there is no content hash to chase. The hashed names that used
|
| 37 |
-
listed here (index-CigEzVrZ.js / index-DcLscXnL.css) were deleted:
|
| 38 |
those bundles were built from a source tree that had no product code at
|
| 39 |
all, so the live Space was serving a build without the catalogue
|
| 40 |
feature. See "Why the bundle was rebuilt" in README.md.
|
| 41 |
-->
|
| 42 |
-
<script type="module" crossorigin src="./dist/assets/index.js
|
| 43 |
-
<link rel="stylesheet" crossorigin href="./dist/assets/app.css
|
| 44 |
-
|
| 45 |
-
<!--
|
| 46 |
-
Loaded AFTER app.css, and deliberately overriding it.
|
| 47 |
-
|
| 48 |
-
The ring app.css paints is the old one: its @keyframes neon-run rotated
|
| 49 |
-
the whole ::before layer by a turn, and because that layer carries the mask
|
| 50 |
-
as well as the gradient, the mask rotated with it and stopped lining up
|
| 51 |
-
with the tile's edges. On a non-square photograph the painted border tilted
|
| 52 |
-
off the frame, so the highlight read as a skewed smear rather than a light
|
| 53 |
-
going round the picture -- which is what "a static neon border" looked
|
| 54 |
-
like. src/index.css already carries the angle-based version, so a rebuild
|
| 55 |
-
makes this stylesheet a no-op rather than a divergence. Order matters: it
|
| 56 |
-
has to come after app.css to win, and it must NOT be renamed by the build.
|
| 57 |
-
-->
|
| 58 |
-
<link rel="stylesheet" href="./dist/assets/neon-frame.css?v=20261110-1" />
|
| 59 |
-
|
| 60 |
-
<!--
|
| 61 |
-
Repairs the share link on vai-avatar interior photographs.
|
| 62 |
-
|
| 63 |
-
src/assets.js routes on `avatarImageId` first, which is the real fix, but
|
| 64 |
-
this Space is `sdk: static` and serves dist/assets/index.js -- a bundle
|
| 65 |
-
built before that fix existed. So every photograph still opens
|
| 66 |
-
/share/p/avatarimg%3A... , which is vai-avatar's "Not Found" page: the
|
| 67 |
-
`avatarimg:` sku is a namespace for de-duplication, not a catalogue
|
| 68 |
-
identity, and /share/p/ resolves against the product catalogue where no
|
| 69 |
-
such SKU exists. This corrects the href where it is consumed, with no
|
| 70 |
-
rebuild, and goes inert once the bundle is rebuilt.
|
| 71 |
-
|
| 72 |
-
Loaded as a module, and BEFORE the app bundle's work matters because it
|
| 73 |
-
installs a capture-phase click listener on document -- the phase in which
|
| 74 |
-
a mis-routed href can still be corrected before the browser resolves it
|
| 75 |
-
for navigation.
|
| 76 |
-
-->
|
| 77 |
-
<script type="module" src="./dist/assets/share-fix.js?v=20261110-1"></script>
|
| 78 |
</head>
|
| 79 |
<body>
|
| 80 |
<div id="root"></div>
|
| 81 |
</body>
|
| 82 |
-
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
+
<meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
+
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
<!--
|
| 12 |
This is the page the Space actually serves. It is NOT a Vite build input
|
| 13 |
(that is app.html), so `vite build` never rewrites it.
|
|
|
|
| 16 |
bundle, so they must always name files that EXIST.
|
| 17 |
|
| 18 |
vite.config.js pins entryFileNames/assetsFileNames to stable names
|
| 19 |
+
(assets/index.js, assets/app.css), so `npm run build` is sufficient on
|
| 20 |
+
its own -- there is no content hash to chase. The hashed names that used
|
| 21 |
+
to be listed here (index-CigEzVrZ.js / index-DcLscXnL.css) were deleted:
|
| 22 |
those bundles were built from a source tree that had no product code at
|
| 23 |
all, so the live Space was serving a build without the catalogue
|
| 24 |
feature. See "Why the bundle was rebuilt" in README.md.
|
| 25 |
-->
|
| 26 |
+
<script type="module" crossorigin src="./dist/assets/index.js"></script>
|
| 27 |
+
<link rel="stylesheet" crossorigin href="./dist/assets/app.css">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</head>
|
| 29 |
<body>
|
| 30 |
<div id="root"></div>
|
| 31 |
</body>
|
| 32 |
+
</html>
|
package-lock.json
CHANGED
|
@@ -26,7 +26,6 @@
|
|
| 26 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 27 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 28 |
"globals": "^17.12.0",
|
| 29 |
-
"playwright": "^1.63.0",
|
| 30 |
"vite": "^8.3.0"
|
| 31 |
},
|
| 32 |
"engines": {
|
|
@@ -4346,35 +4345,6 @@
|
|
| 4346 |
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 4347 |
"license": "MIT"
|
| 4348 |
},
|
| 4349 |
-
"node_modules/playwright": {
|
| 4350 |
-
"version": "1.63.0",
|
| 4351 |
-
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
| 4352 |
-
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
| 4353 |
-
"dev": true,
|
| 4354 |
-
"license": "Apache-2.0",
|
| 4355 |
-
"dependencies": {
|
| 4356 |
-
"playwright-core": "1.63.0"
|
| 4357 |
-
},
|
| 4358 |
-
"bin": {
|
| 4359 |
-
"playwright": "cli.js"
|
| 4360 |
-
},
|
| 4361 |
-
"engines": {
|
| 4362 |
-
"node": ">=20"
|
| 4363 |
-
}
|
| 4364 |
-
},
|
| 4365 |
-
"node_modules/playwright-core": {
|
| 4366 |
-
"version": "1.63.0",
|
| 4367 |
-
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
| 4368 |
-
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
| 4369 |
-
"dev": true,
|
| 4370 |
-
"license": "Apache-2.0",
|
| 4371 |
-
"bin": {
|
| 4372 |
-
"playwright-core": "cli.js"
|
| 4373 |
-
},
|
| 4374 |
-
"engines": {
|
| 4375 |
-
"node": ">=20"
|
| 4376 |
-
}
|
| 4377 |
-
},
|
| 4378 |
"node_modules/possible-typed-array-names": {
|
| 4379 |
"version": "1.0.0",
|
| 4380 |
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
|
|
|
| 26 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 27 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 28 |
"globals": "^17.12.0",
|
|
|
|
| 29 |
"vite": "^8.3.0"
|
| 30 |
},
|
| 31 |
"engines": {
|
|
|
|
| 4345 |
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 4346 |
"license": "MIT"
|
| 4347 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4348 |
"node_modules/possible-typed-array-names": {
|
| 4349 |
"version": "1.0.0",
|
| 4350 |
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
package.json
CHANGED
|
@@ -7,8 +7,6 @@
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"preview": "vite preview",
|
| 10 |
-
"test": "node scripts/test-avatar-interiors.mjs",
|
| 11 |
-
"test:ui": "node scripts/test-neon-ui.mjs",
|
| 12 |
"sync:catalogue": "node scripts/sync-catalogue.mjs",
|
| 13 |
"refresh:catalogue": "node scripts/refresh-catalogue.mjs",
|
| 14 |
"seed:catalogue": "node scripts/seed-catalogue.mjs"
|
|
@@ -32,10 +30,9 @@
|
|
| 32 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 33 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 34 |
"globals": "^17.12.0",
|
| 35 |
-
"playwright": "^1.63.0",
|
| 36 |
"vite": "^8.3.0"
|
| 37 |
},
|
| 38 |
"engines": {
|
| 39 |
"node": "^20.19.0 || >=22.12.0"
|
| 40 |
}
|
| 41 |
-
}
|
|
|
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
"preview": "vite preview",
|
|
|
|
|
|
|
| 10 |
"sync:catalogue": "node scripts/sync-catalogue.mjs",
|
| 11 |
"refresh:catalogue": "node scripts/refresh-catalogue.mjs",
|
| 12 |
"seed:catalogue": "node scripts/seed-catalogue.mjs"
|
|
|
|
| 30 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 31 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 32 |
"globals": "^17.12.0",
|
|
|
|
| 33 |
"vite": "^8.3.0"
|
| 34 |
},
|
| 35 |
"engines": {
|
| 36 |
"node": "^20.19.0 || >=22.12.0"
|
| 37 |
}
|
| 38 |
+
}
|
public/brand/vaistudio_center.png
DELETED
Git LFS Details
|
public/catalogue/products.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/catalogue/promos.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/share/og-image.png
DELETED
Git LFS Details
|
repo/rebuild.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
import subprocess, os, re
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 6 |
-
REPO = "bep40/VSEARCH"
|
| 7 |
-
CLONE = "/repo"
|
| 8 |
-
SPACE_URL = "https://huggingface.co/spaces/"
|
| 9 |
-
HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
|
| 10 |
-
|
| 11 |
-
def run(cmd, **kw):
|
| 12 |
-
return subprocess.run(cmd, shell=True, cwd=CLONE, check=True,
|
| 13 |
-
capture_output=True, text=True, env={**os.environ, "HF_TOKEN": HF_TOKEN})
|
| 14 |
-
|
| 15 |
-
Path(CLONE).mkdir(parents=True, exist_ok=True)
|
| 16 |
-
if not Path(CLONE).exists() or not Path(CLONE, ".git").exists():
|
| 17 |
-
run("git clone -q --filter=blob:none --depth 1 " + SPACE_URL + REPO + " " + CLONE)
|
| 18 |
-
os.chdir(CLONE)
|
| 19 |
-
print("install:", run("npm install --silent --no-audit --no-fund").stdout[-200:])
|
| 20 |
-
build = run("npm run build")
|
| 21 |
-
print("build:", build.stdout[-400:])
|
| 22 |
-
import re as re2
|
| 23 |
-
html = Path(CLONE, "index.html").read_text()
|
| 24 |
-
assets = {}
|
| 25 |
-
for key in ("src", "href"):
|
| 26 |
-
for m in re2.finditer(rf"(?:{key})=\"([^\"]+)\"", html):
|
| 27 |
-
url = m.group(1).split("?")[0]
|
| 28 |
-
assets[url] = None
|
| 29 |
-
print("index.html references:", assets)
|
| 30 |
-
|
| 31 |
-
# Upload via REST upload_file to avoid git auth prompts
|
| 32 |
-
import urllib.request
|
| 33 |
-
import urllib.parse
|
| 34 |
-
import json
|
| 35 |
-
|
| 36 |
-
def hf_upload(path_in_repo, path_or_fileobj):
|
| 37 |
-
url = f"https://huggingface.co/api/spaces/{REPO}/upload_file"
|
| 38 |
-
q = urllib.parse.urlencode({"create_pr": False, "revision": "main", "commit_message": "Upload rebuilt static asset"})
|
| 39 |
-
body = b""
|
| 40 |
-
if isinstance(path_or_fileobj, str):
|
| 41 |
-
body = open(path_or_fileobj, "rb").read()
|
| 42 |
-
else:
|
| 43 |
-
body = path_or_fileobj.read()
|
| 44 |
-
data = urllib.parse.urlencode({"path_in_repo": path_in_repo}).encode()
|
| 45 |
-
data = data + b"&content=" + urllib.parse.quote(body)
|
| 46 |
-
req = urllib.request.Request(url + "?" + q, data=data, headers={"Authorization": HEADERS["Authorization"], "Content-Type": "application/x-www-form-urlencoded"})
|
| 47 |
-
with urllib.request.urlopen(req) as resp:
|
| 48 |
-
return json.loads(resp.read())
|
| 49 |
-
|
| 50 |
-
for url in sorted(assets):
|
| 51 |
-
local = Path(CLONE, url)
|
| 52 |
-
if not local.exists():
|
| 53 |
-
raise SystemExit("missing asset: " + url)
|
| 54 |
-
hf_upload(url, str(local))
|
| 55 |
-
print("uploaded:", url, local.stat().st_size)
|
| 56 |
-
print("ALL_OK")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/gen-thumbnail.py
DELETED
|
@@ -1,108 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Generate the social-share thumbnail for the semantic-image-field Space.
|
| 4 |
-
|
| 5 |
-
Always the CURRENT 12 newest photographs: 8 newest interiors (by created_at)
|
| 6 |
-
from bep40/vai-avatar2-interior + 4 newest added products (by _added_at)
|
| 7 |
-
from bep40/grob-products-updated. Composed as a 4x3 grid at 1200x675 (2:1
|
| 8 |
-
social-card crop). Written to BOTH public/ and dist/ so whichever path the
|
| 9 |
-
static Space serves, the resolved URL exists.
|
| 10 |
-
|
| 11 |
-
Intended to run from a scheduled HF job (or locally) BEFORE the catalogue
|
| 12 |
-
sync commits, so the committed thumbnail is refreshed at least as often as
|
| 13 |
-
the mirror itself.
|
| 14 |
-
"""
|
| 15 |
-
import io, json, os, sys, urllib.request
|
| 16 |
-
|
| 17 |
-
INTERIOR_INDEX = "https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/index.json"
|
| 18 |
-
INTERIOR_API = "https://bep40-vai-avatar2-interior-api.hf.space"
|
| 19 |
-
PRODUCTS_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_url_added.json"
|
| 20 |
-
N_INTERIORS = 8
|
| 21 |
-
N_PRODUCTS = 4
|
| 22 |
-
W, H = 1200, 675
|
| 23 |
-
GAP = 6
|
| 24 |
-
COLS, ROWS = 4, 3
|
| 25 |
-
BG = (17, 20, 28)
|
| 26 |
-
|
| 27 |
-
UA = {"User-Agent": "V.AI-STUDIO-thumbnail/1.0"}
|
| 28 |
-
|
| 29 |
-
def fetch_json(url, timeout=25):
|
| 30 |
-
req = urllib.request.Request(url, headers=UA)
|
| 31 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 32 |
-
return json.loads(r.read().decode("utf-8"))
|
| 33 |
-
|
| 34 |
-
def fetch_bytes(url, timeout=25):
|
| 35 |
-
req = urllib.request.Request(url, headers=UA)
|
| 36 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 37 |
-
return r.read()
|
| 38 |
-
|
| 39 |
-
def main():
|
| 40 |
-
from PIL import Image
|
| 41 |
-
interiors = fetch_json(INTERIOR_INDEX)
|
| 42 |
-
products = fetch_json(PRODUCTS_URL)
|
| 43 |
-
|
| 44 |
-
dated = [r for r in interiors
|
| 45 |
-
if isinstance(r, dict) and isinstance(r.get("created_at"), str)
|
| 46 |
-
and r.get("created_at") and r.get("image_id")]
|
| 47 |
-
dated.sort(key=lambda r: r["created_at"], reverse=True)
|
| 48 |
-
top_i = dated[:N_INTERIORS]
|
| 49 |
-
|
| 50 |
-
prods = [p for p in products
|
| 51 |
-
if isinstance(p, dict) and isinstance(p.get("_added_at"), str)
|
| 52 |
-
and p.get("_added_at") and (p.get("imgs") or p.get("i"))]
|
| 53 |
-
prods.sort(key=lambda p: str(p["_added_at"]), reverse=True)
|
| 54 |
-
top_p = prods[:N_PRODUCTS]
|
| 55 |
-
|
| 56 |
-
urls = [f"{INTERIOR_API}/image/{r['image_id']}.jpg" for r in top_i]
|
| 57 |
-
urls += [(p.get("imgs") or [p.get("i")])[0] for p in top_p]
|
| 58 |
-
|
| 59 |
-
cell_w = (W - GAP * (COLS - 1)) // COLS
|
| 60 |
-
cell_h = (H - GAP * (ROWS - 1)) // ROWS
|
| 61 |
-
|
| 62 |
-
canvas = Image.new("RGB", (W, H), BG)
|
| 63 |
-
cells = []
|
| 64 |
-
for u in urls:
|
| 65 |
-
try:
|
| 66 |
-
data = fetch_bytes(u)
|
| 67 |
-
im = Image.open(io.BytesIO(data))
|
| 68 |
-
if im.mode not in ("RGB", "RGBA"):
|
| 69 |
-
im = im.convert("RGB")
|
| 70 |
-
if im.mode == "RGBA":
|
| 71 |
-
im = im.convert("RGB")
|
| 72 |
-
cells.append(im)
|
| 73 |
-
except Exception as e:
|
| 74 |
-
print(f"skip {u[:80]}: {e}", file=sys.stderr)
|
| 75 |
-
|
| 76 |
-
if not cells:
|
| 77 |
-
print("FATAL: no images could be fetched", file=sys.stderr)
|
| 78 |
-
sys.exit(1)
|
| 79 |
-
|
| 80 |
-
idx = 0
|
| 81 |
-
for row in range(ROWS):
|
| 82 |
-
for col in range(COLS):
|
| 83 |
-
if idx >= len(cells):
|
| 84 |
-
break
|
| 85 |
-
im = cells[idx]
|
| 86 |
-
idx += 1
|
| 87 |
-
iw, ih = im.size
|
| 88 |
-
scale = max(cell_w / iw, cell_h / ih)
|
| 89 |
-
sw, sh = max(1, round(iw * scale)), max(1, round(ih * scale))
|
| 90 |
-
im2 = im.resize((sw, sh), Image.LANCZOS)
|
| 91 |
-
left = (sw - cell_w) // 2
|
| 92 |
-
top = (sh - cell_h) // 2
|
| 93 |
-
im2 = im2.crop((left, top, left + cell_w, top + cell_h))
|
| 94 |
-
canvas.paste(im2, (col * (cell_w + GAP), row * (cell_h + GAP)))
|
| 95 |
-
|
| 96 |
-
out = io.BytesIO()
|
| 97 |
-
canvas.save(out, format="PNG", optimize=True)
|
| 98 |
-
png = out.getvalue()
|
| 99 |
-
|
| 100 |
-
os.makedirs("public/share", exist_ok=True)
|
| 101 |
-
os.makedirs("dist/share", exist_ok=True)
|
| 102 |
-
for path in ("public/share/og-image.png", "dist/share/og-image.png"):
|
| 103 |
-
with open(path, "wb") as f:
|
| 104 |
-
f.write(png)
|
| 105 |
-
print(f"OK wrote {len(png)} bytes {W}x{H} from {len(cells)} images")
|
| 106 |
-
|
| 107 |
-
if __name__ == "__main__":
|
| 108 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/refresh-catalogue.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const CLONE = process.env.CLONE_DIR || '/tmp/space';
|
|
| 32 |
* is why the swap below happens only after it has run.
|
| 33 |
*/
|
| 34 |
const STABLE_BUNDLE = 'assets/index.js';
|
| 35 |
-
const STABLE_STYLE = 'assets/
|
| 36 |
|
| 37 |
/** The two mirrored files, in both the source and the served location. */
|
| 38 |
const MIRROR = ['products.json', 'promos.json'];
|
|
@@ -70,7 +70,7 @@ const MIRROR = ['products.json', 'promos.json'];
|
|
| 70 |
* served was never pushed. That is a silent failure with no error anywhere, and
|
| 71 |
* it is exactly the "sửa sp không đồng bộ" symptom.
|
| 72 |
*/
|
| 73 |
-
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets'
|
| 74 |
|
| 75 |
const ok = message => console.log(`[refresh] ${message}`);
|
| 76 |
const warn = message => console.warn(`[refresh] WARNING: ${message}`);
|
|
@@ -99,23 +99,10 @@ function git(...args) {
|
|
| 99 |
);
|
| 100 |
}
|
| 101 |
|
| 102 |
-
/**
|
| 103 |
-
* Is git-lfs installed at all? (The smudge filter can only exist after
|
| 104 |
-
* `git lfs install`, so availability is checked on the binary first, then
|
| 105 |
-
* the clone-local filter is (re)wired with `git lfs install --local`.)
|
| 106 |
-
*/
|
| 107 |
-
function hasGitLfsBinary() {
|
| 108 |
-
try {
|
| 109 |
-
return Boolean(shell('git lfs version 2>/dev/null || true').trim());
|
| 110 |
-
} catch {
|
| 111 |
-
return false;
|
| 112 |
-
}
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
/** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */
|
| 116 |
function hasGitLfs() {
|
| 117 |
try {
|
| 118 |
-
if (!
|
| 119 |
return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
|
| 120 |
} catch {
|
| 121 |
return false;
|
|
@@ -174,7 +161,7 @@ function recordCount(relative) {
|
|
| 174 |
/** True when index.html already names both stable assets. */
|
| 175 |
function indexReferencesStable(html) {
|
| 176 |
return /\.\/dist\/assets\/index\.js\b/.test(html)
|
| 177 |
-
&& /\.\/dist\/assets\/
|
| 178 |
}
|
| 179 |
|
| 180 |
/**
|
|
@@ -265,21 +252,6 @@ function main() {
|
|
| 265 |
git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
|
| 266 |
ok(`cloned ${REPO}`);
|
| 267 |
|
| 268 |
-
// The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries,
|
| 269 |
-
// so the clone MUST be able to smudge/clean via git-lfs. `git lfs install
|
| 270 |
-
// --local` inside the clone (a) loads the repository-local filters and (b)
|
| 271 |
-
// fails visibly when git-lfs is not installed, instead of silently committing
|
| 272 |
-
// a raw PNG pointer that a later push gets rejected for.
|
| 273 |
-
if (hasGitLfsBinary()) {
|
| 274 |
-
shell('git lfs install --local');
|
| 275 |
-
if (!hasGitLfs()) {
|
| 276 |
-
throw new Error('git lfs install --local did not wire the smudge filter');
|
| 277 |
-
}
|
| 278 |
-
ok('git-lfs wired for this clone');
|
| 279 |
-
} else {
|
| 280 |
-
throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job');
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
// Informational only. COMMITTABLE excludes every LFS-tracked path, so a
|
| 284 |
// missing git-lfs cannot lead to pointers being committed; it just means the
|
| 285 |
// committed dist data cannot be verified from this clone.
|
|
@@ -294,16 +266,6 @@ function main() {
|
|
| 294 |
// Copies vai-avatar's live product sources into public/catalogue/.
|
| 295 |
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
|
| 296 |
|
| 297 |
-
// Regenerate the social-share thumbnail (12 newest images) BEFORE the build,
|
| 298 |
-
// so Vite copies public/share/ into dist/share/ and the committed og-image
|
| 299 |
-
// is refreshed on every sync. Non-fatal: the previous thumbnail keeps
|
| 300 |
-
// serving if the Hub is unreachable, but a HARD failure is not silent.
|
| 301 |
-
shell('set -euo pipefail; python3 scripts/gen-thumbnail.py');
|
| 302 |
-
for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) {
|
| 303 |
-
requireRealFile(share, { why: 'the social-share thumbnail would be missing' });
|
| 304 |
-
}
|
| 305 |
-
ok('social-share thumbnail regenerated');
|
| 306 |
-
|
| 307 |
// Guard before building: an empty or truncated mirror must never ship.
|
| 308 |
for (const file of MIRROR) {
|
| 309 |
requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
|
|
@@ -346,10 +308,8 @@ function main() {
|
|
| 346 |
// Verify the served page BEFORE committing, and only AFTER repointing — the
|
| 347 |
// original order checked the stale reference against the stale file and passed.
|
| 348 |
const html = readFileSync(`${CLONE}/index.html`, 'utf8');
|
| 349 |
-
// Query strings (`?v=...`) are cache busters, not part of the filename —
|
| 350 |
-
// strip them so the existence check tests the real file on disk.
|
| 351 |
const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
|
| 352 |
-
.map(match => match[1]
|
| 353 |
if (!referenced.length) {
|
| 354 |
throw new Error('index.html references no dist/assets bundle — refusing to publish');
|
| 355 |
}
|
|
|
|
| 32 |
* is why the swap below happens only after it has run.
|
| 33 |
*/
|
| 34 |
const STABLE_BUNDLE = 'assets/index.js';
|
| 35 |
+
const STABLE_STYLE = 'assets/index.css';
|
| 36 |
|
| 37 |
/** The two mirrored files, in both the source and the served location. */
|
| 38 |
const MIRROR = ['products.json', 'promos.json'];
|
|
|
|
| 70 |
* served was never pushed. That is a silent failure with no error anywhere, and
|
| 71 |
* it is exactly the "sửa sp không đồng bộ" symptom.
|
| 72 |
*/
|
| 73 |
+
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets'];
|
| 74 |
|
| 75 |
const ok = message => console.log(`[refresh] ${message}`);
|
| 76 |
const warn = message => console.warn(`[refresh] WARNING: ${message}`);
|
|
|
|
| 99 |
);
|
| 100 |
}
|
| 101 |
|
| 102 |
+
/** Is git-lfs both installed AND wired as a smudge filter for this clone? */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
function hasGitLfs() {
|
| 104 |
try {
|
| 105 |
+
if (!shell('git lfs version 2>/dev/null || true').trim()) return false;
|
| 106 |
return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
|
| 107 |
} catch {
|
| 108 |
return false;
|
|
|
|
| 161 |
/** True when index.html already names both stable assets. */
|
| 162 |
function indexReferencesStable(html) {
|
| 163 |
return /\.\/dist\/assets\/index\.js\b/.test(html)
|
| 164 |
+
&& /\.\/dist\/assets\/index\.css\b/.test(html);
|
| 165 |
}
|
| 166 |
|
| 167 |
/**
|
|
|
|
| 252 |
git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
|
| 253 |
ok(`cloned ${REPO}`);
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
// Informational only. COMMITTABLE excludes every LFS-tracked path, so a
|
| 256 |
// missing git-lfs cannot lead to pointers being committed; it just means the
|
| 257 |
// committed dist data cannot be verified from this clone.
|
|
|
|
| 266 |
// Copies vai-avatar's live product sources into public/catalogue/.
|
| 267 |
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
// Guard before building: an empty or truncated mirror must never ship.
|
| 270 |
for (const file of MIRROR) {
|
| 271 |
requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
|
|
|
|
| 308 |
// Verify the served page BEFORE committing, and only AFTER repointing — the
|
| 309 |
// original order checked the stale reference against the stale file and passed.
|
| 310 |
const html = readFileSync(`${CLONE}/index.html`, 'utf8');
|
|
|
|
|
|
|
| 311 |
const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
|
| 312 |
+
.map(match => match[1]);
|
| 313 |
if (!referenced.length) {
|
| 314 |
throw new Error('index.html references no dist/assets bundle — refusing to publish');
|
| 315 |
}
|
scripts/refresh.mjs
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env node
|
| 2 |
-
import { execFileSync } from 'node:child_process';
|
| 3 |
-
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
| 4 |
-
|
| 5 |
-
const REPO = 'bep40/semantic-image-field';
|
| 6 |
-
const CLONE = '/tmp/space';
|
| 7 |
-
const STABLE_BUNDLE = 'assets/index.js';
|
| 8 |
-
const STABLE_STYLE = 'assets/app.css';
|
| 9 |
-
const MIRROR = ['products.json', 'promos.json'];
|
| 10 |
-
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
|
| 11 |
-
|
| 12 |
-
const ok = m => console.log(`[refresh] ${m}`);
|
| 13 |
-
|
| 14 |
-
function run(command, args, { cwd = CLONE } = {}) {
|
| 15 |
-
return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
| 16 |
-
}
|
| 17 |
-
function shell(script, cwd = CLONE) { return run('bash', ['-lc', script], { cwd }); }
|
| 18 |
-
function git(...args) {
|
| 19 |
-
return run('git', ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args]);
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function main() {
|
| 23 |
-
if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN required');
|
| 24 |
-
rmSync(CLONE, { recursive: true, force: true });
|
| 25 |
-
run('mkdir', ['-p', CLONE]);
|
| 26 |
-
run('git', ['clone', '-q', `--filter=blob:none`, '--no-checkout', `https://huggingface.co/spaces/${REPO}`], { cwd: '/' });
|
| 27 |
-
git('checkout', '-q', '-B', 'main', 'main');
|
| 28 |
-
ok('cloned');
|
| 29 |
-
|
| 30 |
-
shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
|
| 31 |
-
ok('dependencies installed');
|
| 32 |
-
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
|
| 33 |
-
for (const file of MIRROR) {
|
| 34 |
-
const count = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
|
| 35 |
-
ok(`public/catalogue/${file}: ${count} entries`);
|
| 36 |
-
}
|
| 37 |
-
shell('set -euo pipefail; npm run build');
|
| 38 |
-
ok('built');
|
| 39 |
-
for (const file of MIRROR) {
|
| 40 |
-
const served = `dist/catalogue/${file}`;
|
| 41 |
-
const source = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
|
| 42 |
-
const built = JSON.parse(readFileSync(served, 'utf8')).length;
|
| 43 |
-
if (source !== built) throw new Error(`${served} has ${built} but mirror has ${source}`);
|
| 44 |
-
ok(`served mirror verified: ${file} (${built} entries)`);
|
| 45 |
-
}
|
| 46 |
-
const htmlPath = `${CLONE}/index.html`;
|
| 47 |
-
let html = readFileSync(htmlPath, 'utf8');
|
| 48 |
-
if (/\.\/dist\/assets\/index\.js\b/.test(html) && /\.\/dist\/assets\/app\.css\b/.test(html)) {
|
| 49 |
-
ok('index.html already references stable bundle');
|
| 50 |
-
} else {
|
| 51 |
-
let next = html.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g, `$1${STABLE_BUNDLE.slice('assets/'.length)}`);
|
| 52 |
-
next = next.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g, `$1${STABLE_STYLE.slice('assets/'.length)}`);
|
| 53 |
-
if (next === html) throw new Error('index.html names bundle in unexpected way');
|
| 54 |
-
writeFileSync(htmlPath, next);
|
| 55 |
-
ok('index.html repointed to ' + STABLE_BUNDLE);
|
| 56 |
-
}
|
| 57 |
-
for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
|
| 58 |
-
if (!existsSync(`${CLONE}/dist/${asset}`)) throw new Error(`dist/${asset} missing`);
|
| 59 |
-
}
|
| 60 |
-
const commit = shell(`set -euo pipefail;
|
| 61 |
-
git config user.name "semantic-image-field refresh";
|
| 62 |
-
git config user.email "refresh@hf.space";
|
| 63 |
-
git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')};
|
| 64 |
-
if git diff --cached --quiet; then echo NOCHANGE; else git commit -q -m "chore: rebuild with semantic search fix"; echo COMMITTED; fi`).trim();
|
| 65 |
-
if (commit.includes('COMMITTED')) {
|
| 66 |
-
git('push', '-q', 'origin', 'main');
|
| 67 |
-
ok('pushed — the static Space will rebuild');
|
| 68 |
-
} else {
|
| 69 |
-
ok(`no change needed (HEAD ${shell('git rev-parse --short HEAD').trim()})`);
|
| 70 |
-
}
|
| 71 |
-
console.log('REFRESH_OK');
|
| 72 |
-
}
|
| 73 |
-
try { main(); }
|
| 74 |
-
catch (e) { console.error('REFRESH_FAILED:', e.message, e.stderr || ''); process.exitCode = 1; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-avatar-interiors.mjs
DELETED
|
@@ -1,287 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Tests for the vai-avatar interior sync and the featured/neon emphasis.
|
| 3 |
-
*
|
| 4 |
-
* The emphasis rules are the part most likely to rot silently: a wrong tile
|
| 5 |
-
* still renders, it just renders at the wrong size or without the ring, and
|
| 6 |
-
* nothing throws. These assert the geometry and the ordering, not just "no
|
| 7 |
-
* crash".
|
| 8 |
-
*
|
| 9 |
-
* The share-URL block is here for the same reason, and it is deliberately
|
| 10 |
-
* Node-only. The browser copy of this rule lives in dist/assets/share-fix.js as
|
| 11 |
-
* a string regex, because it has to run against the served bundle before a
|
| 12 |
-
* rebuild; the routing decision itself lives in src/assets.js. These tests pin
|
| 13 |
-
* the src/ half so the two cannot drift apart silently, and the browser half is
|
| 14 |
-
* asserted separately in scripts/test-neon-ui.mjs.
|
| 15 |
-
*/
|
| 16 |
-
import assert from 'node:assert/strict';
|
| 17 |
-
import { mergeResults } from '../src/merge.js';
|
| 18 |
-
import { foldText, searchAvatarInteriors } from '../src/avatar-interiors.js';
|
| 19 |
-
import { shareURL } from '../src/assets.js';
|
| 20 |
-
|
| 21 |
-
let passed = 0, failed = 0;
|
| 22 |
-
const test = (name, fn) => {
|
| 23 |
-
try { fn(); console.log(' ok ' + name); passed++; }
|
| 24 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message); failed++; }
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
-
const interior = (id, score) => ({ id: `interior:${id}`, blur: 'L000', ar: 1, score });
|
| 28 |
-
const product = (sku, score) => ({ sku, name: 'Tủ bếp', ar: 1, score, images: ['https://x/y.jpg'] });
|
| 29 |
-
|
| 30 |
-
console.log('\nvai-avatar interior sync');
|
| 31 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 32 |
-
|
| 33 |
-
test('Vietnamese folding makes diacritics optional on both sides', () => {
|
| 34 |
-
// The whole reason foldText exists: a visitor types "bep da", the record says
|
| 35 |
-
// "Bếp đá". Without folding these never match and the neon never fires.
|
| 36 |
-
assert.equal(foldText('Bếp đá'), 'bep da');
|
| 37 |
-
assert.equal(foldText('Phòng Ngủ'), 'phong ngu');
|
| 38 |
-
});
|
| 39 |
-
|
| 40 |
-
test('a whole-query match outranks a single-term match', () => {
|
| 41 |
-
const items = [
|
| 42 |
-
{ avatarImageId: 'a', title: 'sofa phòng khách hiện đại', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' },
|
| 43 |
-
{ avatarImageId: 'b', title: 'ghế sofa', description: 'mẫu ghế đơn', categories: [], keywords: [], addedAt: '2026-10-02T00:00:00Z' },
|
| 44 |
-
];
|
| 45 |
-
const hits = searchAvatarInteriors('sofa phòng khách', items);
|
| 46 |
-
assert.equal(hits[0].avatarImageId, 'a', 'the full-query match must rank first');
|
| 47 |
-
});
|
| 48 |
-
|
| 49 |
-
test('a whole-query match scores above the exact-outline threshold', () => {
|
| 50 |
-
// 900 is the same floor relevance.js uses, and it is what makes merge.js treat
|
| 51 |
-
// this as identity rather than resemblance. If this dropped below 700 the neon
|
| 52 |
-
// would silently stop appearing on newly added photographs.
|
| 53 |
-
const items = [{ avatarImageId: 'a', title: 'chuyển sang phong cách classical', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 54 |
-
assert.ok(searchAvatarInteriors('classical', items)[0].score >= 700);
|
| 55 |
-
});
|
| 56 |
-
|
| 57 |
-
test('an unrelated query returns nothing rather than everything', () => {
|
| 58 |
-
const items = [{ avatarImageId: 'a', title: 'sofa phòng khách', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 59 |
-
assert.equal(searchAvatarInteriors('móc treo', items).length, 0);
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
test('a query too short to be meaningful is rejected', () => {
|
| 63 |
-
const items = [{ avatarImageId: 'a', title: 'a', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 64 |
-
assert.equal(searchAvatarInteriors('a', items).length, 0);
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
console.log('\nshare URLs');
|
| 68 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 69 |
-
|
| 70 |
-
const STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 71 |
-
const broken = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
|
| 72 |
-
const fixed = `${STUDIO}/share/i/b68dc779370dc70e`;
|
| 73 |
-
|
| 74 |
-
test('the reported broken URL is now the interior route', () => {
|
| 75 |
-
// The exact address from the report. `avatarimg:` is a namespace for
|
| 76 |
-
// de-duplication, not a catalogue identity, so /share/p/ resolved to nothing
|
| 77 |
-
// and vai-avatar answered "Not Found" for every interior photograph.
|
| 78 |
-
assert.equal(shareURL({ avatarImageId: 'b68dc779370dc70e', sku: 'avatarimg:b68dc779370dc70e' }), fixed);
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
test('the bug is reproducible against the old logic', () => {
|
| 82 |
-
// Pins what the fix is fixing, so the assertion above cannot pass for an
|
| 83 |
-
// unrelated reason. Without the avatarImageId branch, `sku` wins and the link
|
| 84 |
-
// is the dead one -- byte-identical to the reported URL.
|
| 85 |
-
const photo = { sku: 'avatarimg:b68dc779370dc70e' };
|
| 86 |
-
const oldRoute = `${STUDIO}/share/p/${encodeURIComponent(photo.sku)}`;
|
| 87 |
-
assert.equal(oldRoute, broken, 'the reported URL must be what the old routing produced');
|
| 88 |
-
assert.notEqual(shareURL(photo), oldRoute, 'shareURL still routes an avatarimg sku to /share/p/');
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
test('an interior is routed even when it has lost avatarImageId', () => {
|
| 92 |
-
// merge.js rewrites `id` into a namespaced tile key, so a record can arrive
|
| 93 |
-
// carrying the sku but not the original field. The prefix is unwrapped rather
|
| 94 |
-
// than treated as an identity, which is what this fallback exists for.
|
| 95 |
-
assert.equal(shareURL({ sku: 'avatarimg:b68dc779370dc70e' }), fixed);
|
| 96 |
-
});
|
| 97 |
-
|
| 98 |
-
test('an empty avatarimg prefix yields no share URL at all', () => {
|
| 99 |
-
// /share/i/ with nothing after it is its own 404. Returning null lets the
|
| 100 |
-
// caller omit the button rather than render a dead link.
|
| 101 |
-
assert.equal(shareURL({ sku: 'avatarimg:' }), null);
|
| 102 |
-
assert.equal(shareURL({ avatarImageId: ' ' }), null);
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
test('a real product still routes to /share/p/', () => {
|
| 106 |
-
// The fix must not disturb the route that works. A product keeps its slug, and
|
| 107 |
-
// /share/p/ resolves all three identity forms.
|
| 108 |
-
assert.equal(shareURL({ sku: 'MH800TC' }), `${STUDIO}/share/p/MH800TC`);
|
| 109 |
-
assert.equal(
|
| 110 |
-
shareURL({ sku: 'MH800TC', slug: 'mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm' }),
|
| 111 |
-
`${STUDIO}/share/p/mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm`,
|
| 112 |
-
'the slug must still win over the sku',
|
| 113 |
-
);
|
| 114 |
-
});
|
| 115 |
-
|
| 116 |
-
test('a baked interior without a sku keeps its own route', () => {
|
| 117 |
-
// No sku at all: the id branch is what answers, and it must not be captured by
|
| 118 |
-
// the avatarimg fallback.
|
| 119 |
-
assert.equal(shareURL({ id: 'a1b2c3d4e5' }), `${STUDIO}/share/i/a1b2c3d4e5`);
|
| 120 |
-
});
|
| 121 |
-
|
| 122 |
-
test('a record with nothing to share returns null', () => {
|
| 123 |
-
assert.equal(shareURL(null), null);
|
| 124 |
-
assert.equal(shareURL(undefined), null);
|
| 125 |
-
assert.equal(shareURL({}), null);
|
| 126 |
-
assert.equal(shareURL('nope'), null);
|
| 127 |
-
});
|
| 128 |
-
|
| 129 |
-
console.log('\nfeatured tiles');
|
| 130 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 131 |
-
|
| 132 |
-
test('vai-avatar interiors land at the head of the field', () => {
|
| 133 |
-
// "near the search box" is implemented as list order: index 0 is the slot
|
| 134 |
-
// nearest the pill. A new photograph must not land mid-field.
|
| 135 |
-
const out = mergeResults(
|
| 136 |
-
[interior('a', 0.9), interior('b', 0.8)],
|
| 137 |
-
[],
|
| 138 |
-
{ avatarInteriors: [{ avatarImageId: 'new1', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' }] },
|
| 139 |
-
);
|
| 140 |
-
assert.equal(out[0].avatarImageId, 'new1');
|
| 141 |
-
assert.ok(out[0].featured, 'a new photograph is featured');
|
| 142 |
-
});
|
| 143 |
-
|
| 144 |
-
test('newest first: the most recent photograph is the first tile', () => {
|
| 145 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 146 |
-
{ avatarImageId: 'older', title: 'Cũ', addedAt: '2026-10-01T00:00:00Z' },
|
| 147 |
-
{ avatarImageId: 'newer', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
|
| 148 |
-
] });
|
| 149 |
-
assert.equal(out[0].avatarImageId, 'newer');
|
| 150 |
-
});
|
| 151 |
-
|
| 152 |
-
test('a featured tile is measurably larger than an ordinary one', () => {
|
| 153 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 154 |
-
{ avatarImageId: 'n', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
|
| 155 |
-
] });
|
| 156 |
-
assert.ok(out[0].featuredScale > 1, 'featuredScale must exceed 1');
|
| 157 |
-
});
|
| 158 |
-
|
| 159 |
-
test('a featured interior keeps a finite score so it is not dimmed', () => {
|
| 160 |
-
// relevanceAppearance() dims any tile whose score is not finite -- a featured
|
| 161 |
-
// tile rendered at 14% opacity would be the opposite of the request.
|
| 162 |
-
const out = mergeResults([], [], { avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }] });
|
| 163 |
-
assert.ok(Number.isFinite(out[0].score), 'score must be finite, got ' + out[0].score);
|
| 164 |
-
});
|
| 165 |
-
|
| 166 |
-
test('a decisive product winner is featured and reaches the head', () => {
|
| 167 |
-
// 1000 = exact SKU in vai-avatar's scorer, next tier is 550, so this is
|
| 168 |
-
// identity, and it should be presented like the answer rather than like a hit.
|
| 169 |
-
// The shape is the worker's own `{ sku, score, product }` wrapper.
|
| 170 |
-
const out = mergeResults(
|
| 171 |
-
[interior('a', 0.9)],
|
| 172 |
-
[{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'Tủ bếp', ar: 1, images: ['https://cdn/x.jpg'] } }],
|
| 173 |
-
{ query: 'SKU1' },
|
| 174 |
-
);
|
| 175 |
-
const hit = out.find(r => r.sku === 'SKU1');
|
| 176 |
-
assert.ok(hit?.featured, 'the exact product is featured');
|
| 177 |
-
assert.ok(hit.featuredScale > 1);
|
| 178 |
-
});
|
| 179 |
-
|
| 180 |
-
test('a non-decisive product is NOT featured', () => {
|
| 181 |
-
// Two products scoring 1000 and 900 is a model family, not an answer. Enlarging
|
| 182 |
-
// it would be misleading, and the neon must not ring it either.
|
| 183 |
-
const out = mergeResults([interior('a', 0.9)], [
|
| 184 |
-
{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'A', ar: 1, images: ['https://cdn/x.jpg'] } },
|
| 185 |
-
{ sku: 'SKU2', score: 900, product: { sku: 'SKU2', name: 'B', ar: 1, images: ['https://cdn/x.jpg'] } },
|
| 186 |
-
], { query: 'SKU1' });
|
| 187 |
-
assert.ok(!out.find(r => r.sku === 'SKU1')?.featured, 'a tie must not be featured');
|
| 188 |
-
});
|
| 189 |
-
|
| 190 |
-
test('enlargement and the neon agree: every featured interior is an exact hit', () => {
|
| 191 |
-
// The two decisions come from one source (exactMatches). This asserts the
|
| 192 |
-
// consequence -- a tile is never enlarged without being ringed.
|
| 193 |
-
const out = mergeResults([interior('a', 0.9)], [], {
|
| 194 |
-
avatarInteriors: [{ avatarImageId: 'n', title: 'phong cách classical', score: 900 }],
|
| 195 |
-
query: 'classical',
|
| 196 |
-
});
|
| 197 |
-
const tile = out[0];
|
| 198 |
-
assert.ok(tile.featured && tile.featuredScale > 1);
|
| 199 |
-
});
|
| 200 |
-
|
| 201 |
-
console.log('\nnamespace safety');
|
| 202 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 203 |
-
|
| 204 |
-
test('an avatar interior cannot be shadowed by a colliding raw id', () => {
|
| 205 |
-
// A bare id in a shared namespace would let one record silently drop another
|
| 206 |
-
// with no error. Namespacing is what makes that impossible.
|
| 207 |
-
const out = mergeResults([interior('n', 0.5)], [], {
|
| 208 |
-
avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }],
|
| 209 |
-
});
|
| 210 |
-
assert.equal(out.length, 2, 'both records survive: ' + JSON.stringify(out.map(r => r.id)));
|
| 211 |
-
});
|
| 212 |
-
|
| 213 |
-
test('a duplicate avatar interior is listed once', () => {
|
| 214 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 215 |
-
{ avatarImageId: 'n', title: 'Mới' }, { avatarImageId: 'n', title: 'Mới' },
|
| 216 |
-
] });
|
| 217 |
-
assert.equal(out.filter(r => r.avatarImageId === 'n').length, 1);
|
| 218 |
-
});
|
| 219 |
-
|
| 220 |
-
test('an interior without an avatar id is skipped, not rendered blank', () => {
|
| 221 |
-
const out = mergeResults([interior('a', 0.9)], [], { avatarInteriors: [{ title: 'không có id' }] });
|
| 222 |
-
assert.equal(out.length, 1);
|
| 223 |
-
});
|
| 224 |
-
|
| 225 |
-
test('an empty avatar list changes nothing', () => {
|
| 226 |
-
const base = mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)]);
|
| 227 |
-
assert.equal(mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)], { avatarInteriors: [] }).length, base.length);
|
| 228 |
-
});
|
| 229 |
-
|
| 230 |
-
console.log('\nonly the newest ring');
|
| 231 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 232 |
-
|
| 233 |
-
/** Build `n` avatar interiors, newest last, and merge them into the field. */
|
| 234 |
-
const freshSet = n => Array.from({ length: n }, (_, i) => ({
|
| 235 |
-
avatarImageId: `id${i}`,
|
| 236 |
-
title: `Ảnh ${i}`,
|
| 237 |
-
addedAt: `2026-10-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`,
|
| 238 |
-
}));
|
| 239 |
-
|
| 240 |
-
test('exactly ten of many new photographs carry the neon', () => {
|
| 241 |
-
// The brief asks for more photographs on the page AND for only the newest to
|
| 242 |
-
// ring. Those pull against each other, so the count is pinned: 24 rings is
|
| 243 |
-
// decoration, not emphasis.
|
| 244 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 245 |
-
const ringed = out.filter(r => r.featured);
|
| 246 |
-
assert.equal(ringed.length, 10, 'expected 10 ringed, got ' + ringed.length);
|
| 247 |
-
});
|
| 248 |
-
|
| 249 |
-
test('the un-ringed ones are still shown as ordinary tiles', () => {
|
| 250 |
-
// "Chỉ chọn 10 ảnh mới nhất có viền neon còn lại hiển thị bình thường" -- the
|
| 251 |
-
// rest must be VISIBLE, just not ringed. Dropping them would satisfy the first
|
| 252 |
-
// half of the sentence and break the second.
|
| 253 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 254 |
-
assert.equal(out.length, 40, 'all 40 should be in the field, got ' + out.length);
|
| 255 |
-
assert.equal(out.filter(r => !r.featured).length, 30);
|
| 256 |
-
});
|
| 257 |
-
|
| 258 |
-
test('an un-ringed tile has no featuredScale, so it renders at normal size', () => {
|
| 259 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 260 |
-
const plain = out.filter(r => !r.featured);
|
| 261 |
-
assert.ok(plain.every(r => r.featuredScale === undefined), 'an ordinary tile carries a scale');
|
| 262 |
-
});
|
| 263 |
-
|
| 264 |
-
test('the ten ringed are the newest ten, in order', () => {
|
| 265 |
-
// mergedAt is per-record, so equal dates sort by input order; distinct dates
|
| 266 |
-
// make the intent unambiguous.
|
| 267 |
-
const items = Array.from({ length: 15 }, (_, i) => ({
|
| 268 |
-
avatarImageId: `x${i}`,
|
| 269 |
-
title: `Ảnh ${i}`,
|
| 270 |
-
addedAt: `2026-10-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
| 271 |
-
}));
|
| 272 |
-
const ringed = mergeResults([], [], { avatarInteriors: items }).filter(r => r.featured);
|
| 273 |
-
assert.equal(ringed.length, 10);
|
| 274 |
-
// x14 is day 15, the newest. The ringed set must be days 15 down to 6.
|
| 275 |
-
assert.equal(ringed[0].avatarImageId, 'x14', 'newest is not first');
|
| 276 |
-
assert.equal(ringed[9].avatarImageId, 'x5');
|
| 277 |
-
const plain = mergeResults([], [], { avatarInteriors: items }).filter(r => !r.featured);
|
| 278 |
-
assert.equal(plain[0].avatarImageId, 'x4', 'the oldest ringed-off one is day 5');
|
| 279 |
-
});
|
| 280 |
-
|
| 281 |
-
test('fewer than ten new photographs still all ring', () => {
|
| 282 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(4) });
|
| 283 |
-
assert.equal(out.filter(r => r.featured).length, 4);
|
| 284 |
-
});
|
| 285 |
-
|
| 286 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 287 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-merge.mjs
CHANGED
|
@@ -57,7 +57,7 @@ test('gives each product a resolvable absolute src', () => {
|
|
| 57 |
});
|
| 58 |
|
| 59 |
test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
|
| 60 |
-
const wrapped = mergeResults(interiors(4), [{ sku: 'A', score:
|
| 61 |
const flat = mergeResults(interiors(4), [product('A')]);
|
| 62 |
assert.equal(wrapped.filter(r => r.sku).length, 1);
|
| 63 |
assert.equal(flat.filter(r => r.sku).length, 1);
|
|
@@ -84,7 +84,7 @@ test('never duplicates an interior or a product', () => {
|
|
| 84 |
test('caps how many products enter the field', () => {
|
| 85 |
const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
|
| 86 |
const out = mergeResults(interiors(60), many);
|
| 87 |
-
assert.equal(out.filter(r => r.sku).length,
|
| 88 |
// The strongest catalogue hits are the ones kept.
|
| 89 |
assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
|
| 90 |
});
|
|
@@ -161,50 +161,4 @@ test('interior scores are never modified', () => {
|
|
| 161 |
assert.deepEqual(ranked.map(r => r.score), before);
|
| 162 |
});
|
| 163 |
|
| 164 |
-
test('a product query crowds interiors out of the field', () => {
|
| 165 |
-
// "kệ xoong" is a product ask, so once a product clears the keyword floor the
|
| 166 |
-
// CLIP interiors must not be sprinkled through the results -- only the racks.
|
| 167 |
-
// The product's name must actually carry the query's words (or their rack
|
| 168 |
-
// synonyms), which is what separates a product answer from a substring hit.
|
| 169 |
-
const ranked = interiors(30).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
|
| 170 |
-
const hits = [
|
| 171 |
-
{ sku: 'KV1', name: 'Kệ xoong nồi inox', ar: 1, images: ['https://cdn.example/kv1.jpg'], score: 367 },
|
| 172 |
-
{ sku: 'KV2', name: 'Giá xoong nồi inox', ar: 1, images: ['https://cdn.example/kv2.jpg'], score: 367 },
|
| 173 |
-
];
|
| 174 |
-
const out = mergeResults(ranked, hits, { query: 'kệ xoong' });
|
| 175 |
-
assert.equal(out.filter(r => r.sku).length, 2);
|
| 176 |
-
assert.equal(out.filter(r => !r.sku).length, 0, 'interiors must be dropped for a product query');
|
| 177 |
-
});
|
| 178 |
-
|
| 179 |
-
test('browse mode keeps interiors even when products arrive', () => {
|
| 180 |
-
// No query means browse: the newest interiors front row must stay; the
|
| 181 |
-
// index-time products are decoration, not the answer.
|
| 182 |
-
const ranked = interiors(6).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
|
| 183 |
-
const out = mergeResults(ranked, [product('A', { score: 367 })]);
|
| 184 |
-
assert.equal(out.filter(r => !r.sku).length, 6, 'browse keeps all interiors');
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
test('every product hit above the keyword floor is featured (neon ring + scale)', () => {
|
| 188 |
-
// The brief wants "toàn bộ ảnh là kệ xoong nội... có viền neon và kích thước
|
| 189 |
-
// thư giãn hơn": all relevant racks, not just the top 3, get the ring+scale.
|
| 190 |
-
const hits = [
|
| 191 |
-
product('A', { score: 367 }), product('B', { score: 367 }),
|
| 192 |
-
product('C', { score: 367 }), product('D', { score: 367 }),
|
| 193 |
-
];
|
| 194 |
-
const out = mergeResults(interiors(2), hits, { query: 'kệ xoong' });
|
| 195 |
-
const featured = out.filter(r => r.sku && r.featured);
|
| 196 |
-
assert.equal(featured.length, 4, 'all keyword hits above the floor are featured');
|
| 197 |
-
});
|
| 198 |
-
|
| 199 |
-
test('a product whose only lead points into a dead V.AISTUDIO dir is skipped', () => {
|
| 200 |
-
const dead = {
|
| 201 |
-
sku: 'EPD4635VX', name: 'Giá', ar: 1,
|
| 202 |
-
images: ['https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/eurogold_img/EPD4635VX.jpg'],
|
| 203 |
-
};
|
| 204 |
-
const live = product('EPO8360VX');
|
| 205 |
-
const out = mergeResults(interiors(3), [dead, live]);
|
| 206 |
-
const skus = out.filter(r => r.sku).map(r => r.sku);
|
| 207 |
-
assert.deepEqual(skus, ['EPO8360VX']);
|
| 208 |
-
});
|
| 209 |
-
|
| 210 |
console.log(`\n${passed} passed`);
|
|
|
|
| 57 |
});
|
| 58 |
|
| 59 |
test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
|
| 60 |
+
const wrapped = mergeResults(interiors(4), [{ sku: 'A', score: 9, product: product('A') }]);
|
| 61 |
const flat = mergeResults(interiors(4), [product('A')]);
|
| 62 |
assert.equal(wrapped.filter(r => r.sku).length, 1);
|
| 63 |
assert.equal(flat.filter(r => r.sku).length, 1);
|
|
|
|
| 84 |
test('caps how many products enter the field', () => {
|
| 85 |
const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
|
| 86 |
const out = mergeResults(interiors(60), many);
|
| 87 |
+
assert.equal(out.filter(r => r.sku).length, 12);
|
| 88 |
// The strongest catalogue hits are the ones kept.
|
| 89 |
assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
|
| 90 |
});
|
|
|
|
| 161 |
assert.deepEqual(ranked.map(r => r.score), before);
|
| 162 |
});
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
console.log(`\n${passed} passed`);
|
scripts/test-neon-ui.mjs
DELETED
|
@@ -1,256 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser check for the featured + running-neon emphasis.
|
| 3 |
-
*
|
| 4 |
-
* Complements test-avatar-interiors.mjs: that file proves the data is correct,
|
| 5 |
-
* this one proves the browser actually PAINTS it. The failure mode that matters
|
| 6 |
-
* most here is silent -- a mask-composite the browser ignores renders an opaque
|
| 7 |
-
* tile instead of a ring, which no assertion on the data would ever catch.
|
| 8 |
-
*
|
| 9 |
-
* node scripts/test-neon-ui.mjs # against a local preview
|
| 10 |
-
* BASE_URL=... node scripts/test-neon-ui.mjs
|
| 11 |
-
*/
|
| 12 |
-
import { chromium } from 'playwright';
|
| 13 |
-
|
| 14 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
|
| 15 |
-
let passed = 0, failed = 0;
|
| 16 |
-
const test = async (name, fn) => {
|
| 17 |
-
try { await fn(); console.log(' ok ' + name); passed++; }
|
| 18 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
|
| 19 |
-
};
|
| 20 |
-
const assert = (cond, message) => { if (!cond) throw new Error(message); };
|
| 21 |
-
|
| 22 |
-
const browser = await chromium.launch();
|
| 23 |
-
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
| 24 |
-
const errors = [];
|
| 25 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 26 |
-
|
| 27 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 28 |
-
// The worker downloads the CLIP text tower before it can answer; give it room.
|
| 29 |
-
await page.waitForSelector('.image-tile', { timeout: 120000 });
|
| 30 |
-
|
| 31 |
-
console.log('\nfield + controls');
|
| 32 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 33 |
-
|
| 34 |
-
await test('the field renders tiles', async () => {
|
| 35 |
-
const n = await page.locator('.image-tile').count();
|
| 36 |
-
assert(n > 10, 'expected a populated field, got ' + n);
|
| 37 |
-
});
|
| 38 |
-
|
| 39 |
-
await test('the size and position controls are gone', async () => {
|
| 40 |
-
// Explicitly asserted rather than assumed: the brief asked for them removed,
|
| 41 |
-
// and a leftover panel under the pill would be a visible regression.
|
| 42 |
-
assert(await page.locator('.field-controls').count() === 0, '.field-controls still present');
|
| 43 |
-
assert(await page.getByRole('button', { name: /kích thước|vị trí/i }).count() === 0, 'a control button is still rendered');
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
await test('the search pill is unobstructed', async () => {
|
| 47 |
-
const box = await page.locator('.search-pill').boundingBox();
|
| 48 |
-
assert(box && box.height > 20, 'the search pill has no box');
|
| 49 |
-
});
|
| 50 |
-
|
| 51 |
-
console.log('\nfeatured + neon');
|
| 52 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 53 |
-
|
| 54 |
-
await test('a fresh vai-avatar photograph appears as a featured tile', async () => {
|
| 55 |
-
// Waits for the live index rather than assuming the network was fast.
|
| 56 |
-
await page.waitForSelector('.image-tile.featured', { timeout: 60000 });
|
| 57 |
-
const n = await page.locator('.image-tile.featured').count();
|
| 58 |
-
assert(n > 0, 'no featured tile rendered');
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
await test('the featured tile is larger than an ordinary tile', async () => {
|
| 62 |
-
const featured = await page.locator('.image-tile.featured').first().boundingBox();
|
| 63 |
-
const ordinary = await page.locator('.image-tile:not(.featured)').first().boundingBox();
|
| 64 |
-
const area = b => b.width * b.height;
|
| 65 |
-
assert(area(featured) > area(ordinary) * 1.15,
|
| 66 |
-
`featured ${Math.round(area(featured))}px² vs ordinary ${Math.round(area(ordinary))}px²`);
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
await test('a fresh photograph loads from vai-avatar, CORS included', async () => {
|
| 70 |
-
// The tiles fall back to BlurHash silently on a CORS failure, so this is the
|
| 71 |
-
// only place that failure would show up at all.
|
| 72 |
-
await page.waitForFunction(() => {
|
| 73 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 74 |
-
return tile?.querySelector('img.loaded');
|
| 75 |
-
}, { timeout: 60000 });
|
| 76 |
-
});
|
| 77 |
-
|
| 78 |
-
await test('a fresh photograph wears the running neon with no query typed', async () => {
|
| 79 |
-
// Asserted separately from the exact-match case because the two are different
|
| 80 |
-
// CSS selectors: a newest photograph rings before anything has been searched,
|
| 81 |
-
// and dropping that would make the feature invisible on page load.
|
| 82 |
-
const ring = await page.evaluate(() => {
|
| 83 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 84 |
-
const style = getComputedStyle(tile, '::before');
|
| 85 |
-
return { animation: style.animationName, mask: style.maskImage || style.webkitMaskImage };
|
| 86 |
-
});
|
| 87 |
-
assert(ring.animation === 'neon-run', 'a fresh photograph has no running neon: ' + ring.animation);
|
| 88 |
-
assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
await test('the ring layer itself never moves', async () => {
|
| 92 |
-
// The regression this whole file is about. ::before carries the mask as well
|
| 93 |
-
// as the gradient, so rotating the layer rotates the mask too and the painted
|
| 94 |
-
// border slides off the tile's edges -- which on a 16:9 photograph reads as a
|
| 95 |
-
// skewed smear, not a light going round the frame.
|
| 96 |
-
//
|
| 97 |
-
// Asserted on a REAL tile, and cross-checked for shape: a square tile would
|
| 98 |
-
// hide a rotated mask just as well as it hid the original bug, so the test
|
| 99 |
-
// fails if it cannot find a tile that is demonstrably not square.
|
| 100 |
-
const ring = await page.evaluate(() => {
|
| 101 |
-
const nonSquare = [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')]
|
| 102 |
-
.map(tile => ({ tile, box: tile.getBoundingClientRect() }))
|
| 103 |
-
.filter(({ box }) => box.width > 40 && box.height > 40
|
| 104 |
-
&& Math.abs(box.width / box.height - 1) > 0.15);
|
| 105 |
-
if (!nonSquare.length) return { checked: false };
|
| 106 |
-
const { tile, box } = nonSquare[0];
|
| 107 |
-
const style = getComputedStyle(tile, '::before');
|
| 108 |
-
return {
|
| 109 |
-
checked: true,
|
| 110 |
-
transform: style.transform,
|
| 111 |
-
angle: style.getPropertyValue('--neon-angle').trim(),
|
| 112 |
-
ratio: box.width / box.height,
|
| 113 |
-
};
|
| 114 |
-
});
|
| 115 |
-
assert(ring.checked, 'no non-square ringed tile on screen to test against');
|
| 116 |
-
assert(/^(none|matrix\(1, 0, 0, 1, 0, 0\)|matrix\(1, 0, 0, 1, 0px, 0px\))$/.test(ring.transform),
|
| 117 |
-
`the ring layer is transformed (${ring.transform}) on a ${ring.ratio.toFixed(2)}:1 tile, so its mask has rotated off the frame`);
|
| 118 |
-
assert(ring.angle && /deg|rad|grad|turn/.test(ring.angle),
|
| 119 |
-
'the sweep angle is not resolving to an angle: "' + ring.angle + '"');
|
| 120 |
-
});
|
| 121 |
-
|
| 122 |
-
await test('a fresh photograph is fully lit, not dimmed as unranked', async () => {
|
| 123 |
-
const opacity = await page.evaluate(() => {
|
| 124 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 125 |
-
return parseFloat(getComputedStyle(tile).opacity);
|
| 126 |
-
});
|
| 127 |
-
assert(opacity > 0.9, 'a fresh photograph is faded to ' + opacity);
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
console.log('\nquery emphasis');
|
| 131 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 132 |
-
|
| 133 |
-
await test('an exact query rings a tile with the running neon', async () => {
|
| 134 |
-
await page.fill('input[type=text]', 'phòng khách');
|
| 135 |
-
await page.waitForSelector('.image-tile.exact', { timeout: 60000 });
|
| 136 |
-
|
| 137 |
-
const ring = await page.evaluate(() => {
|
| 138 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 139 |
-
const style = getComputedStyle(tile, '::before');
|
| 140 |
-
return {
|
| 141 |
-
animation: style.animationName,
|
| 142 |
-
background: style.backgroundImage,
|
| 143 |
-
mask: style.maskImage || style.webkitMaskImage,
|
| 144 |
-
};
|
| 145 |
-
});
|
| 146 |
-
assert(ring.animation === 'neon-run', 'the ring is not animating: ' + ring.animation);
|
| 147 |
-
assert(/conic-gradient/.test(ring.background), 'the ring is not a conic gradient');
|
| 148 |
-
assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
|
| 149 |
-
});
|
| 150 |
-
|
| 151 |
-
await test('the neon sweep actually advances between frames', async () => {
|
| 152 |
-
// A gradient that is present but not advancing is the exact failure the brief
|
| 153 |
-
// asked to avoid, and it looks correct in a single screenshot.
|
| 154 |
-
//
|
| 155 |
-
// Read the sweep angle, NOT `transform`. The transform is now pinned to none
|
| 156 |
-
// on purpose -- it is the thing that used to drag the mask off the frame -- so
|
| 157 |
-
// asserting on it would be asserting the old bug is still there.
|
| 158 |
-
const read = () => page.evaluate(() => {
|
| 159 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 160 |
-
if (!tile) return null;
|
| 161 |
-
const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
|
| 162 |
-
const value = Number.parseFloat(raw);
|
| 163 |
-
return Number.isFinite(value) ? value : raw;
|
| 164 |
-
});
|
| 165 |
-
const a = await read();
|
| 166 |
-
assert(a !== null, 'no exact tile to read');
|
| 167 |
-
await page.waitForTimeout(700);
|
| 168 |
-
const b = await read();
|
| 169 |
-
assert(a !== b, `the sweep angle did not change (${a} -> ${b})`);
|
| 170 |
-
});
|
| 171 |
-
|
| 172 |
-
await test('the head stays inside the frame on a non-square tile', async () => {
|
| 173 |
-
// The mask is the tile's own box, so the bright head has to sit on the edge
|
| 174 |
-
// for every aspect ratio. Verified by reading the gradient's own angle out of
|
| 175 |
-
// the computed style: it must be a resolved angle on every ringed tile, not
|
| 176 |
-
// just on the ones that happen to be square.
|
| 177 |
-
const angles = await page.evaluate(() => {
|
| 178 |
-
return [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')].map(tile => {
|
| 179 |
-
const box = tile.getBoundingClientRect();
|
| 180 |
-
const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
|
| 181 |
-
return { ratio: box.width / box.height, raw };
|
| 182 |
-
});
|
| 183 |
-
});
|
| 184 |
-
for (const { ratio, raw } of angles) {
|
| 185 |
-
assert(raw && /deg$/.test(raw),
|
| 186 |
-
`a ${ratio.toFixed(2)}:1 tile has sweep angle "${raw}", so the head is not on the frame`);
|
| 187 |
-
}
|
| 188 |
-
});
|
| 189 |
-
|
| 190 |
-
await test('an exact tile is never dimmed', async () => {
|
| 191 |
-
const opacity = await page.evaluate(() => {
|
| 192 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 193 |
-
return parseFloat(getComputedStyle(tile).opacity);
|
| 194 |
-
});
|
| 195 |
-
assert(opacity > 0.9, 'an exact match is faded to ' + opacity);
|
| 196 |
-
});
|
| 197 |
-
|
| 198 |
-
await test('a query with no exact answer rings nothing', async () => {
|
| 199 |
-
await page.fill('input[type=text]', 'zzzqqqxyz không tồn tại');
|
| 200 |
-
await page.waitForTimeout(6000);
|
| 201 |
-
assert(await page.locator('.image-tile.exact').count() === 0,
|
| 202 |
-
'an unresolvable query still produced an exact match');
|
| 203 |
-
});
|
| 204 |
-
|
| 205 |
-
console.log('\nshare links');
|
| 206 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 207 |
-
|
| 208 |
-
await test('an interior photograph never points /share/p/ at an avatarimg sku', async () => {
|
| 209 |
-
// The regression: `avatarimg:` is a namespace for de-duplication, not a
|
| 210 |
-
// catalogue identity, so /share/p/ resolves to nothing and vai-avatar answers
|
| 211 |
-
// "Not Found". Every interior photograph was affected.
|
| 212 |
-
//
|
| 213 |
-
// Driven off a hand-built anchor rather than the live field: the field's own
|
| 214 |
-
// hrefs depend on a network read of the vai-avatar interior index, which may
|
| 215 |
-
// not have landed when this runs. This asserts the routing rule itself.
|
| 216 |
-
const href = await page.evaluate(() => {
|
| 217 |
-
const anchor = document.createElement('a');
|
| 218 |
-
anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
|
| 219 |
-
document.body.append(anchor);
|
| 220 |
-
globalThis.__repairInteriorShareLinks?.();
|
| 221 |
-
const resolved = anchor.href;
|
| 222 |
-
anchor.remove();
|
| 223 |
-
return resolved;
|
| 224 |
-
});
|
| 225 |
-
assert(href === 'https://bep40-vai-avatar.hf.space/share/i/b68dc779370dc70e',
|
| 226 |
-
'the mis-routed link was not corrected, it resolved to ' + href);
|
| 227 |
-
});
|
| 228 |
-
|
| 229 |
-
await test('a real product link is left alone', async () => {
|
| 230 |
-
// The shim must not touch a link that resolves. Rewriting this would send a
|
| 231 |
-
// shopper to a 404 dressed up as a fix.
|
| 232 |
-
const href = await page.evaluate(() => {
|
| 233 |
-
const anchor = document.createElement('a');
|
| 234 |
-
anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/MH800TC';
|
| 235 |
-
document.body.append(anchor);
|
| 236 |
-
globalThis.__repairInteriorShareLinks?.();
|
| 237 |
-
const resolved = anchor.href;
|
| 238 |
-
anchor.remove();
|
| 239 |
-
return resolved;
|
| 240 |
-
});
|
| 241 |
-
assert(href === 'https://bep40-vai-avatar.hf.space/share/p/MH800TC',
|
| 242 |
-
'a valid product link was rewritten to ' + href);
|
| 243 |
-
});
|
| 244 |
-
|
| 245 |
-
console.log('\nno regressions');
|
| 246 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 247 |
-
|
| 248 |
-
await test('no uncaught page errors', async () => {
|
| 249 |
-
assert(errors.length === 0, errors.slice(0, 3).join(' | '));
|
| 250 |
-
});
|
| 251 |
-
|
| 252 |
-
await page.screenshot({ path: '/tmp/neon-field.png', fullPage: false });
|
| 253 |
-
await browser.close();
|
| 254 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 255 |
-
console.log('screenshot: /tmp/neon-field.png');
|
| 256 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-product-search.mjs
DELETED
|
@@ -1,173 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Verify the vai-avatar scorer ported into product-search.js.
|
| 3 |
-
*
|
| 4 |
-
* The point of these tests is parity with vai-avatar, not a notion of "good
|
| 5 |
-
* ranking". Each case is a query vai-avatar is known to handle a specific way,
|
| 6 |
-
* and the expectation is written down so a change in behaviour here is a
|
| 7 |
-
* visible diff rather than a silent reordering nobody notices until a customer
|
| 8 |
-
* says the wrong product came up.
|
| 9 |
-
*
|
| 10 |
-
* Run: node scripts/test-product-search.mjs
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
-
import { scoreProduct, searchLikeAvatar, extractTerms } from '../src/product-search.js';
|
| 14 |
-
import { hasDecisiveWinner, exactMatches } from '../src/relevance.js';
|
| 15 |
-
import { normalizeText } from '../src/catalogue.js';
|
| 16 |
-
|
| 17 |
-
/* ── catalogue.js's normaliseProduct() output shape ── */
|
| 18 |
-
function product({ name = '', model = '', brand = '', category = '', summary = '', feats = [], specs = [], sku = '', ...rest }) {
|
| 19 |
-
return { sku: sku || model || name, name, model, brand, category, summary, feats, specs, ...rest };
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
let passed = 0;
|
| 23 |
-
let failed = 0;
|
| 24 |
-
function check(label, actual, expected) {
|
| 25 |
-
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
| 26 |
-
if (ok) { passed++; console.log(` ok ${label}`); }
|
| 27 |
-
else { failed++; console.log(` FAIL ${label}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
|
| 28 |
-
}
|
| 29 |
-
function ok(label, condition, detail = '') {
|
| 30 |
-
if (condition) { passed++; console.log(` ok ${label}`); }
|
| 31 |
-
else { failed++; console.log(` FAIL ${label} ${detail}`); }
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
console.log('\nextractTerms');
|
| 35 |
-
check('folds diacritics and drops 1-char noise',
|
| 36 |
-
extractTerms('Bếp từ Mới'), ['bep', 'tu', 'moi']);
|
| 37 |
-
check('keeps domain words a stopword list would drop',
|
| 38 |
-
extractTerms('tủ lạnh'), ['tu', 'lanh'],
|
| 39 |
-
);
|
| 40 |
-
ok('"tu" survives: it is both "từ" and "tủ"', extractTerms('tu lanh').includes('tu'));
|
| 41 |
-
check('"đ" folds to d', extractTerms('đá Granite'), ['da', 'granite']);
|
| 42 |
-
|
| 43 |
-
console.log('\nexact model / sku beats everything');
|
| 44 |
-
const gp170 = product({ name: 'Bếp từ MALLOCA GP170BD 5 vùng', model: 'GP170BD', brand: 'Malloca', category: 'Bếp từ' });
|
| 45 |
-
const other = product({ name: 'Bếp từ MALLOCA GP900 3 vùng', model: 'GP900', brand: 'Malloca', category: 'Bếp từ' });
|
| 46 |
-
{
|
| 47 |
-
const terms = extractTerms('GP170BD');
|
| 48 |
-
const exact = scoreProduct(gp170, 'GP170BD', terms);
|
| 49 |
-
const near = scoreProduct(other, 'GP170BD', terms);
|
| 50 |
-
ok(`exact SKU (${exact}) outranks a near model (${near})`, exact > near);
|
| 51 |
-
ok('exact SKU crosses the neon threshold (>=700)', exact >= 700, `got ${exact}`);
|
| 52 |
-
ok('the near model does NOT', near < 700, `got ${near}`);
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
console.log('\ntitle containment');
|
| 56 |
-
{
|
| 57 |
-
const terms = extractTerms('khoá điện tử');
|
| 58 |
-
const smart = product({ name: 'Khóa cửa điện tử thông minh VAI', category: 'Khóa cửa thông minh', feats: ['Vân tay', 'Mã số'] });
|
| 59 |
-
const plainLock = product({ name: 'Khóa cửa thường cánh thông', category: 'Khóa cửa' });
|
| 60 |
-
ok(`smart lock (${scoreProduct(smart, 'khoá điện tử', terms)}) outranks plain (${scoreProduct(plainLock, 'khoá điện tử', terms)})`,
|
| 61 |
-
scoreProduct(smart, 'khoá điện tử', terms) > scoreProduct(plainLock, 'khoá điện tử', terms));
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
console.log('\nsize (mm) matching is numeric, not textual');
|
| 65 |
-
{
|
| 66 |
-
// The catalogue writes sizes inconsistently; "700" must find the 700mm rack
|
| 67 |
-
// and not be fooled by a 70cm or a price containing 700.
|
| 68 |
-
const rack700 = product({ name: 'Kệ xoong nồi inox 700mm 2 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '700 mm']] });
|
| 69 |
-
const rack900 = product({ name: 'Kệ xoong nồi inox 900mm 3 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '900 mm']] });
|
| 70 |
-
const terms = extractTerms('kệ xoong nồi 700mm');
|
| 71 |
-
const hit = scoreProduct(rack700, 'kệ xoong nồi 700mm', terms);
|
| 72 |
-
const miss = scoreProduct(rack900, 'kệ xoong nồi 700mm', terms);
|
| 73 |
-
ok(`700mm rack (${hit}) outranks 900mm rack (${miss})`, hit > miss);
|
| 74 |
-
const t2 = extractTerms('kệ 700');
|
| 75 |
-
ok('a bare "700" also finds the 700mm rack',
|
| 76 |
-
scoreProduct(rack700, 'kệ 700', t2) > scoreProduct(rack900, 'kệ 700', t2));
|
| 77 |
-
ok('and the 900 rack is actually penalised for not matching', miss < hit);
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
console.log('\nmaterial matching reads specs, not just the title');
|
| 81 |
-
{
|
| 82 |
-
const title = product({ name: 'Chậu rửa inox MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
|
| 83 |
-
const specsOnly = product({ name: 'Chậu rửa bàn MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
|
| 84 |
-
const terms = extractTerms('chậu rửa inox 304');
|
| 85 |
-
ok('a title mention scores', scoreProduct(title, 'chậu rửa inox 304', terms) > 0);
|
| 86 |
-
ok('a specs-only mention also scores', scoreProduct(specsOnly, 'chậu rửa inox 304', terms) > 0);
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
console.log('\nbrand bonus');
|
| 90 |
-
{
|
| 91 |
-
const malloca = product({ name: 'Bàn ăn gỗ Malloca', brand: 'Malloca', category: 'Bàn ăn' });
|
| 92 |
-
const otherBrand = product({ name: 'Bàn ăn gỗ Eurogold', brand: 'Eurogold', category: 'Bàn ăn' });
|
| 93 |
-
const terms = extractTerms('bàn ăn malloca');
|
| 94 |
-
ok(`query naming the brand wins it (${scoreProduct(malloca, 'bàn ăn malloca', terms)} > ${scoreProduct(otherBrand, 'bàn ăn malloca', terms)})`,
|
| 95 |
-
scoreProduct(malloca, 'bàn ăn malloca', terms) > scoreProduct(otherBrand, 'bàn ăn malloca', terms));
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
console.log('\ncovering the query beats one rare word');
|
| 99 |
-
{
|
| 100 |
-
const both = product({ name: 'Bếp từ MALLOCA 5 vùng', category: 'Bếp từ' });
|
| 101 |
-
const partial = product({ name: 'Bàn ghế gỗ sồi', category: 'Bàn ghế' });
|
| 102 |
-
const terms = extractTerms('bếp từ');
|
| 103 |
-
ok(`both terms in title (${scoreProduct(both, 'bếp từ', terms)}) beats one (${scoreProduct(partial, 'bếp từ', terms)})`,
|
| 104 |
-
scoreProduct(both, 'bếp từ', terms) > scoreProduct(partial, 'bếp từ', terms));
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
console.log('\nunrelated product scores zero');
|
| 108 |
-
{
|
| 109 |
-
const terms = extractTerms('bếp từ');
|
| 110 |
-
ok('a bicycle is not a kitchen product',
|
| 111 |
-
scoreProduct(product({ name: 'Xe đạp thể thao Giant', category: 'Xe đạp' }), 'bếp từ', terms) === 0);
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
console.log('\nsearchLikeAvatar ranks and limits');
|
| 115 |
-
{
|
| 116 |
-
const items = new Map([
|
| 117 |
-
['GP170BD', gp170],
|
| 118 |
-
['GP900', other],
|
| 119 |
-
['BD1054', product({ name: 'Máy giặt Hitachi Inverter 10.5kg BD-1054HVOW', brand: 'Hitachi', category: 'Máy giặt' })],
|
| 120 |
-
]);
|
| 121 |
-
const top = searchLikeAvatar('GP170BD', items, 5);
|
| 122 |
-
ok('the exact model is first', top[0]?.sku === 'GP170BD', `got ${top[0]?.sku}`);
|
| 123 |
-
check('respects the limit', searchLikeAvatar('bếp', items, 2).length <= 2, true);
|
| 124 |
-
check('an empty query returns nothing', searchLikeAvatar('', items, 5).length, 0);
|
| 125 |
-
check('a query matching nothing returns nothing', searchLikeAvatar('xe đạp', items, 5).length, 0);
|
| 126 |
-
check('a missing catalogue returns nothing', searchLikeAvatar('bếp', null, 5).length, 0);
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
console.log('\nhasDecisiveWinner (the neon gate for products)');
|
| 130 |
-
ok('a 1000-point SKU match with a weak runner-up qualifies',
|
| 131 |
-
hasDecisiveWinner([1000, 20]));
|
| 132 |
-
ok('a lone 1000 with nothing else qualifies',
|
| 133 |
-
hasDecisiveWinner([1000]));
|
| 134 |
-
ok('a mere 550 (the "contains" tier) does not',
|
| 135 |
-
!hasDecisiveWinner([550, 10]));
|
| 136 |
-
ok('a near-tie does not, so a model family does not all light up',
|
| 137 |
-
!hasDecisiveWinner([1000, 900]));
|
| 138 |
-
ok('an empty list does not',
|
| 139 |
-
!hasDecisiveWinner([]));
|
| 140 |
-
ok('a missing list does not',
|
| 141 |
-
!hasDecisiveWinner(undefined));
|
| 142 |
-
|
| 143 |
-
console.log('\nexactMatches — which tiles get the outline');
|
| 144 |
-
{
|
| 145 |
-
// A decisive product winner, keyed the way merge.js keys product tiles.
|
| 146 |
-
const ids = exactMatches([], 'GP170BD', [{ sku: 'GP170BD', score: 1316 }, { sku: 'GP900', score: 10 }]);
|
| 147 |
-
ok('the decisive product is outlined', ids.has('malloca:GP170BD'));
|
| 148 |
-
// A relevance-only winner must not light up.
|
| 149 |
-
const weak = exactMatches([], 'bếp inox', [{ sku: 'A', score: 60 }, { sku: 'B', score: 10 }]);
|
| 150 |
-
ok('a mere relevance match is not outlined', weak.size === 0);
|
| 151 |
-
// Interior: the query appears verbatim in the record's own title.
|
| 152 |
-
const interiors = exactMatches(
|
| 153 |
-
[{ id: 'abc123', score: 0.31, title: 'Bếp đá marble cao cấp', categories: ['Nội thất bếp'] }, { id: 'def456', score: 0.29, title: 'Phòng khách hiện đại' }],
|
| 154 |
-
'bếp đá',
|
| 155 |
-
);
|
| 156 |
-
ok('an interior whose title contains the query is outlined', interiors.has('abc123'));
|
| 157 |
-
ok('the merely-similar interior is not', !interiors.has('def456'));
|
| 158 |
-
// Accents must fold on both sides.
|
| 159 |
-
const folded = exactMatches([{ id: 'x', score: 0.3, title: 'Bep Da Marble' }], 'bếp đá');
|
| 160 |
-
ok('accent folding works both ways', folded.has('x'));
|
| 161 |
-
// A short query must not match everything by accident.
|
| 162 |
-
ok('a 1-2 character query outlines nothing', exactMatches([{ id: 'x', score: 0.3, title: 'a b' }], 'a').size === 0);
|
| 163 |
-
ok('an empty query outlines nothing', exactMatches([{ id: 'x', score: 0.9, title: 'Bếp đá' }], ' ').size === 0);
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
console.log('\nnormalisation parity with catalogue.js');
|
| 167 |
-
{
|
| 168 |
-
ok('normalizeText folds the same way in both modules',
|
| 169 |
-
normalizeText('Bếp Từ Mây') === 'bep tu may');
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 173 |
-
if (failed) process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-semantic-search.mjs
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Nghiệm thu hành vi tìm kiếm: ngữ nghĩa (ảnh interiors) vs lookup sản phẩm.
|
| 3 |
-
*
|
| 4 |
-
* Chạy: node scripts/test-semantic-search.mjs
|
| 5 |
-
*
|
| 6 |
-
* Bốn nhóm kiểm tra, đúng bốn cam kết của Space:
|
| 7 |
-
*
|
| 8 |
-
* 1. Phân loại từ khóa -- "bàn"/"ghế"/"nội thất"/"nhà phố" là từ ngữ
|
| 9 |
-
* nghĩa; "kệ"/"xoong"/"bếp"/"tủ"/"máy" vẫn là từ sản phẩm.
|
| 10 |
-
* 2. Đồng nghĩa -- "nhà phố" mở rộng ra "nhà ống"; "biệt thự" ra "villa".
|
| 11 |
-
* 3. Ảnh interiors -- tìm "nhà phố" thấy caption "Nhà ống", KHÔNG thấy
|
| 12 |
-
* "Đèn ống" (false positive đã được đo và loại).
|
| 13 |
-
* 4. Crowd-out -- "bàn ghế"/"nội thất" GIỮ ảnh interiors; "kệ xoong"
|
| 14 |
-
* VẪN gỡ interiors (chỉ còn sản phẩm); lookup SKU chính xác vẫn
|
| 15 |
-
* crowd-out và trả đúng 1 sản phẩm đầu tiên.
|
| 16 |
-
*/
|
| 17 |
-
import { productLikeTerms, isSemanticTerm, expandQueryTerms } from '../src/semantic-terms.js';
|
| 18 |
-
import { mergeResults } from '../src/merge.js';
|
| 19 |
-
import { searchLikeAvatar } from '../src/product-search.js';
|
| 20 |
-
import { searchAvatarInteriors } from '../src/avatar-interiors.js';
|
| 21 |
-
|
| 22 |
-
let failures = 0;
|
| 23 |
-
function check(name, ok, detail = '') {
|
| 24 |
-
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
|
| 25 |
-
if (!ok) failures++;
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
/** Ghi sản phẩm vào dạng Map mà catalogue.js/buildCatalogue() trả về. */
|
| 29 |
-
function toItems(products) {
|
| 30 |
-
const items = new Map();
|
| 31 |
-
for (const p of products) items.set(p.sku, p);
|
| 32 |
-
return items;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
/* ─────────────────────── 1. Phân loại từ khóa ─────────────────────── */
|
| 36 |
-
|
| 37 |
-
check('"bàn ghế" toàn từ ngữ nghĩa', productLikeTerms(['ban', 'ghe']).length === 0);
|
| 38 |
-
check('"kệ xoong" toàn từ sản phẩm', JSON.stringify(productLikeTerms(['ke', 'xoong'])) === '["ke","xoong"]');
|
| 39 |
-
check('"nội thất" toàn từ ngữ nghĩa', productLikeTerms(['noi', 'that']).length === 0);
|
| 40 |
-
check('"nhà phố" toàn từ ngữ nghĩa', productLikeTerms(['nha', 'pho']).length === 0);
|
| 41 |
-
check('"biệt thự" toàn từ ngữ nghĩa', productLikeTerms(['biet', 'thu']).length === 0);
|
| 42 |
-
check('"bếp từ" vẫn là từ sản phẩm', productLikeTerms(['bep', 'tu']).length === 2);
|
| 43 |
-
check('"máy hút mùi" vẫn là từ sản phẩm', productLikeTerms(['may', 'hut', 'mui']).length === 3);
|
| 44 |
-
check('isSemanticTerm(ban) đúng', isSemanticTerm('ban') === true);
|
| 45 |
-
check('isSemanticTerm(ke) sai (kệ là dòng SP chủ lực)', isSemanticTerm('ke') === false);
|
| 46 |
-
|
| 47 |
-
/* ─────────────────────── 2. Đồng nghĩa ─────────────────────── */
|
| 48 |
-
|
| 49 |
-
check('mở rộng "nhà phố" có "ong" (nhà ống)', expandQueryTerms(['nha', 'pho']).includes('ong'));
|
| 50 |
-
check('mở rộng "biệt thự" có "villa"', expandQueryTerms(['biet', 'thu']).includes('villa'));
|
| 51 |
-
check('mở rộng "nội thất" có "that"', expandQueryTerms(['noi']).includes('that'));
|
| 52 |
-
check('mở rộng "ghe" sinh "ban" (cặp bàn-ghế là đồng nghĩa thật)', JSON.stringify(expandQueryTerms(['ghe'])) === '["ghe","ban"]');
|
| 53 |
-
|
| 54 |
-
/* ─────────────────────── 3. Ảnh interiors (caption lexical) ─────────────────────── */
|
| 55 |
-
|
| 56 |
-
const avatarInteriors = [
|
| 57 |
-
{ sku: 'avatarimg:1', avatarImageId: '1', title: 'Nhà ống mặt tiền', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/1.jpg'], src: 'https://cdn.example/1.jpg' },
|
| 58 |
-
{ sku: 'avatarimg:2', avatarImageId: '2', title: 'Biệt thự vườn xanh', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/2.jpg'], src: 'https://cdn.example/2.jpg' },
|
| 59 |
-
{ sku: 'avatarimg:3', avatarImageId: '3', title: 'Đèn ống LED âm trần', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/3.jpg'], src: 'https://cdn.example/3.jpg' },
|
| 60 |
-
{ sku: 'avatarimg:4', avatarImageId: '4', title: 'Nội thất phòng khách gỗ', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/4.jpg'], src: 'https://cdn.example/4.jpg' },
|
| 61 |
-
];
|
| 62 |
-
|
| 63 |
-
const nhaPhoHits = searchAvatarInteriors('nhà phố', avatarInteriors);
|
| 64 |
-
check('tìm "nhà phố" thấy ảnh "Nhà ống"', nhaPhoHits.some(r => r.avatarImageId === '1'), nhaPhoHits.map(r => r.title).join(' | '));
|
| 65 |
-
check('tìm "nhà phố" thấy ảnh "Biệt thự"', nhaPhoHits.some(r => r.avatarImageId === '2'));
|
| 66 |
-
check('tìm "nhà phố" KHÔNG thấy "Đèn ống" (false positive)', !nhaPhoHits.some(r => r.avatarImageId === '3'), nhaPhoHits.map(r => r.title).join(' | '));
|
| 67 |
-
|
| 68 |
-
const noiThatHits = searchAvatarInteriors('nội thất', avatarInteriors);
|
| 69 |
-
check('tìm "nội thất" thấy ảnh "Nội thất phòng khách"', noiThatHits.some(r => r.avatarImageId === '4'), noiThatHits.map(r => r.title).join(' | '));
|
| 70 |
-
|
| 71 |
-
/* ─────────────────────── 4. Crowd-out trong mergeResults ─────────────────────── */
|
| 72 |
-
|
| 73 |
-
/** Ảnh interiors đã được CLIP xếp hạng (điểm cosine), dạng worker trả về. */
|
| 74 |
-
const clipInteriors = [
|
| 75 |
-
{ id: 'a1b2c3d4e5', url: 'phong-khach-01.jpg', score: 0.91, title: 'Phòng khách gỗ', category: 'phong khach' },
|
| 76 |
-
{ id: 'f6e5d4c3b2', url: 'phong-khach-02.jpg', score: 0.87, title: 'Phòng khách sáng', category: 'phong khach' },
|
| 77 |
-
];
|
| 78 |
-
|
| 79 |
-
const banGhe = toItems([
|
| 80 |
-
{ sku: 'BAN-OC-001', model: 'BAN-OC-001', name: 'Bàn ghế gỗ óc chó', brand: 'Malloca', category: 'ban-ghe', summary: 'Bàn ghế ăn gỗ óc chó nhập khẩu', specs: [], feats: [], images: ['https://cdn.example/ban.jpg'], price: '4.500.000₫', priceNum: 4500000 },
|
| 81 |
-
]);
|
| 82 |
-
const banGheHits = searchLikeAvatar('bàn ghế', banGhe, 24);
|
| 83 |
-
console.log(` [info] score "bàn ghế" = ${banGheHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 84 |
-
|
| 85 |
-
const mergedBanGhe = mergeResults(clipInteriors, banGheHits, { query: 'bàn ghế', avatarInteriors: [], recentProducts: [] });
|
| 86 |
-
// CLIP interior tiles giữ id GỐC (a1b2c3d4e5...), không có prefix `interior:` —
|
| 87 |
-
// prefix chỉ là key de-dup nội bộ của mergeResults. Kiểm tra trên id gốc.
|
| 88 |
-
check('tìm "bàn ghế" VẪN GIỮ ảnh interiors (semantic)', mergedBanGhe.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedBanGhe.map(t => t.id).join(', '));
|
| 89 |
-
check('tìm "bàn ghế" vẫn có sản phẩm xen kẽ', mergedBanGhe.some(t => t.sku === 'BAN-OC-001'));
|
| 90 |
-
|
| 91 |
-
const noiThat = toItems([
|
| 92 |
-
{ sku: 'NT-001', model: 'NT-001', name: 'Nội thất gỗ phòng khách', brand: 'Malloca', category: 'noi-that', summary: '', specs: [], feats: [], images: ['https://cdn.example/nt.jpg'], price: '7.000.000₫', priceNum: 7000000 },
|
| 93 |
-
]);
|
| 94 |
-
const noiThatProductHits = searchLikeAvatar('nội thất', noiThat, 24);
|
| 95 |
-
const mergedNoiThat = mergeResults(clipInteriors, noiThatProductHits, { query: 'nội thất', avatarInteriors: [], recentProducts: [] });
|
| 96 |
-
check('tìm "nội thất" VẪN GIỮ ảnh interiors (semantic)', mergedNoiThat.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedNoiThat.map(t => t.id).join(', '));
|
| 97 |
-
|
| 98 |
-
const keXoong = toItems([
|
| 99 |
-
{ sku: 'GX-170', model: 'GX-170', name: 'Giá xoong nồi inox Eurogold', brand: 'Eurogold', category: 'ke-xoong', summary: 'Kệ xoong treo tường inox 304', specs: [], feats: [], images: ['https://cdn.example/ke.jpg'], price: '590.000₫', priceNum: 590000 },
|
| 100 |
-
]);
|
| 101 |
-
const keXoongHits = searchLikeAvatar('kệ xoong', keXoong, 24);
|
| 102 |
-
console.log(` [info] score "kệ xoong" = ${keXoongHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 103 |
-
const mergedKeXoong = mergeResults(clipInteriors, keXoongHits, { query: 'kệ xoong', avatarInteriors: [], recentProducts: [] });
|
| 104 |
-
check('tìm "kệ xoong" VẪN gỡ interiors (product query)', !mergedKeXoong.some(t => String(t.id).startsWith('interior:')), mergedKeXoong.map(t => t.id).join(', '));
|
| 105 |
-
check('tìm "kệ xoong" chỉ còn sản phẩm', mergedKeXoong.every(t => t.sku === 'GX-170'));
|
| 106 |
-
|
| 107 |
-
/* ─────────────── 5. Lookup SKU: chính xác 100%, vẫn crowd-out ─────────────── */
|
| 108 |
-
|
| 109 |
-
const skuMap = toItems([
|
| 110 |
-
{ sku: 'GP170BD', model: 'GP170BD', name: 'Giá xoong nồi Eurogold GP170BD', brand: 'Eurogold', category: 'ke-xoong', summary: '', specs: [], feats: [], images: ['https://cdn.example/gp.jpg'], price: '850.000₫', priceNum: 850000 },
|
| 111 |
-
]);
|
| 112 |
-
const skuHits = searchLikeAvatar('GP170BD', skuMap, 24);
|
| 113 |
-
console.log(` [info] score "GP170BD" = ${skuHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 114 |
-
check('lookup SKU "GP170BD" trúng chính xác sản phẩm đó', skuHits[0]?.sku === 'GP170BD');
|
| 115 |
-
check('lookup SKU đạt ngưỡng identity (≥700)', (skuHits[0]?.score ?? 0) >= 700, `score=${skuHits[0]?.score}`);
|
| 116 |
-
const mergedSku = mergeResults(clipInteriors, skuHits, { query: 'GP170BD', avatarInteriors: [], recentProducts: [] });
|
| 117 |
-
check('lookup SKU crowd-out interiors (đây là tra cứu, không phải duyệt ảnh)', !mergedSku.some(t => String(t.id).startsWith('interior:')), mergedSku.map(t => t.id).join(', '));
|
| 118 |
-
check('lookup SKU trả đúng 1 sản phẩm, đầu danh sách', mergedSku[0]?.sku === 'GP170BD');
|
| 119 |
-
|
| 120 |
-
/* ─────────────────────── tổng kết ─────────────────────── */
|
| 121 |
-
|
| 122 |
-
console.log(failures ? `\n${failures} TEST FAIL` : '\nALL TESTS PASS');
|
| 123 |
-
process.exit(failures ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-ui.mjs
DELETED
|
@@ -1,201 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser check for the three changes: the backdrop, the size/position
|
| 3 |
-
* controls, and the neon outline.
|
| 4 |
-
*
|
| 5 |
-
* Run against a served build:
|
| 6 |
-
* npx vite preview --port 4173 & node scripts/test-ui.mjs
|
| 7 |
-
*
|
| 8 |
-
* Every assertion is about something visible. The neon one in particular is
|
| 9 |
-
* asserted against the RENDERED pixels and computed styles rather than against
|
| 10 |
-
* a class name, because a class that is added but overridden by CSS would pass
|
| 11 |
-
* a class-name check while showing the user nothing at all.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
-
import { chromium } from 'playwright';
|
| 15 |
-
|
| 16 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173';
|
| 17 |
-
let passed = 0, failed = 0;
|
| 18 |
-
const ok = (label, condition, detail = '') => {
|
| 19 |
-
if (condition) { passed++; console.log(` ok ${label}`); }
|
| 20 |
-
else { failed++; console.log(` FAIL ${label} ${detail}`); }
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const browser = await chromium.launch();
|
| 24 |
-
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
| 25 |
-
const errors = [];
|
| 26 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 27 |
-
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
| 28 |
-
|
| 29 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 30 |
-
// The 130 MB model load is not what is under test here; give React time to mount
|
| 31 |
-
// and paint the field, then stop waiting on it.
|
| 32 |
-
await page.waitForSelector('.image-tile', { timeout: 30000 });
|
| 33 |
-
await page.waitForTimeout(2500);
|
| 34 |
-
|
| 35 |
-
console.log('\n1. backdrop');
|
| 36 |
-
{
|
| 37 |
-
const info = await page.evaluate(() => {
|
| 38 |
-
const el = document.querySelector('.brand-backdrop');
|
| 39 |
-
if (!el) return null;
|
| 40 |
-
const style = getComputedStyle(el);
|
| 41 |
-
return { image: style.backgroundImage, opacity: style.opacity, position: style.position };
|
| 42 |
-
});
|
| 43 |
-
ok('the backdrop element exists', !!info);
|
| 44 |
-
ok('it paints the vai-avatar artwork', !!info?.image?.includes('vaistudio_center.png'), info?.image);
|
| 45 |
-
ok('opacity is 30%', Math.abs(Number(info?.opacity) - 0.3) < 0.001, `got ${info?.opacity}`);
|
| 46 |
-
ok('it is fixed and out of the way', info?.position === 'fixed');
|
| 47 |
-
// The image must actually load, or it is a 30% transparent rectangle.
|
| 48 |
-
const loaded = await page.evaluate(() => new Promise(resolve => {
|
| 49 |
-
const url = getComputedStyle(document.querySelector('.brand-backdrop')).backgroundImage.slice(5, -2);
|
| 50 |
-
const probe = new Image();
|
| 51 |
-
probe.onload = () => resolve(probe.naturalWidth > 0);
|
| 52 |
-
probe.onerror = () => resolve(false);
|
| 53 |
-
probe.src = url;
|
| 54 |
-
setTimeout(() => resolve(false), 8000);
|
| 55 |
-
}));
|
| 56 |
-
ok('the artwork actually decodes (not a 404)', loaded);
|
| 57 |
-
// It must not swallow clicks meant for a photograph.
|
| 58 |
-
const pe = await page.evaluate(() => getComputedStyle(document.querySelector('.brand-backdrop')).pointerEvents);
|
| 59 |
-
ok('it does not intercept pointer events', pe === 'none', `got ${pe}`);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
console.log('\n2. size & position controls, docked to the search box');
|
| 63 |
-
{
|
| 64 |
-
const toggle = await page.$('.field-controls-toggle');
|
| 65 |
-
ok('the toggle exists', !!toggle);
|
| 66 |
-
if (toggle) {
|
| 67 |
-
// "Near the search box" is a real requirement, so measure it.
|
| 68 |
-
const geo = await page.evaluate(() => {
|
| 69 |
-
const box = document.querySelector('.search-pill')?.getBoundingClientRect();
|
| 70 |
-
const knob = document.querySelector('.field-controls-toggle')?.getBoundingClientRect();
|
| 71 |
-
if (!box || !knob) return null;
|
| 72 |
-
return { gap: knob.top - box.bottom, centres: Math.abs((box.left + box.right) / 2 - (knob.left + knob.right) / 2) };
|
| 73 |
-
});
|
| 74 |
-
ok(`it sits just below the search box (${Math.round(geo?.gap ?? -1)}px gap)`, (geo?.gap ?? -1) >= 0 && (geo?.gap ?? 99) < 120);
|
| 75 |
-
ok(`and is horizontally centred with it (${Math.round(geo?.centres ?? -1)}px off)`, (geo?.centres ?? 99) < 24);
|
| 76 |
-
|
| 77 |
-
await toggle.click();
|
| 78 |
-
await page.waitForSelector('.field-controls-panel', { timeout: 5000 });
|
| 79 |
-
ok('the panel opens', true);
|
| 80 |
-
ok('it has a size control', !!(await page.$('#field-size')));
|
| 81 |
-
ok('it has a position pad', !!(await page.$('.field-controls-pad')));
|
| 82 |
-
|
| 83 |
-
// Size: measure a real tile before and after.
|
| 84 |
-
const before = await page.evaluate(() => {
|
| 85 |
-
const tile = document.querySelector('.image-tile');
|
| 86 |
-
return tile ? parseFloat(getComputedStyle(tile).width) : 0;
|
| 87 |
-
});
|
| 88 |
-
await page.evaluate(() => {
|
| 89 |
-
const slider = document.querySelector('#field-size');
|
| 90 |
-
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
| 91 |
-
setter.call(slider, '170');
|
| 92 |
-
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
| 93 |
-
slider.dispatchEvent(new Event('change', { bubbles: true }));
|
| 94 |
-
});
|
| 95 |
-
await page.waitForTimeout(1800);
|
| 96 |
-
const after = await page.evaluate(() => {
|
| 97 |
-
const tile = document.querySelector('.image-tile');
|
| 98 |
-
return tile ? parseFloat(getComputedStyle(tile).width) : 0;
|
| 99 |
-
});
|
| 100 |
-
ok(`raising the size slider grows the photographs (${Math.round(before)}px -> ${Math.round(after)}px)`,
|
| 101 |
-
after > before * 1.15, `before ${before} after ${after}`);
|
| 102 |
-
|
| 103 |
-
// Position: drag the pad and confirm the plane actually moved.
|
| 104 |
-
const pad = await page.$('.field-controls-pad');
|
| 105 |
-
const box = await pad.boundingBox();
|
| 106 |
-
// Read the recorded offset rather than guessing at a tile: which tile is
|
| 107 |
-
// "first" in the DOM is a seeded z-order detail, so asserting on it made
|
| 108 |
-
// this test depend on layout internals instead of on the observable state.
|
| 109 |
-
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
| 110 |
-
await page.mouse.down();
|
| 111 |
-
await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4, { steps: 12 });
|
| 112 |
-
await page.mouse.up();
|
| 113 |
-
await page.waitForTimeout(1200);
|
| 114 |
-
const view = await page.evaluate(() => JSON.parse(sessionStorage.getItem('sif:view') || '{}'));
|
| 115 |
-
ok(`dragging the pad sets both axes (${view.offsetX}, ${view.offsetY})`,
|
| 116 |
-
(view.offsetX ?? 0) > 100 && (view.offsetY ?? 0) > 100, JSON.stringify(view));
|
| 117 |
-
// And the change must actually reach the photographs' rendered transform.
|
| 118 |
-
// Read the whole field and compare it against the SAME measurement taken
|
| 119 |
-
// before the drag: the first tile in the DOM is a seeded z-order detail,
|
| 120 |
-
// so the spread across all tiles is the reliable signal, not any one tile.
|
| 121 |
-
const span = () => page.evaluate(() => {
|
| 122 |
-
const xs = [...document.querySelectorAll('.image-tile')].map(t => {
|
| 123 |
-
const m = /translate3d\((-?[\d.]+)px/.exec(t.style.transform || getComputedStyle(t).transform);
|
| 124 |
-
return m ? Number(m[1]) : null;
|
| 125 |
-
}).filter(v => v !== null);
|
| 126 |
-
return xs.length ? { min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null;
|
| 127 |
-
});
|
| 128 |
-
const spanAfter = await span();
|
| 129 |
-
ok(`the photographs moved with it (x spans ${spanAfter ? `${Math.round(spanAfter.min)}..${Math.round(spanAfter.max)} over ${spanAfter.n} tiles` : 'no tiles'})`,
|
| 130 |
-
!!spanAfter && spanAfter.max - spanAfter.min > 200, JSON.stringify(spanAfter));
|
| 131 |
-
|
| 132 |
-
// Persistence across reload is what makes it a setting, not a gesture.
|
| 133 |
-
await page.reload({ waitUntil: 'domcontentloaded' });
|
| 134 |
-
await page.waitForSelector('.image-tile', { timeout: 30000 });
|
| 135 |
-
const persisted = await page.evaluate(() => {
|
| 136 |
-
const slider = document.querySelector('#field-size');
|
| 137 |
-
return sessionStorage.getItem('sif:view');
|
| 138 |
-
});
|
| 139 |
-
ok('the view survives a reload', !!persisted && JSON.parse(persisted).tileScale > 1, persisted);
|
| 140 |
-
await page.evaluate(() => { sessionStorage.clear(); });
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
console.log('\n3. neon outline on an exact match');
|
| 145 |
-
{
|
| 146 |
-
// Render tiles in the field's own coordinate space, with the classes and
|
| 147 |
-
// custom properties ImageField sets, then read the painted result. Typing a
|
| 148 |
-
// real query would mean loading the 130 MB model first, and the outline --
|
| 149 |
-
// not the encoder -- is what is under test here.
|
| 150 |
-
const painted = await page.evaluate(() => {
|
| 151 |
-
const field = document.querySelector('.image-field');
|
| 152 |
-
const exact = document.createElement('button');
|
| 153 |
-
exact.className = 'image-tile exact';
|
| 154 |
-
exact.dataset.exact = 'true';
|
| 155 |
-
exact.style.cssText = 'position:absolute;left:40px;top:40px;width:200px;height:160px;';
|
| 156 |
-
const plain = document.createElement('button');
|
| 157 |
-
plain.className = 'image-tile';
|
| 158 |
-
plain.dataset.exact = '';
|
| 159 |
-
plain.style.cssText = 'position:absolute;left:300px;top:40px;width:200px;height:160px;';
|
| 160 |
-
field.append(exact, plain);
|
| 161 |
-
const ring = getComputedStyle(exact, '::after');
|
| 162 |
-
const noRing = getComputedStyle(plain, '::after');
|
| 163 |
-
const out = {
|
| 164 |
-
content: ring.content,
|
| 165 |
-
borderWidth: ring.borderTopWidth,
|
| 166 |
-
shadow: ring.boxShadow,
|
| 167 |
-
exactOpacity: getComputedStyle(exact).getPropertyValue('--relevance-opacity').trim(),
|
| 168 |
-
plainContent: noRing.content,
|
| 169 |
-
};
|
| 170 |
-
exact.remove(); plain.remove();
|
| 171 |
-
return out;
|
| 172 |
-
});
|
| 173 |
-
ok('the outline pseudo-element exists on an exact tile', painted.content !== 'none' && painted.content.includes('"'));
|
| 174 |
-
ok(`it has a visible stroke (${painted.borderWidth})`, parseFloat(painted.borderWidth) >= 1);
|
| 175 |
-
// count comma-separated shadows; color-mix() resolves to oklab() in Chromium,
|
| 176 |
-
// so match on the geometry ("Npx Npx Npx") rather than on a colour function.
|
| 177 |
-
const glowLayers = (painted.shadow.match(/\d+px\s+\d+px\s+\d+px/g) || []).length;
|
| 178 |
-
ok(`it carries a multi-layer neon glow (${glowLayers} layers)`, glowLayers >= 3, painted.shadow);
|
| 179 |
-
ok('an ordinary tile gets NO outline', painted.plainContent === 'none', `got ${painted.plainContent}`);
|
| 180 |
-
ok('an exact tile is never faded', painted.exactOpacity === '' || Number(painted.exactOpacity) >= 1, `got ${painted.exactOpacity}`);
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
console.log('\n4. no console errors from the new code');
|
| 184 |
-
{
|
| 185 |
-
// Image CORS from the dataset CDN is filtered out deliberately: it is a
|
| 186 |
-
// PRE-EXISTING property of how this Space loads interior photographs (the
|
| 187 |
-
// DATASET URL in assets.js is unchanged by this work), it depends on the
|
| 188 |
-
// headless host's own network policy rather than on any code under test, and
|
| 189 |
-
// surfacing it here would bury a real regression under a thousand lines of
|
| 190 |
-
// noise. The tile layer falls back to the BlurHash placeholder, so the field
|
| 191 |
-
// still paints without it.
|
| 192 |
-
const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e) && !/CORS policy/i.test(e));
|
| 193 |
-
ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 2).join(' | '));
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false });
|
| 197 |
-
console.log('\nscreenshot: /tmp/sif-ui.png');
|
| 198 |
-
await browser.close();
|
| 199 |
-
|
| 200 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 201 |
-
if (failed) process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-viewer-ui.mjs
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser checks for the restored viewer CSS, the ten-ring limit, and pill
|
| 3 |
-
* clearance.
|
| 4 |
-
*
|
| 5 |
-
* The CSS regression this file exists for was silent: opening a photograph
|
| 6 |
-
* produced an unstyled <dialog> that still worked, still showed the image, and
|
| 7 |
-
* just looked nothing like the design. Nothing throws, so only a computed-style
|
| 8 |
-
* assertion catches it.
|
| 9 |
-
*/
|
| 10 |
-
import { chromium } from 'playwright';
|
| 11 |
-
|
| 12 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
|
| 13 |
-
let passed = 0, failed = 0;
|
| 14 |
-
const test = async (name, fn) => {
|
| 15 |
-
try { await fn(); console.log(' ok ' + name); passed++; }
|
| 16 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
|
| 17 |
-
};
|
| 18 |
-
const assert = (cond, message) => { if (!cond) throw new Error(message); };
|
| 19 |
-
|
| 20 |
-
const browser = await chromium.launch();
|
| 21 |
-
const page = await browser.newPage({ viewport: { width: 1600, height: 950 } });
|
| 22 |
-
const errors = [];
|
| 23 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 24 |
-
|
| 25 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 26 |
-
await page.waitForSelector('.image-tile', { timeout: 120000 });
|
| 27 |
-
await page.waitForTimeout(6000);
|
| 28 |
-
|
| 29 |
-
console.log('\nrestored viewer CSS');
|
| 30 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 31 |
-
|
| 32 |
-
await test('the viewer opens full-screen with its own background', async () => {
|
| 33 |
-
// force: true because the field is deliberately alive -- every tile drifts a
|
| 34 |
-
// few px per frame, so a click aimed at a position sampled a moment ago can
|
| 35 |
-
// land beside the tile. That is the field working, not a bug, and it is why
|
| 36 |
-
// the shot here is dispatched at the element rather than at coordinates.
|
| 37 |
-
await page.locator('.image-tile').first().click({ force: true, timeout: 15000 });
|
| 38 |
-
await page.waitForSelector('.photo-viewer[open]', { timeout: 30000 });
|
| 39 |
-
const box = await page.locator('.photo-viewer').boundingBox();
|
| 40 |
-
const style = await page.evaluate(() => {
|
| 41 |
-
const v = document.querySelector('.photo-viewer');
|
| 42 |
-
const s = getComputedStyle(v);
|
| 43 |
-
return { position: s.position, bg: s.backgroundColor, w: s.width, h: s.height };
|
| 44 |
-
});
|
| 45 |
-
assert(style.position === 'fixed', 'viewer is ' + style.position + ', not fixed');
|
| 46 |
-
assert(box.width >= 1500 && box.height >= 900, 'viewer is ' + Math.round(box.width) + 'x' + Math.round(box.height));
|
| 47 |
-
assert(style.bg !== 'rgba(0, 0, 0, 0)', 'viewer has no background');
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
await test('the toolbar is a bar, not stacked plain text', async () => {
|
| 51 |
-
// The regression rendered this as inline-flow divs at full width. A real
|
| 52 |
-
// toolbar is absolutely positioned over the image, which is what stops the
|
| 53 |
-
// share button sitting on top of the photograph.
|
| 54 |
-
const style = await page.evaluate(() => {
|
| 55 |
-
const t = document.querySelector('.photo-toolbar');
|
| 56 |
-
return t ? { position: getComputedStyle(t).position, display: getComputedStyle(t).display } : null;
|
| 57 |
-
});
|
| 58 |
-
assert(style, 'no .photo-toolbar found');
|
| 59 |
-
assert(style.position === 'absolute', 'toolbar is ' + style.position);
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
await test('the close button is sized and round, not a bare glyph', async () => {
|
| 63 |
-
const box = await page.locator('.photo-toolbar button').first().boundingBox();
|
| 64 |
-
assert(box && box.width >= 28 && box.height >= 28,
|
| 65 |
-
'close button is ' + (box ? Math.round(box.width) + 'x' + Math.round(box.height) : 'missing'));
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
await test('the frame sits inside the viewport, not flush to an edge', async () => {
|
| 69 |
-
const box = await page.locator('.photo-frame').boundingBox();
|
| 70 |
-
assert(box && box.width > 100 && box.height > 100, 'frame is ' + JSON.stringify(box));
|
| 71 |
-
assert(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1601 && box.y + box.height <= 951,
|
| 72 |
-
'frame escapes the viewport');
|
| 73 |
-
});
|
| 74 |
-
|
| 75 |
-
await test('the detail panel is styled when the photograph has metadata', async () => {
|
| 76 |
-
const n = await page.locator('.photo-detail').count();
|
| 77 |
-
if (!n) { console.log(' (skipped: this photograph has no metadata panel)'); return; }
|
| 78 |
-
const style = await page.evaluate(() => {
|
| 79 |
-
const d = document.querySelector('.photo-detail');
|
| 80 |
-
const s = getComputedStyle(d);
|
| 81 |
-
return { position: s.position, z: s.zIndex };
|
| 82 |
-
});
|
| 83 |
-
assert(['absolute', 'fixed'].includes(style.position), 'panel is ' + style.position);
|
| 84 |
-
});
|
| 85 |
-
|
| 86 |
-
console.log('\nten rings, not sixty');
|
| 87 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 88 |
-
|
| 89 |
-
await test('at most ten tiles carry the neon', async () => {
|
| 90 |
-
await page.keyboard.press('Escape');
|
| 91 |
-
await page.waitForTimeout(1500);
|
| 92 |
-
const ringed = await page.locator('.image-tile.featured').count();
|
| 93 |
-
assert(ringed <= 10, ringed + ' tiles are ringed, expected at most 10');
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
await test('more photographs are shown than before', async () => {
|
| 97 |
-
const total = await page.locator('.image-tile').count();
|
| 98 |
-
assert(total > 96, 'only ' + total + ' tiles; the field should now hold 150 on desktop');
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
await test('un-ringed new photographs are still visible', async () => {
|
| 102 |
-
// "còn lại hiển thị bình thường" -- the rest must render as ordinary tiles.
|
| 103 |
-
const plain = await page.evaluate(() => {
|
| 104 |
-
const tiles = [...document.querySelectorAll('.image-tile')];
|
| 105 |
-
return tiles.filter(t => !t.classList.contains('featured')).length;
|
| 106 |
-
});
|
| 107 |
-
assert(plain > 10, 'no un-ringed tiles found');
|
| 108 |
-
});
|
| 109 |
-
|
| 110 |
-
console.log('\npill clearance');
|
| 111 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 112 |
-
|
| 113 |
-
await test('no ringed tile overlaps the search pill', async () => {
|
| 114 |
-
// The brief is explicit: big, near the search box, but NOT covering it.
|
| 115 |
-
// Measured against every ringed tile's real box, not its centre point.
|
| 116 |
-
const worst = await page.evaluate(() => {
|
| 117 |
-
const pill = document.querySelector('.search-pill').getBoundingClientRect();
|
| 118 |
-
let overlap = 0, who = null;
|
| 119 |
-
for (const tile of document.querySelectorAll('.image-tile.featured')) {
|
| 120 |
-
const r = tile.getBoundingClientRect();
|
| 121 |
-
const w = Math.max(0, Math.min(r.right, pill.right) - Math.max(r.left, pill.left));
|
| 122 |
-
const h = Math.max(0, Math.min(r.bottom, pill.bottom) - Math.max(r.top, pill.top));
|
| 123 |
-
const area = w * h;
|
| 124 |
-
if (area > overlap) { overlap = area; who = tile.dataset.imageId; }
|
| 125 |
-
}
|
| 126 |
-
return { overlap, who, pill: pill.width * pill.height };
|
| 127 |
-
});
|
| 128 |
-
assert(worst.overlap === 0,
|
| 129 |
-
`tile ${worst.who} covers ${Math.round(worst.overlap)}px² of the pill (${Math.round(worst.pill)}px² total)`);
|
| 130 |
-
});
|
| 131 |
-
|
| 132 |
-
await test('a ringed tile is still larger than an ordinary one', async () => {
|
| 133 |
-
const r = await page.evaluate(() => {
|
| 134 |
-
const area = el => { const b = el.getBoundingClientRect(); return b.width * b.height; };
|
| 135 |
-
const f = document.querySelector('.image-tile.featured');
|
| 136 |
-
const o = [...document.querySelectorAll('.image-tile')].find(t => !t.classList.contains('featured'));
|
| 137 |
-
return { f: area(f), o: area(o) };
|
| 138 |
-
});
|
| 139 |
-
assert(r.f > r.o * 1.15, `ringed ${Math.round(r.f)}px² vs ordinary ${Math.round(r.o)}px²`);
|
| 140 |
-
});
|
| 141 |
-
|
| 142 |
-
console.log('\nno regressions');
|
| 143 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 144 |
-
|
| 145 |
-
await test('no uncaught page errors', async () => {
|
| 146 |
-
assert(errors.length === 0, errors.slice(0, 3).join(' | '));
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
-
await browser.close();
|
| 150 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 151 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.jsx
CHANGED
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
| 2 |
import ImageField from './ImageField.jsx';
|
| 3 |
import PhotoViewer from './PhotoViewer.jsx';
|
| 4 |
import { mergeResults } from './merge.js';
|
| 5 |
-
import { searchAvatarInteriors, loadRecentAvatarInteriors } from './avatar-interiors.js';
|
| 6 |
|
| 7 |
/**
|
| 8 |
* How often to re-read the product catalogue so add/edit/delete on vai-avatar
|
|
@@ -37,20 +36,6 @@ export default function App() {
|
|
| 37 |
const [products, setProducts] = useState([]);
|
| 38 |
const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
|
| 39 |
const [catalogue, setCatalogue] = useState({ count: 0 });
|
| 40 |
-
// The newest interior photographs published on vai-avatar, already matched
|
| 41 |
-
// against the current query by the worker (see avatar-interiors.js). Kept as
|
| 42 |
-
// its own state so a new photograph appearing upstream reaches the field on
|
| 43 |
-
// the next poll even when nothing has been typed.
|
| 44 |
-
const [avatarInteriors, setAvatarInteriors] = useState([]);
|
| 45 |
-
// The newest ADDED PRODUCTS (via vai-avatar "Thêm SP"). Kept as its own state
|
| 46 |
-
// so a product pasted on vai-avatar shows up in the field -- ringed and
|
| 47 |
-
// enlarged like the newest interiors -- on the next poll, with no query.
|
| 48 |
-
const [recentProducts, setRecentProducts] = useState([]);
|
| 49 |
-
// The unfiltered newest-first set, held so clearing the query can restore it.
|
| 50 |
-
// Without this the field would keep showing only the photographs that happened
|
| 51 |
-
// to match the last search, and a new upload would not appear until it was
|
| 52 |
-
// typed at.
|
| 53 |
-
const avatarPool = useRef([]);
|
| 54 |
const worker = useRef(null);
|
| 55 |
const input = useRef(null);
|
| 56 |
const requestId = useRef(0);
|
|
@@ -64,14 +49,6 @@ export default function App() {
|
|
| 64 |
const productsRef = useRef([]);
|
| 65 |
const signature = useRef('');
|
| 66 |
|
| 67 |
-
/**
|
| 68 |
-
* The newest-first interior list, used by the fullscreen viewer for time-based
|
| 69 |
-
* left/right navigation. Kept in a ref (not state) so the render of the
|
| 70 |
-
* viewer never re-renders when the pool updates -- only the navigation
|
| 71 |
-
* behaviour changes.
|
| 72 |
-
*/
|
| 73 |
-
const timeList = useRef([]);
|
| 74 |
-
|
| 75 |
useEffect(() => {
|
| 76 |
const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
|
| 77 |
worker.current = instance;
|
|
@@ -88,34 +65,9 @@ export default function App() {
|
|
| 88 |
if (data.status === 'products-loading') setProductStatus(current => ({ ...current, loading: true }));
|
| 89 |
if (data.status === 'products-complete' && data.id === productId.current) {
|
| 90 |
setProducts(data.output); setProductStatus({ loading: false, error: '' });
|
| 91 |
-
// The matched interiors ride the same message, so one setState pair
|
| 92 |
-
// updates both halves of the field atomically -- no frame where a new
|
| 93 |
-
// photograph sits next to results for a query that did not produce it.
|
| 94 |
-
if (Array.isArray(data.interiors)) setAvatarInteriors(data.interiors);
|
| 95 |
if (typeof data.signature === 'string') signature.current = data.signature;
|
| 96 |
if (data.count) setCatalogue({ count: data.count });
|
| 97 |
}
|
| 98 |
-
// vai-avatar published new interiors. `changed` is false on the vast
|
| 99 |
-
// majority of ticks, and skipping the setState then is what stops the
|
| 100 |
-
// field being torn down and rebuilt every 30s for no visible reason.
|
| 101 |
-
if (data.status === 'avatar-updated') {
|
| 102 |
-
if (data.changed) {
|
| 103 |
-
// With no query, every new photograph is shown. That is the whole
|
| 104 |
-
// point of the feature: a photograph added on vai-avatar has to be
|
| 105 |
-
// visible here without anyone having to search for it. With a query,
|
| 106 |
-
// the list is filtered to the ones that answer it, so the "new" row at
|
| 107 |
-
// the top of the field is always relevant to what was actually typed.
|
| 108 |
-
const text = queryRef.current.trim();
|
| 109 |
-
avatarPool.current = data.items ?? [];
|
| 110 |
-
setAvatarInteriors(text ? searchAvatarInteriors(text, avatarPool.current) : avatarPool.current);
|
| 111 |
-
// The newest added products, ringed on the same field as the newest
|
| 112 |
-
// interiors. `data.recentProducts` is already newest-first and
|
| 113 |
-
// image-bearing (the worker filters), so it drops straight in.
|
| 114 |
-
if (Array.isArray(data.recentProducts)) setRecentProducts(data.recentProducts);
|
| 115 |
-
// Ask for a re-rank so the products arrive from the same snapshot.
|
| 116 |
-
if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 48 });
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
if (data.status === 'products-error' && data.id === productId.current) {
|
| 120 |
setProducts([]); setProductStatus({ loading: false, error: data.message });
|
| 121 |
}
|
|
@@ -130,7 +82,7 @@ export default function App() {
|
|
| 130 |
setCatalogue({ count: data.count });
|
| 131 |
if (changed) {
|
| 132 |
const text = queryRef.current.trim();
|
| 133 |
-
if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit:
|
| 134 |
}
|
| 135 |
}
|
| 136 |
// Only surface a refresh failure if the panel has nothing to show: a
|
|
@@ -171,20 +123,6 @@ export default function App() {
|
|
| 171 |
return () => { instance.terminate(); };
|
| 172 |
}, []);
|
| 173 |
|
| 174 |
-
// Load the time-ordered interior list once, so the viewer can page by date.
|
| 175 |
-
useEffect(() => {
|
| 176 |
-
let cancelled = false;
|
| 177 |
-
loadRecentAvatarInteriors(400).then(list => {
|
| 178 |
-
if (!cancelled) {
|
| 179 |
-
timeList.current = list;
|
| 180 |
-
if (!avatarPool.current.length && list.length) {
|
| 181 |
-
avatarPool.current = list;
|
| 182 |
-
}
|
| 183 |
-
}
|
| 184 |
-
}).catch(console.error);
|
| 185 |
-
return () => { cancelled = true; };
|
| 186 |
-
}, []);
|
| 187 |
-
|
| 188 |
useEffect(() => { productsRef.current = products; }, [products]);
|
| 189 |
|
| 190 |
// Poll the catalogue so add / edit / delete on vai-avatar propagate here.
|
|
@@ -222,21 +160,14 @@ export default function App() {
|
|
| 222 |
worker.current?.postMessage({ type: 'clear' });
|
| 223 |
setResults([]); setBusy(false);
|
| 224 |
setProducts([]); setProductStatus({ loading: false, error: '' });
|
| 225 |
-
// Clearing the query restores the unfiltered newest-first set, so the
|
| 226 |
-
// photographs just added on vai-avatar are visible again rather than the
|
| 227 |
-
// field being left showing only the tail of the previous search.
|
| 228 |
-
setAvatarInteriors(avatarPool.current);
|
| 229 |
return;
|
| 230 |
}
|
| 231 |
setBusy(true);
|
| 232 |
setProductStatus({ loading: true, error: '' });
|
| 233 |
-
// Filtered locally first so the featured row does not blank out on every
|
| 234 |
-
// keystroke while the worker's own answer is still in flight.
|
| 235 |
-
setAvatarInteriors(searchAvatarInteriors(text, avatarPool.current));
|
| 236 |
// The worker coalesces queued requests; typing needs no artificial debounce.
|
| 237 |
worker.current?.postMessage({ type: 'search', text, id });
|
| 238 |
// Products share the query but are ranked against their own catalogue.
|
| 239 |
-
worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit:
|
| 240 |
}, []);
|
| 241 |
|
| 242 |
useEffect(() => {
|
|
@@ -285,14 +216,7 @@ export default function App() {
|
|
| 285 |
* so a product added there appears here within one poll (30s) with no rebuild
|
| 286 |
* and no re-embedding.
|
| 287 |
*/
|
| 288 |
-
const field = useMemo(
|
| 289 |
-
// `query` is a dependency because mergeResults needs it to decide which
|
| 290 |
-
// product is the decisive winner, and that decision changes as the query
|
| 291 |
-
// changes. Without it, a tile enlarged for the previous query's exact answer
|
| 292 |
-
// would keep its size under the new one.
|
| 293 |
-
() => mergeResults(results, products, { avatarInteriors, recentProducts, query }),
|
| 294 |
-
[results, products, avatarInteriors, recentProducts, query],
|
| 295 |
-
);
|
| 296 |
|
| 297 |
/**
|
| 298 |
* Catalogue trouble is still reported, but only when it leaves the field
|
|
@@ -302,14 +226,7 @@ export default function App() {
|
|
| 302 |
const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
|
| 303 |
|
| 304 |
return <main className="app relative isolate h-dvh w-full overflow-hidden">
|
| 305 |
-
<
|
| 306 |
-
<ImageField
|
| 307 |
-
results={field}
|
| 308 |
-
paused={!!photo}
|
| 309 |
-
query={query}
|
| 310 |
-
products={products}
|
| 311 |
-
onSelect={(image, origin) => setPhoto({ ...image, origin })}
|
| 312 |
-
/>
|
| 313 |
<div className="vignette" aria-hidden="true" />
|
| 314 |
<div className="grain" aria-hidden="true" />
|
| 315 |
<section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
|
|
@@ -327,6 +244,6 @@ export default function App() {
|
|
| 327 |
<a className="pointer-events-auto" href="https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web" target="_blank" rel="noreferrer">Transformers.js <span className="version">4.3.0</span></a>
|
| 328 |
<span>{catalogue.count ? `${catalogue.count.toLocaleString()} sản phẩm` : 'sản phẩm'}<span className="footer-divider">/</span> {count.toLocaleString()} ảnh</span>
|
| 329 |
</footer>
|
| 330 |
-
{photo && <PhotoViewer photo={photo}
|
| 331 |
</main>;
|
| 332 |
-
}
|
|
|
|
| 2 |
import ImageField from './ImageField.jsx';
|
| 3 |
import PhotoViewer from './PhotoViewer.jsx';
|
| 4 |
import { mergeResults } from './merge.js';
|
|
|
|
| 5 |
|
| 6 |
/**
|
| 7 |
* How often to re-read the product catalogue so add/edit/delete on vai-avatar
|
|
|
|
| 36 |
const [products, setProducts] = useState([]);
|
| 37 |
const [productStatus, setProductStatus] = useState({ loading: false, error: '' });
|
| 38 |
const [catalogue, setCatalogue] = useState({ count: 0 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const worker = useRef(null);
|
| 40 |
const input = useRef(null);
|
| 41 |
const requestId = useRef(0);
|
|
|
|
| 49 |
const productsRef = useRef([]);
|
| 50 |
const signature = useRef('');
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
useEffect(() => {
|
| 53 |
const instance = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
|
| 54 |
worker.current = instance;
|
|
|
|
| 65 |
if (data.status === 'products-loading') setProductStatus(current => ({ ...current, loading: true }));
|
| 66 |
if (data.status === 'products-complete' && data.id === productId.current) {
|
| 67 |
setProducts(data.output); setProductStatus({ loading: false, error: '' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
if (typeof data.signature === 'string') signature.current = data.signature;
|
| 69 |
if (data.count) setCatalogue({ count: data.count });
|
| 70 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
if (data.status === 'products-error' && data.id === productId.current) {
|
| 72 |
setProducts([]); setProductStatus({ loading: false, error: data.message });
|
| 73 |
}
|
|
|
|
| 82 |
setCatalogue({ count: data.count });
|
| 83 |
if (changed) {
|
| 84 |
const text = queryRef.current.trim();
|
| 85 |
+
if (text) worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 24 });
|
| 86 |
}
|
| 87 |
}
|
| 88 |
// Only surface a refresh failure if the panel has nothing to show: a
|
|
|
|
| 123 |
return () => { instance.terminate(); };
|
| 124 |
}, []);
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
useEffect(() => { productsRef.current = products; }, [products]);
|
| 127 |
|
| 128 |
// Poll the catalogue so add / edit / delete on vai-avatar propagate here.
|
|
|
|
| 160 |
worker.current?.postMessage({ type: 'clear' });
|
| 161 |
setResults([]); setBusy(false);
|
| 162 |
setProducts([]); setProductStatus({ loading: false, error: '' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
return;
|
| 164 |
}
|
| 165 |
setBusy(true);
|
| 166 |
setProductStatus({ loading: true, error: '' });
|
|
|
|
|
|
|
|
|
|
| 167 |
// The worker coalesces queued requests; typing needs no artificial debounce.
|
| 168 |
worker.current?.postMessage({ type: 'search', text, id });
|
| 169 |
// Products share the query but are ranked against their own catalogue.
|
| 170 |
+
worker.current?.postMessage({ type: 'search-products', text, id: ++productId.current, limit: 24 });
|
| 171 |
}, []);
|
| 172 |
|
| 173 |
useEffect(() => {
|
|
|
|
| 216 |
* so a product added there appears here within one poll (30s) with no rebuild
|
| 217 |
* and no re-embedding.
|
| 218 |
*/
|
| 219 |
+
const field = useMemo(() => mergeResults(results, products), [results, products]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
/**
|
| 222 |
* Catalogue trouble is still reported, but only when it leaves the field
|
|
|
|
| 226 |
const catalogueError = !productStatus.error || products.length ? '' : productStatus.error;
|
| 227 |
|
| 228 |
return <main className="app relative isolate h-dvh w-full overflow-hidden">
|
| 229 |
+
<ImageField results={field} paused={!!photo} onSelect={(image, origin) => setPhoto({ ...image, origin })} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
<div className="vignette" aria-hidden="true" />
|
| 231 |
<div className="grain" aria-hidden="true" />
|
| 232 |
<section className="search-center" aria-label="Tìm kiếm ảnh ngữ nghĩa">
|
|
|
|
| 244 |
<a className="pointer-events-auto" href="https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web" target="_blank" rel="noreferrer">Transformers.js <span className="version">4.3.0</span></a>
|
| 245 |
<span>{catalogue.count ? `${catalogue.count.toLocaleString()} sản phẩm` : 'sản phẩm'}<span className="footer-divider">/</span> {count.toLocaleString()} ảnh</span>
|
| 246 |
</footer>
|
| 247 |
+
{photo && <PhotoViewer photo={photo} onClose={() => setPhoto(null)} />}
|
| 248 |
</main>;
|
| 249 |
+
}
|
src/ImageField.jsx
CHANGED
|
@@ -3,31 +3,15 @@ import collection from './collection.json';
|
|
| 3 |
import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js';
|
| 4 |
import { sampleMotion, transition } from './motion.js';
|
| 5 |
import { relevanceAppearance } from './relevance.js';
|
| 6 |
-
import { imageURL, tileURL
|
| 7 |
import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js';
|
| 8 |
|
| 9 |
-
/**
|
| 10 |
-
* z-index given to the tile the pointer is over.
|
| 11 |
-
*
|
| 12 |
-
* Clears every seeded value above (1 + floor(seed(id) * 100), so 1..100) and
|
| 13 |
-
* every other z-index the field uses -- vignette 300, grain 301, search pill
|
| 14 |
-
* 400, footer 401 -- so the lifted tile sits above its neighbours without
|
| 15 |
-
* pushing the search box under the user's photographs.
|
| 16 |
-
*/
|
| 17 |
-
const LIFTED_Z = 200;
|
| 18 |
-
|
| 19 |
/** A single population of photographs, one coordinate plane, one animation clock. */
|
| 20 |
-
export default function ImageField({ results, onSelect, paused
|
| 21 |
const surface = useRef(null);
|
| 22 |
const engine = useRef(null);
|
| 23 |
const selection = useRef(onSelect);
|
| 24 |
selection.current = onSelect;
|
| 25 |
-
// The query and products are read inside the long-lived effect, so they are
|
| 26 |
-
// mirrored into a ref rather than becoming effect dependencies. Making them
|
| 27 |
-
// dependencies would tear down and rebuild every tile -- and re-request every
|
| 28 |
-
// thumbnail -- on every keystroke.
|
| 29 |
-
const controls = useRef({ query, products });
|
| 30 |
-
controls.current = { query, products };
|
| 31 |
|
| 32 |
useEffect(() => {
|
| 33 |
const root = surface.current;
|
|
@@ -41,104 +25,33 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 41 |
|
| 42 |
function create(image, target, initial = false) {
|
| 43 |
const button = document.createElement('button');
|
| 44 |
-
|
| 45 |
-
// `exact`: it comes from the record itself and does not change for a
|
| 46 |
-
// given tile while it is on screen, so there is nothing to re-evaluate.
|
| 47 |
-
button.className = image.featured ? 'image-tile featured' : 'image-tile';
|
| 48 |
button.type = 'button';
|
| 49 |
button.dataset.imageId = image.id;
|
| 50 |
button.style.backgroundColor = averageColor(image.blur);
|
| 51 |
-
/
|
| 52 |
-
|
| 53 |
-
* opening it first.
|
| 54 |
-
*
|
| 55 |
-
* The tile stays a <button> -- it has to, for keyboard activation and for
|
| 56 |
-
* aria-haspopup="dialog" to mean anything -- and the anchor is appended
|
| 57 |
-
* inside it. Nesting an <a> in a <button> is not valid HTML, so the anchor
|
| 58 |
-
* is aria-hidden and tabIndex -1: it is a pointer affordance, and the
|
| 59 |
-
* reachable way to share is the link in the viewer, which is a real link
|
| 60 |
-
* with a real name. A keyboard user is not offered something that behaves
|
| 61 |
-
* differently from its accessible name.
|
| 62 |
-
*
|
| 63 |
-
* Clicking it must not also open the viewer, so its handler stops
|
| 64 |
-
* propagation; the tile's own onclick never sees the event.
|
| 65 |
-
*/
|
| 66 |
-
const share = document.createElement('a');
|
| 67 |
-
share.className = 'image-share';
|
| 68 |
-
share.tabIndex = -1;
|
| 69 |
-
share.setAttribute('aria-hidden', 'true');
|
| 70 |
-
/**
|
| 71 |
-
* Opens in a new tab, like the viewer's own share link. Navigating the
|
| 72 |
-
* current tab away would destroy the search the user is standing in, and
|
| 73 |
-
* the field cannot be rebuilt from history -- there is no route to come
|
| 74 |
-
* back to.
|
| 75 |
-
*/
|
| 76 |
-
share.target = '_blank';
|
| 77 |
-
share.rel = 'noopener noreferrer';
|
| 78 |
-
share.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">'
|
| 79 |
-
+ '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>';
|
| 80 |
-
share.addEventListener('click', event => event.stopPropagation());
|
| 81 |
-
button.append(share);
|
| 82 |
-
button._share = share;
|
| 83 |
-
// Stack order belongs to the photograph, never to its search rank -- EXCEPT
|
| 84 |
-
// that a featured tile -- the exact answer, a top keyword hit, or a just
|
| 85 |
-
// added avatar photograph -- must sit visibly ON TOP of the field around
|
| 86 |
-
// it, which is the whole point of enlarging it. So featured tiles draw in
|
| 87 |
-
// a layer above every ordinary tile (which can at most reach 100; 300 is
|
| 88 |
-
// safely clear of the 1..100 seed range and below the 400 reserved for the
|
| 89 |
-
// search pill glass in index.css).
|
| 90 |
-
const baseZ = image.featured ? 300 + Math.floor(seed(image.id) * 40) : 1 + Math.floor(seed(image.id) * 100);
|
| 91 |
-
button.style.zIndex = baseZ;
|
| 92 |
root.append(button);
|
| 93 |
const home = initial ? target : edgePosition(image, state.width, state.height, target);
|
| 94 |
-
|
| 95 |
-
// when the pointer moves on.
|
| 96 |
-
const node = { image, button, baseZ, lifted: false, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 };
|
| 97 |
resize(node);
|
| 98 |
button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`;
|
| 99 |
nodes.set(image.id, node);
|
| 100 |
node.cancelPlaceholder = placeholders.request(image.blur, url => {
|
| 101 |
if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`;
|
| 102 |
});
|
| 103 |
-
button.onclick =
|
| 104 |
-
/
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
* the browser may have re-targeted the event at the button itself, and
|
| 109 |
-
* the photograph opens behind a tab that was supposed to be shared.
|
| 110 |
-
* Testing the composed path covers both cases.
|
| 111 |
-
*/
|
| 112 |
-
const path = event.composedPath ? event.composedPath() : [event.target];
|
| 113 |
-
if (path.includes(share)) return;
|
| 114 |
-
selection.current(
|
| 115 |
-
// A product carries its full gallery so the viewer can page through it,
|
| 116 |
-
// exactly as the old product strip did. Interiors have no `images` and
|
| 117 |
-
// are unchanged.
|
| 118 |
-
//
|
| 119 |
-
// Read `node.image` at click time rather than closing over `image`: a
|
| 120 |
-
// node is created once and then reused across searches, and the record
|
| 121 |
-
// it was created from may have been an ambient tile from
|
| 122 |
-
// collection.json -- id/url/ar/blur only. Closing over the original
|
| 123 |
-
// meant a photograph that later matched the query kept opening with
|
| 124 |
-
// none of its title, categories or source, because the richer search
|
| 125 |
-
// result was never written back to the node. layout() assigns
|
| 126 |
-
// node.image on every pass, so the click always sees the current one.
|
| 127 |
-
{ ...node.image, previewURL: node.photo?.currentSrc || node.photo?.src },
|
| 128 |
button.getBoundingClientRect().toJSON(),
|
| 129 |
-
|
| 130 |
-
};
|
| 131 |
return node;
|
| 132 |
}
|
| 133 |
|
| 134 |
function resize(node) {
|
| 135 |
-
|
| 136 |
-
// added on vai-avatar -- carries its own scale multiplier. Read from the
|
| 137 |
-
// record rather than passed down, so a tile that becomes featured keeps
|
| 138 |
-
// the same identity (and therefore the same place in the layout) it had
|
| 139 |
-
// as an ordinary tile.
|
| 140 |
-
const scale = node.image?.featuredScale || 1;
|
| 141 |
-
const { width, height } = dimensions(node.image, state.width, scale);
|
| 142 |
if (node.width === width && node.height === height) return;
|
| 143 |
node.width = width; node.height = height;
|
| 144 |
node.button.style.width = `${width}px`;
|
|
@@ -160,19 +73,7 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 160 |
if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; }
|
| 161 |
photo.alt = ''; photo.draggable = false; photo.className = '';
|
| 162 |
node.photo = photo;
|
| 163 |
-
|
| 164 |
-
* insertBefore rather than append, so the share anchor stays the tile's
|
| 165 |
-
* last child.
|
| 166 |
-
*
|
| 167 |
-
* The tile has no stacking context of its own, and both children are
|
| 168 |
-
* absolutely positioned -- so with the <img> appended last, it paints
|
| 169 |
-
* over the anchor and every click on the share button opens the viewer
|
| 170 |
-
* instead of sharing. z-index cannot fix it either: it is set in a
|
| 171 |
-
* stylesheet, and re-appending the photo reorders the DOM, so the two
|
| 172 |
-
* swap paint order on each cached thumbnail. Keeping the anchor last in
|
| 173 |
-
* the DOM fixes it by construction, for every image, loaded or not.
|
| 174 |
-
*/
|
| 175 |
-
node.button.insertBefore(photo, node.button._share);
|
| 176 |
if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
|
| 177 |
photo.classList.add('loaded');
|
| 178 |
}, priority);
|
|
@@ -182,55 +83,13 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 182 |
const now = performance.now();
|
| 183 |
const active = new Set(state.pool.map(image => image.id));
|
| 184 |
const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
// scatter() only for a plain, featureless browse.
|
| 190 |
-
//
|
| 191 |
-
// The reason is that scatter() lays tiles out on a jittered grid with no
|
| 192 |
-
// notion of the search pill, so index 0 lands in a corner and a featured
|
| 193 |
-
// tile can end up underneath the pill with the pill's own glass sitting on
|
| 194 |
-
// top of it. fieldPosition() spirals out from the centre and already has
|
| 195 |
-
// the clearance term that pushes tiles around the pill. Using it for the
|
| 196 |
-
// idle state too is what puts the ten newest photographs in a readable
|
| 197 |
-
// arc below the search box instead of wherever the grid happens to put
|
| 198 |
-
// them.
|
| 199 |
-
const ordered = state.results.length || state.pool.some(image => image.featured);
|
| 200 |
-
const targets = state.pool.map((image, index) => ordered
|
| 201 |
-
? fieldPosition(image, index, state.pool.length, state.width, state.height, shiftX, shiftY)
|
| 202 |
-
: scatter(image, index, state.pool.length, state.width, state.height, shiftX, shiftY));
|
| 203 |
-
// The query and the product hits go in so relevance.js can decide which
|
| 204 |
-
// tiles are EXACT matches (see exactMatches) and get the neon outline.
|
| 205 |
-
const appearance = relevanceAppearance(state.results, controls.current.query, controls.current.products);
|
| 206 |
state.pool.forEach((image, index) => {
|
| 207 |
const target = targets[index];
|
| 208 |
const node = nodes.get(image.id) || create(image, target, initial);
|
| 209 |
-
// Keep the node's record current, not just its position: `image` here is
|
| 210 |
-
// whatever the pool now holds, which for a photograph that has become a
|
| 211 |
-
// search result is the full row (title, categories, source) rather than
|
| 212 |
-
// the metadata-free collection entry the node was first built from.
|
| 213 |
-
// Without this, opening a matched photograph shows nothing about it.
|
| 214 |
-
node.image = image;
|
| 215 |
-
/**
|
| 216 |
-
* Point the tile's share link at the current record, and hide it when
|
| 217 |
-
* there is nothing to share. Updated alongside node.image for the same
|
| 218 |
-
* reason: the node may have been built from a record that carried no
|
| 219 |
-
* shareable identity, and a photograph's address is derived from it.
|
| 220 |
-
*/
|
| 221 |
-
const share = node.button._share;
|
| 222 |
-
if (share) {
|
| 223 |
-
const href = shareURL(image);
|
| 224 |
-
if (href) {
|
| 225 |
-
share.href = href;
|
| 226 |
-
share.dataset.shareFor = String(image.id);
|
| 227 |
-
share.removeAttribute('hidden');
|
| 228 |
-
} else {
|
| 229 |
-
share.removeAttribute('href');
|
| 230 |
-
share.removeAttribute('data-share-for');
|
| 231 |
-
share.hidden = true;
|
| 232 |
-
}
|
| 233 |
-
}
|
| 234 |
node.target = target; node.exit = false;
|
| 235 |
node.button.removeAttribute('aria-hidden');
|
| 236 |
const rank = ranks.get(image.id);
|
|
@@ -240,22 +99,10 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 240 |
node.load?.prioritize(rank ?? 200 + index);
|
| 241 |
node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
|
| 242 |
node.button.setAttribute('aria-haspopup', 'dialog');
|
| 243 |
-
|
| 244 |
-
// outline and the dimming can never disagree about which tile is the
|
| 245 |
-
// answer. classList.toggle (not add) because a tile keeps its node
|
| 246 |
-
// across searches and would otherwise keep a stale outline.
|
| 247 |
-
const { opacity, blur, exact } = appearance(image.id);
|
| 248 |
node.button.style.setProperty('--relevance-opacity', opacity);
|
| 249 |
node.button.style.setProperty('--relevance-blur', `${blur}px`);
|
| 250 |
-
node.button.classList.toggle('exact', !!exact);
|
| 251 |
-
// An exact hit is never allowed to fade with the tail, whatever its
|
| 252 |
-
// cosine: if the outline says "this is the one", it has to be readable.
|
| 253 |
-
if (exact) {
|
| 254 |
-
node.button.style.setProperty('--relevance-opacity', 1);
|
| 255 |
-
node.button.style.setProperty('--relevance-blur', '0px');
|
| 256 |
-
}
|
| 257 |
node.button.dataset.rank = rank == null ? '' : rank + 1;
|
| 258 |
-
node.button.dataset.exact = exact ? 'true' : '';
|
| 259 |
node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
|
| 260 |
transition(node, target, now, initial || motion.matches);
|
| 261 |
});
|
|
@@ -265,10 +112,6 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 265 |
node.load?.cancel(); node.load = null;
|
| 266 |
node.button.setAttribute('aria-hidden', 'true');
|
| 267 |
node.button.dataset.rank = '';
|
| 268 |
-
// Clear the outline on the way out: a tile drifting off-screen must not
|
| 269 |
-
// keep advertising itself as the answer to the previous query.
|
| 270 |
-
node.button.dataset.exact = '';
|
| 271 |
-
node.button.classList.remove('exact');
|
| 272 |
node.button.tabIndex = -1;
|
| 273 |
transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
|
| 274 |
}
|
|
@@ -340,47 +183,8 @@ export default function ImageField({ results, onSelect, paused, query = '', prod
|
|
| 340 |
if (event.pointerType === 'touch') return;
|
| 341 |
state.pointer.x = (event.clientX / state.width - 0.5) * 2;
|
| 342 |
state.pointer.y = (event.clientY / state.height - 0.5) * 2;
|
| 343 |
-
liftUnderPointer(event.target);
|
| 344 |
-
};
|
| 345 |
-
/**
|
| 346 |
-
* Raise the photograph under the pointer above its neighbours.
|
| 347 |
-
*
|
| 348 |
-
* The field overlaps tiles deliberately and each tile's z-index is a stable
|
| 349 |
-
* seed of its id, so a tile can end up permanently buried under whichever
|
| 350 |
-
* neighbour hashed higher. CSS :hover cannot rescue it, because a buried
|
| 351 |
-
* element never matches :hover -- which is the chicken-and-egg the share
|
| 352 |
-
* button was stuck in. Measured on the idle field: of 79 in-viewport share
|
| 353 |
-
* buttons, not one was the topmost element at its own centre; 13 were
|
| 354 |
-
* covered by another tile's photograph and 8 by another tile outright.
|
| 355 |
-
*
|
| 356 |
-
* Lifting whichever tile the pointer is over makes the next hover land on
|
| 357 |
-
* the tile the user is aiming at, and then its own :hover reveals the
|
| 358 |
-
* share button normally. The seeded resting order is restored when the
|
| 359 |
-
* pointer leaves, so the composition stays deterministic across reloads.
|
| 360 |
-
*/
|
| 361 |
-
function liftUnderPointer(target) {
|
| 362 |
-
const tile = target?.closest?.('.image-tile');
|
| 363 |
-
for (const node of nodes.values()) {
|
| 364 |
-
if (node.lifted && node.button !== tile) restoreZ(node);
|
| 365 |
-
}
|
| 366 |
-
if (!tile) return;
|
| 367 |
-
const node = [...nodes.values()].find(n => n.button === tile);
|
| 368 |
-
if (node && !node.lifted) {
|
| 369 |
-
node.lifted = true;
|
| 370 |
-
tile.style.zIndex = LIFTED_Z;
|
| 371 |
-
}
|
| 372 |
-
}
|
| 373 |
-
function restoreZ(node) {
|
| 374 |
-
node.lifted = false;
|
| 375 |
-
node.button.style.zIndex = node.baseZ;
|
| 376 |
-
}
|
| 377 |
-
const leave = () => {
|
| 378 |
-
state.pointer.x = state.pointer.y = 0;
|
| 379 |
-
// Restore the seeded resting order. Leaving the field is the only
|
| 380 |
-
// unambiguous signal that the user is done arranging it, and doing it
|
| 381 |
-
// here is what keeps the composition identical on every reload.
|
| 382 |
-
for (const node of nodes.values()) if (node.lifted) restoreZ(node);
|
| 383 |
};
|
|
|
|
| 384 |
root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave);
|
| 385 |
frame = requestAnimationFrame(animate);
|
| 386 |
engine.current = { state, update };
|
|
|
|
| 3 |
import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js';
|
| 4 |
import { sampleMotion, transition } from './motion.js';
|
| 5 |
import { relevanceAppearance } from './relevance.js';
|
| 6 |
+
import { imageURL, tileURL } from './assets.js';
|
| 7 |
import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js';
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
/** A single population of photographs, one coordinate plane, one animation clock. */
|
| 10 |
+
export default function ImageField({ results, onSelect, paused }) {
|
| 11 |
const surface = useRef(null);
|
| 12 |
const engine = useRef(null);
|
| 13 |
const selection = useRef(onSelect);
|
| 14 |
selection.current = onSelect;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
useEffect(() => {
|
| 17 |
const root = surface.current;
|
|
|
|
| 25 |
|
| 26 |
function create(image, target, initial = false) {
|
| 27 |
const button = document.createElement('button');
|
| 28 |
+
button.className = 'image-tile';
|
|
|
|
|
|
|
|
|
|
| 29 |
button.type = 'button';
|
| 30 |
button.dataset.imageId = image.id;
|
| 31 |
button.style.backgroundColor = averageColor(image.blur);
|
| 32 |
+
// Stack order belongs to the photograph, never to its search rank.
|
| 33 |
+
button.style.zIndex = 1 + Math.floor(seed(image.id) * 100);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
root.append(button);
|
| 35 |
const home = initial ? target : edgePosition(image, state.width, state.height, target);
|
| 36 |
+
const node = { image, button, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 };
|
|
|
|
|
|
|
| 37 |
resize(node);
|
| 38 |
button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`;
|
| 39 |
nodes.set(image.id, node);
|
| 40 |
node.cancelPlaceholder = placeholders.request(image.blur, url => {
|
| 41 |
if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`;
|
| 42 |
});
|
| 43 |
+
button.onclick = () => selection.current(
|
| 44 |
+
// A product carries its full gallery so the viewer can page through it,
|
| 45 |
+
// exactly as the old product strip did. Interiors have no `images` and
|
| 46 |
+
// are unchanged.
|
| 47 |
+
{ ...image, previewURL: node.photo?.currentSrc || node.photo?.src },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
button.getBoundingClientRect().toJSON(),
|
| 49 |
+
);
|
|
|
|
| 50 |
return node;
|
| 51 |
}
|
| 52 |
|
| 53 |
function resize(node) {
|
| 54 |
+
const { width, height } = dimensions(node.image, state.width);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if (node.width === width && node.height === height) return;
|
| 56 |
node.width = width; node.height = height;
|
| 57 |
node.button.style.width = `${width}px`;
|
|
|
|
| 73 |
if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; }
|
| 74 |
photo.alt = ''; photo.draggable = false; photo.className = '';
|
| 75 |
node.photo = photo;
|
| 76 |
+
node.button.append(photo);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' });
|
| 78 |
photo.classList.add('loaded');
|
| 79 |
}, priority);
|
|
|
|
| 83 |
const now = performance.now();
|
| 84 |
const active = new Set(state.pool.map(image => image.id));
|
| 85 |
const ranks = new Map(state.results.map((image, rank) => [image.id, rank]));
|
| 86 |
+
const targets = state.pool.map((image, index) => state.results.length
|
| 87 |
+
? fieldPosition(image, index, state.pool.length, state.width, state.height)
|
| 88 |
+
: scatter(image, index, state.pool.length, state.width, state.height));
|
| 89 |
+
const appearance = relevanceAppearance(state.results);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
state.pool.forEach((image, index) => {
|
| 91 |
const target = targets[index];
|
| 92 |
const node = nodes.get(image.id) || create(image, target, initial);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
node.target = target; node.exit = false;
|
| 94 |
node.button.removeAttribute('aria-hidden');
|
| 95 |
const rank = ranks.get(image.id);
|
|
|
|
| 99 |
node.load?.prioritize(rank ?? 200 + index);
|
| 100 |
node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`);
|
| 101 |
node.button.setAttribute('aria-haspopup', 'dialog');
|
| 102 |
+
const { opacity, blur } = appearance(image.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
node.button.style.setProperty('--relevance-opacity', opacity);
|
| 104 |
node.button.style.setProperty('--relevance-blur', `${blur}px`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
node.button.dataset.rank = rank == null ? '' : rank + 1;
|
|
|
|
| 106 |
node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1);
|
| 107 |
transition(node, target, now, initial || motion.matches);
|
| 108 |
});
|
|
|
|
| 112 |
node.load?.cancel(); node.load = null;
|
| 113 |
node.button.setAttribute('aria-hidden', 'true');
|
| 114 |
node.button.dataset.rank = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
node.button.tabIndex = -1;
|
| 116 |
transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches);
|
| 117 |
}
|
|
|
|
| 183 |
if (event.pointerType === 'touch') return;
|
| 184 |
state.pointer.x = (event.clientX / state.width - 0.5) * 2;
|
| 185 |
state.pointer.y = (event.clientY / state.height - 0.5) * 2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
};
|
| 187 |
+
const leave = () => { state.pointer.x = state.pointer.y = 0; };
|
| 188 |
root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave);
|
| 189 |
frame = requestAnimationFrame(animate);
|
| 190 |
engine.current = { state, update };
|
src/PhotoViewer.jsx
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import { imageURL, shareURL } from './assets.js';
|
| 3 |
-
import { detailOf, GENERIC_HEADING } from './detail.js';
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Full-screen viewer shared by the interior field and the product catalogue.
|
| 7 |
*
|
| 8 |
-
* A `photo` is either an interior record ({ id, url, ar, blur, source }) or
|
| 9 |
-
*
|
| 10 |
* carry no `images` field, but keying the gallery off product identity anyway
|
| 11 |
* keeps the two paths unambiguous rather than merely incidental.
|
| 12 |
*/
|
|
@@ -16,7 +15,7 @@ function resolveSrc(photo, width) {
|
|
| 16 |
return imageURL(photo, width);
|
| 17 |
}
|
| 18 |
|
| 19 |
-
export default function PhotoViewer({ photo,
|
| 20 |
const dialog = useRef(null);
|
| 21 |
const frame = useRef(null);
|
| 22 |
const closing = useRef(false);
|
|
@@ -27,21 +26,9 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 27 |
// Measured from the decoded image, so a product photo is not letterboxed
|
| 28 |
// against an assumed ratio on first paint.
|
| 29 |
const [measured, setMeasured] = useState(0);
|
| 30 |
-
const [position, setPosition] = useState(0);
|
| 31 |
-
const [timePosition, setTimePosition] = useState(0); // time-ordered navigation index
|
| 32 |
|
| 33 |
-
|
| 34 |
-
* A catalogue product, or a photograph?
|
| 35 |
-
*
|
| 36 |
-
* Not `Boolean(photo.sku)`: a photograph published on vai-avatar carries a
|
| 37 |
-
* namespaced `sku` too (`avatarimg:<image_id>`), which is what lets it share
|
| 38 |
-
* this viewer and the field with a product. Keying on it announced every such
|
| 39 |
-
* photograph as "Sản phẩm (avatarimg:...)" and rendered the product price and
|
| 40 |
-
* spec caption underneath it. `avatarImageId` is present only on records read
|
| 41 |
-
* from the vai-avatar interior index -- and detail.js derives the flag from the
|
| 42 |
-
* same field, so the two cannot disagree.
|
| 43 |
-
*/
|
| 44 |
-
const isProduct = Boolean(photo.sku) && !photo.avatarImageId;
|
| 45 |
|
| 46 |
// Only a product has a gallery worth paging through.
|
| 47 |
const gallery = useMemo(
|
|
@@ -49,21 +36,6 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 49 |
[isProduct, photo.images],
|
| 50 |
);
|
| 51 |
|
| 52 |
-
/**
|
| 53 |
-
* Time-ordered list of interiors (newest → oldest) handed down from the
|
| 54 |
-
* field. When active, the arrows step through photographs by `addedAt`, and
|
| 55 |
-
* the counter shows both the slot and the date of the current photo.
|
| 56 |
-
*
|
| 57 |
-
* If absent, the viewer falls back to the existing behaviour: a single
|
| 58 |
-
* photograph or a product's own gallery.
|
| 59 |
-
*/
|
| 60 |
-
const steppableList = useMemo(() => {
|
| 61 |
-
if (timeList && Array.isArray(timeList) && timeList.length > 1) {
|
| 62 |
-
return timeList;
|
| 63 |
-
}
|
| 64 |
-
return gallery;
|
| 65 |
-
}, [timeList, gallery]);
|
| 66 |
-
|
| 67 |
/**
|
| 68 |
* The frame actually on screen.
|
| 69 |
*
|
|
@@ -72,16 +44,10 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 72 |
* the counter while the photograph stayed frozen.
|
| 73 |
*/
|
| 74 |
const src = useMemo(
|
| 75 |
-
() =>
|
| 76 |
-
|
| 77 |
-
const idx = timeList && timeList.length > 1 ? timePosition : position;
|
| 78 |
-
return (list && list[idx]) ? resolveSrc(list[idx], 2400) : resolveSrc(photo, 2400);
|
| 79 |
-
},
|
| 80 |
-
[steppableList, timePosition, position, photo],
|
| 81 |
);
|
| 82 |
-
const preview =
|
| 83 |
-
? resolveSrc(steppableList[timeList && timeList.length > 1 ? timePosition : position], 640)
|
| 84 |
-
: (photo.previewURL || resolveSrc(photo, 640));
|
| 85 |
const ar = photo.ar || measured || 4 / 3;
|
| 86 |
|
| 87 |
const sourceTransform = useCallback(() => {
|
|
@@ -97,7 +63,7 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 97 |
useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]);
|
| 98 |
|
| 99 |
// Opening a different photo starts that product's gallery from the beginning.
|
| 100 |
-
useEffect(() => { setPosition(0);
|
| 101 |
|
| 102 |
useEffect(() => {
|
| 103 |
const previous = document.activeElement;
|
|
@@ -143,155 +109,71 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 143 |
}, [onClose, sourceTransform]);
|
| 144 |
|
| 145 |
/**
|
| 146 |
-
* Step through
|
| 147 |
-
* clamped at the ends.
|
| 148 |
*
|
| 149 |
* The clamp lives inside the updater so rapid arrow presses cannot drift the
|
| 150 |
* index past the end and land on an empty frame.
|
| 151 |
*/
|
| 152 |
const step = useCallback(delta => {
|
| 153 |
-
const total =
|
| 154 |
if (total < 2) return;
|
| 155 |
-
|
| 156 |
-
setTime(current => {
|
| 157 |
const next = current + delta;
|
| 158 |
return next < 0 || next >= total ? current : next;
|
| 159 |
});
|
| 160 |
-
}, [
|
| 161 |
|
| 162 |
useEffect(() => {
|
| 163 |
-
if (!
|
| 164 |
const onKey = event => {
|
| 165 |
if (event.key === 'ArrowRight') { event.preventDefault(); step(1); }
|
| 166 |
else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); }
|
| 167 |
};
|
| 168 |
window.addEventListener('keydown', onKey);
|
| 169 |
return () => window.removeEventListener('keydown', onKey);
|
| 170 |
-
}, [
|
| 171 |
|
| 172 |
// Escape stays with the dialog's own cancel event; only arrows are added here.
|
| 173 |
const alt = isProduct
|
| 174 |
? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}`
|
| 175 |
: 'Ảnh nội thất đã phóng to';
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
* a "Nguồn" row in the fact table. The share link in the toolbar is the single
|
| 183 |
-
* way out of the app, and the photograph's address is the only URL it needs.
|
| 184 |
-
*/
|
| 185 |
-
const caption = <span className="photo-origin">
|
| 186 |
-
{isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')}
|
| 187 |
-
</span>;
|
| 188 |
|
| 189 |
// Paging controls appear only when there is somewhere to go.
|
| 190 |
-
const stepping = Boolean(
|
| 191 |
-
const usingTimeList = Boolean(timeList && timeList.length > 1);
|
| 192 |
|
| 193 |
/**
|
| 194 |
-
*
|
| 195 |
*
|
| 196 |
-
*
|
| 197 |
-
*
|
| 198 |
-
*
|
| 199 |
-
*
|
| 200 |
-
* them.
|
| 201 |
*/
|
| 202 |
-
const shareTitle = (isProduct
|
| 203 |
-
? [photo.name, photo.n, photo.sku].find(value => typeof value === 'string' && value.trim())
|
| 204 |
-
: [photo.title, detail.heading].find(value => typeof value === 'string' && value.trim())
|
| 205 |
-
) || 'Ảnh nội thất V.AI STUDIO';
|
| 206 |
-
|
| 207 |
const share = shareURL(photo);
|
| 208 |
const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null;
|
| 209 |
|
| 210 |
-
const [shareLabel, setShareLabel] = useState('');
|
| 211 |
-
const onShare = useCallback(async ({ url, title, label }) => {
|
| 212 |
-
const announce = text => {
|
| 213 |
-
label(text);
|
| 214 |
-
window.clearTimeout(announce.timer);
|
| 215 |
-
announce.timer = window.setTimeout(() => label(''), 1800);
|
| 216 |
-
};
|
| 217 |
-
if (navigator.share) {
|
| 218 |
-
try {
|
| 219 |
-
await navigator.share({ title, url });
|
| 220 |
-
announce('Đã chia sẻ');
|
| 221 |
-
} catch {
|
| 222 |
-
// AbortError here means the sheet was swiped away. Nothing to report.
|
| 223 |
-
}
|
| 224 |
-
return;
|
| 225 |
-
}
|
| 226 |
-
if (navigator.clipboard?.writeText) {
|
| 227 |
-
try {
|
| 228 |
-
await navigator.clipboard.writeText(url);
|
| 229 |
-
announce('Đã sao chép liên kết');
|
| 230 |
-
return;
|
| 231 |
-
} catch { /* fall through to the legacy path */ }
|
| 232 |
-
}
|
| 233 |
-
// Last resort, for a non-secure context or a blocked clipboard: a temporary
|
| 234 |
-
// input selected and copied synchronously, which still works where the async
|
| 235 |
-
// API above does not.
|
| 236 |
-
const field = document.createElement('input');
|
| 237 |
-
field.value = url;
|
| 238 |
-
field.setAttribute('aria-hidden', 'true');
|
| 239 |
-
document.body.append(field);
|
| 240 |
-
field.select();
|
| 241 |
-
const copied = document.execCommand?.('copy');
|
| 242 |
-
field.remove();
|
| 243 |
-
announce(copied ? 'Đã sao chép liên kết' : 'Không chia sẻ được');
|
| 244 |
-
}, []);
|
| 245 |
-
|
| 246 |
-
const detail = useMemo(() => detailOf(photo), [photo]);
|
| 247 |
-
const generic = detail.description.startsWith('Ảnh trong bộ sưu tập');
|
| 248 |
-
const hasDetail = Boolean(
|
| 249 |
-
detail.room
|
| 250 |
-
|| detail.tags.length > 1
|
| 251 |
-
|| (detail.heading && detail.heading !== GENERIC_HEADING)
|
| 252 |
-
|| (detail.description && !generic),
|
| 253 |
-
);
|
| 254 |
-
|
| 255 |
-
const nowIdx = timeList && timeList.length > 1 ? timePosition : position;
|
| 256 |
-
const currentItem = steppableList ? steppableList[nowIdx] : null;
|
| 257 |
-
const dateLabel = (currentItem?.addedAt || photo.createdAt || '').slice(0, 10);
|
| 258 |
-
|
| 259 |
return <dialog ref={dialog} className="photo-viewer" aria-label="Ảnh toàn màn hình"
|
| 260 |
onCancel={event => { event.preventDefault(); close(); }}
|
| 261 |
onClick={event => { if (event.target === dialog.current) close(); }}>
|
| 262 |
<div className="photo-toolbar">
|
| 263 |
-
{share ? <
|
| 264 |
-
{
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
Messages, Zalo, Telegram, the system sheet on iOS and Android. That is
|
| 269 |
-
the gesture the icon already implied, and the old <a target="_blank">
|
| 270 |
-
could not offer it: it opened a browser tab, not the reader's own apps.
|
| 271 |
-
navigator.share is where the platform's share sheet lives; where it is
|
| 272 |
-
absent or the user dismisses it, the clipboard takes over so there is
|
| 273 |
-
always something to paste.
|
| 274 |
-
|
| 275 |
-
A dismissal is not an error -- navigator.share rejects with AbortError
|
| 276 |
-
every time the sheet is swiped away -- so a refusal is swallowed
|
| 277 |
-
silently and nothing is announced.
|
| 278 |
-
*/}
|
| 279 |
-
<button type="button" className="photo-share" onClick={() => onShare({ url: share, title: shareTitle, label: setShareLabel })}>
|
| 280 |
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
|
| 281 |
-
<span>{shareLabel || (isProduct ? 'Chia sẻ sản phẩm' : 'Chia sẻ ảnh')}</span>
|
| 282 |
-
</button>
|
| 283 |
-
<a className="photo-open" href={share} target="_blank" rel="noopener noreferrer"
|
| 284 |
-
title={isProduct ? 'Xem sản phẩm trên V.AI STUDIO' : 'Xem ảnh nội thất trên V.AI STUDIO'}>
|
| 285 |
-
<span>Xem trang</span>
|
| 286 |
-
</a>
|
| 287 |
-
</> : null}
|
| 288 |
{caption}
|
| 289 |
<button type="button" aria-label="Đóng ảnh" autoFocus onClick={close}>
|
| 290 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
|
| 291 |
</button>
|
| 292 |
</div>
|
| 293 |
<div className={`photo-viewport ${zoomed ? 'is-zoomed' : ''}`} onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
| 294 |
-
{stepping &&
|
| 295 |
onClick={event => { event.stopPropagation(); step(-1); }}>
|
| 296 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
|
| 297 |
</button>}
|
|
@@ -311,65 +193,19 @@ export default function PhotoViewer({ photo, timeList, onClose }) {
|
|
| 311 |
}
|
| 312 |
}} />
|
| 313 |
</button>
|
| 314 |
-
{stepping &&
|
| 315 |
onClick={event => { event.stopPropagation(); step(1); }}>
|
| 316 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m10 6 6 6-6 6" /></svg>
|
| 317 |
</button>}
|
| 318 |
</div>
|
| 319 |
{isProduct && (photo.price || (specs && specs.length)) ? <div className="photo-caption">
|
| 320 |
{photo.price && <p className="photo-caption-price">{photo.price}</p>}
|
| 321 |
-
{specs && specs.length > 0
|
| 322 |
{specs.slice(0, 6).map(([key, value], index) => (
|
| 323 |
<div key={index}><dt>{key}</dt><dd>{value}</dd></div>
|
| 324 |
))}
|
| 325 |
-
</dl>
|
| 326 |
</div> : null}
|
| 327 |
-
{/
|
| 328 |
-
Detail panel, for BOTH kinds of record.
|
| 329 |
-
|
| 330 |
-
Collapsible rather than always-open because the viewer is a photo first:
|
| 331 |
-
on a phone there is no room for both. Closed by default keeps the first
|
| 332 |
-
impression the photograph, while the summary line still shows the title
|
| 333 |
-
and the price.
|
| 334 |
-
|
| 335 |
-
<details>/<summary> rather than a button plus state: keyboard- and
|
| 336 |
-
screen-reader-correct for free, and it survives a re-render mid-read.
|
| 337 |
-
*/}
|
| 338 |
-
{hasDetail ? (
|
| 339 |
-
<details className="photo-detail">
|
| 340 |
-
<summary className="photo-detail-summary">
|
| 341 |
-
<span className="photo-detail-title">{detail.heading}</span>
|
| 342 |
-
{photo.price ? <span className="photo-detail-price">{photo.price}</span> : null}
|
| 343 |
-
<span className="photo-detail-toggle" aria-hidden="true">Chi tiết</span>
|
| 344 |
-
</summary>
|
| 345 |
-
<div className="photo-detail-body">
|
| 346 |
-
{detail.description ? <p className="photo-detail-desc">{detail.description}</p> : null}
|
| 347 |
-
{detail.tags.length > 0 ? <ul className="photo-detail-tags">
|
| 348 |
-
{detail.tags.map(tag => <li key={tag}>{tag}</li>)}
|
| 349 |
-
</ul> : null}
|
| 350 |
-
{detail.facts.length > 0 ? <dl className="photo-detail-facts">
|
| 351 |
-
{detail.facts.map(([key, value], index) => (
|
| 352 |
-
<div key={index}><dt>{key}</dt><dd>{value}</dd></div>
|
| 353 |
-
))}
|
| 354 |
-
</dl> : null}
|
| 355 |
-
{/*
|
| 356 |
-
No "Xem bài gốc" link here, and no "Nguồn" row either. The panel
|
| 357 |
-
answers "what am I looking at", and the share link in the toolbar
|
| 358 |
-
above already carries the photograph's public address. Naming the
|
| 359 |
-
scraped origin -- daibau.vn, noithatphuphat.com -- in the table put
|
| 360 |
-
somebody else's domain in the middle of our own interface, on 1,113
|
| 361 |
-
of the 1,222 photographs, for no gain to a reader.
|
| 362 |
-
*/}
|
| 363 |
-
</div>
|
| 364 |
-
</details>
|
| 365 |
-
) : null}
|
| 366 |
-
{stepping ? (
|
| 367 |
-
<p className="photo-counter" role="status" aria-live="polite"
|
| 368 |
-
title={usingTimeList ? `Duyệt theo thời gian: ${dateLabel}` : ''}>
|
| 369 |
-
{usingTimeList && dateLabel
|
| 370 |
-
? <span className="photo-date" title={currentItem.addedAt}>{dateLabel}</span>
|
| 371 |
-
: `${nowIdx + 1} / ${steppableList.length}`}
|
| 372 |
-
</p>
|
| 373 |
-
) : null}
|
| 374 |
</dialog>;
|
| 375 |
-
}
|
|
|
|
| 1 |
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import { imageURL, shareURL } from './assets.js';
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Full-screen viewer shared by the interior field and the product catalogue.
|
| 6 |
*
|
| 7 |
+
* A `photo` is either an interior record ({ id, url, ar, blur, source }) or a
|
| 8 |
+
* product photo ({ sku, src, images, name, price, specs }). Interior records
|
| 9 |
* carry no `images` field, but keying the gallery off product identity anyway
|
| 10 |
* keeps the two paths unambiguous rather than merely incidental.
|
| 11 |
*/
|
|
|
|
| 15 |
return imageURL(photo, width);
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export default function PhotoViewer({ photo, onClose }) {
|
| 19 |
const dialog = useRef(null);
|
| 20 |
const frame = useRef(null);
|
| 21 |
const closing = useRef(false);
|
|
|
|
| 26 |
// Measured from the decoded image, so a product photo is not letterboxed
|
| 27 |
// against an assumed ratio on first paint.
|
| 28 |
const [measured, setMeasured] = useState(0);
|
| 29 |
+
const [position, setPosition] = useState(0);
|
|
|
|
| 30 |
|
| 31 |
+
const isProduct = Boolean(photo.sku);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
// Only a product has a gallery worth paging through.
|
| 34 |
const gallery = useMemo(
|
|
|
|
| 36 |
[isProduct, photo.images],
|
| 37 |
);
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
/**
|
| 40 |
* The frame actually on screen.
|
| 41 |
*
|
|
|
|
| 44 |
* the counter while the photograph stayed frozen.
|
| 45 |
*/
|
| 46 |
const src = useMemo(
|
| 47 |
+
() => (gallery ? (gallery[position] ?? gallery[0]) : resolveSrc(photo, 2400)),
|
| 48 |
+
[gallery, position, photo],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
);
|
| 50 |
+
const preview = gallery ? src : (photo.previewURL || resolveSrc(photo, 640));
|
|
|
|
|
|
|
| 51 |
const ar = photo.ar || measured || 4 / 3;
|
| 52 |
|
| 53 |
const sourceTransform = useCallback(() => {
|
|
|
|
| 63 |
useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]);
|
| 64 |
|
| 65 |
// Opening a different photo starts that product's gallery from the beginning.
|
| 66 |
+
useEffect(() => { setPosition(0); }, [photo]);
|
| 67 |
|
| 68 |
useEffect(() => {
|
| 69 |
const previous = document.activeElement;
|
|
|
|
| 109 |
}, [onClose, sourceTransform]);
|
| 110 |
|
| 111 |
/**
|
| 112 |
+
* Step through a product gallery, clamped to the ends.
|
|
|
|
| 113 |
*
|
| 114 |
* The clamp lives inside the updater so rapid arrow presses cannot drift the
|
| 115 |
* index past the end and land on an empty frame.
|
| 116 |
*/
|
| 117 |
const step = useCallback(delta => {
|
| 118 |
+
const total = gallery?.length ?? 0;
|
| 119 |
if (total < 2) return;
|
| 120 |
+
setPosition(current => {
|
|
|
|
| 121 |
const next = current + delta;
|
| 122 |
return next < 0 || next >= total ? current : next;
|
| 123 |
});
|
| 124 |
+
}, [gallery]);
|
| 125 |
|
| 126 |
useEffect(() => {
|
| 127 |
+
if (!gallery || gallery.length < 2) return;
|
| 128 |
const onKey = event => {
|
| 129 |
if (event.key === 'ArrowRight') { event.preventDefault(); step(1); }
|
| 130 |
else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); }
|
| 131 |
};
|
| 132 |
window.addEventListener('keydown', onKey);
|
| 133 |
return () => window.removeEventListener('keydown', onKey);
|
| 134 |
+
}, [gallery, step]);
|
| 135 |
|
| 136 |
// Escape stays with the dialog's own cancel event; only arrows are added here.
|
| 137 |
const alt = isProduct
|
| 138 |
? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}`
|
| 139 |
: 'Ảnh nội thất đã phóng to';
|
| 140 |
|
| 141 |
+
const caption = photo.source
|
| 142 |
+
? <a href={photo.source} target="_blank" rel="noreferrer">View source ↗</a>
|
| 143 |
+
: <span className="photo-origin">
|
| 144 |
+
{isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')}
|
| 145 |
+
</span>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
// Paging controls appear only when there is somewhere to go.
|
| 148 |
+
const stepping = Boolean(gallery && gallery.length > 1);
|
|
|
|
| 149 |
|
| 150 |
/**
|
| 151 |
+
* The share page for this photo, or null when there is nothing to share.
|
| 152 |
*
|
| 153 |
+
* It is keyed off `photo`, not `src`, so paging through a product's gallery
|
| 154 |
+
* keeps one stable link to the product rather than a different link per
|
| 155 |
+
* image -- sharing the 3rd photo of an appliance should still point at the
|
| 156 |
+
* appliance.
|
|
|
|
| 157 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
const share = shareURL(photo);
|
| 159 |
const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null;
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
return <dialog ref={dialog} className="photo-viewer" aria-label="Ảnh toàn màn hình"
|
| 162 |
onCancel={event => { event.preventDefault(); close(); }}
|
| 163 |
onClick={event => { if (event.target === dialog.current) close(); }}>
|
| 164 |
<div className="photo-toolbar">
|
| 165 |
+
{share ? <a className="photo-share" href={share} target="_blank" rel="noopener noreferrer"
|
| 166 |
+
title={isProduct ? 'Xem sản phẩm trên V.AI STUDIO' : 'Xem ảnh nội thất trên V.AI STUDIO'}>
|
| 167 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
|
| 168 |
+
<span>Xem thêm</span>
|
| 169 |
+
</a> : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
{caption}
|
| 171 |
<button type="button" aria-label="Đóng ảnh" autoFocus onClick={close}>
|
| 172 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
|
| 173 |
</button>
|
| 174 |
</div>
|
| 175 |
<div className={`photo-viewport ${zoomed ? 'is-zoomed' : ''}`} onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
| 176 |
+
{stepping && position > 0 && <button type="button" className="photo-step is-prev" aria-label="Ảnh trước"
|
| 177 |
onClick={event => { event.stopPropagation(); step(-1); }}>
|
| 178 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
|
| 179 |
</button>}
|
|
|
|
| 193 |
}
|
| 194 |
}} />
|
| 195 |
</button>
|
| 196 |
+
{stepping && position < gallery.length - 1 && <button type="button" className="photo-step is-next" aria-label="Ảnh tiếp theo"
|
| 197 |
onClick={event => { event.stopPropagation(); step(1); }}>
|
| 198 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="m10 6 6 6-6 6" /></svg>
|
| 199 |
</button>}
|
| 200 |
</div>
|
| 201 |
{isProduct && (photo.price || (specs && specs.length)) ? <div className="photo-caption">
|
| 202 |
{photo.price && <p className="photo-caption-price">{photo.price}</p>}
|
| 203 |
+
{specs && specs.length > 0 && <dl className="photo-caption-specs">
|
| 204 |
{specs.slice(0, 6).map(([key, value], index) => (
|
| 205 |
<div key={index}><dt>{key}</dt><dd>{value}</dd></div>
|
| 206 |
))}
|
| 207 |
+
</dl>}
|
| 208 |
</div> : null}
|
| 209 |
+
{stepping ? <p className="photo-counter" role="status" aria-live="polite">{position + 1} / {gallery.length}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
</dialog>;
|
| 211 |
+
}
|
src/assets.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*/
|
| 5 |
export const DATASET = 'https://huggingface.co/datasets/bep40/interior-image-field-assets/resolve/main/';
|
| 6 |
|
| 7 |
-
/
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
* includes mean pooling and the 768->512 projection, letting Transformers.js
|
| 14 |
-
* run it under the usual CLIP file naming.
|
| 15 |
-
*/
|
| 16 |
export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
|
| 17 |
|
| 18 |
/**
|
|
@@ -21,20 +17,25 @@ export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
|
|
| 21 |
*
|
| 22 |
* The repo uploaded a broken export first (commit 68dc949, 136,951,792 bytes).
|
| 23 |
* Its graph declares `masked` as [batch_size, sequence_length, 768] while an
|
| 24 |
-
* attention mask only ever has 1 feature, so ONNX shape inference aborts
|
| 25 |
-
*
|
| 26 |
-
*
|
| 27 |
*
|
| 28 |
-
*
|
| 29 |
-
*
|
| 30 |
-
*
|
| 31 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
*/
|
| 33 |
export const TEXT_MODEL_REVISION = '9e1d53553228b17abfe6986fbe92e22bd6bd6bad';
|
| 34 |
export const TOKENIZER = 'sentence-transformers/clip-ViT-B-32-multilingual-v1';
|
| 35 |
export const DIMENSIONS = 512;
|
| 36 |
|
| 37 |
-
/** The URL of an interior photograph in the dataset repo, at a CDN width. */
|
| 38 |
export function imageURL(image, width = 360) {
|
| 39 |
return `${DATASET}images/${image.url}?w=${width}&q=80`;
|
| 40 |
}
|
|
@@ -110,97 +111,13 @@ export async function cachedFile(url, onProgress) {
|
|
| 110 |
*/
|
| 111 |
const VAI_STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 112 |
|
| 113 |
-
/**
|
| 114 |
-
* The prefix avatar-interiors.js puts on a vai-avatar photograph's `sku`.
|
| 115 |
-
*
|
| 116 |
-
* Namespacing it there is deliberate -- it is what stops a 16-hex image_id
|
| 117 |
-
* colliding with a baked interior's id in merge.js's de-duplication set, and it
|
| 118 |
-
* is what lets a photograph travel the same code path as a catalogue product
|
| 119 |
-
* (same viewer, same panel, same neon, same share anchor).
|
| 120 |
-
*
|
| 121 |
-
* It is NOT a catalogue identity, and /share/p/ resolves against the product
|
| 122 |
-
* catalogue -- so a photograph sent through that route answers "Not Found".
|
| 123 |
-
* Nothing about that is fixable on vai-avatar's side: no SKU of that shape
|
| 124 |
-
* exists there to find. It has to be routed as the interior it is.
|
| 125 |
-
*/
|
| 126 |
-
const AVATAR_SKU_PREFIX = 'avatarimg:';
|
| 127 |
-
|
| 128 |
-
/**
|
| 129 |
-
* Whether this record is an interior photograph published on vai-avatar.
|
| 130 |
-
*
|
| 131 |
-
* Either field is conclusive on its own. `avatarImageId` is set on every such
|
| 132 |
-
* photograph; the namespaced `sku` covers a record that has lost it.
|
| 133 |
-
*/
|
| 134 |
-
function isAvatarInterior(photo) {
|
| 135 |
-
return Boolean(photo.avatarImageId) || String(photo.sku ?? '').startsWith(AVATAR_SKU_PREFIX);
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
/**
|
| 139 |
-
* The interior id behind a record, or '' when it has none.
|
| 140 |
-
*
|
| 141 |
-
* Two sources, in order, and the second is a fallback rather than an
|
| 142 |
-
* alternative: `avatarImageId` is the field avatar-interiors.js sets, present on
|
| 143 |
-
* every vai-avatar photograph. But a record can reach shareURL() having lost it
|
| 144 |
-
* -- merge.js rewrites `id` into a namespaced tile key, and any path that
|
| 145 |
-
* rebuilds a record from its `sku` alone would carry the prefix but not the
|
| 146 |
-
* original field. That is exactly the shape that produced
|
| 147 |
-
* /share/p/avatarimg%3Ab68dc779370dc70e, which is a "Not Found" page for every
|
| 148 |
-
* single photograph in the vai-avatar interior set.
|
| 149 |
-
*
|
| 150 |
-
* So the namespaced sku is unwrapped as well, and a prefix that is present but
|
| 151 |
-
* empty (`avatarimg:` with nothing after it) yields ''.
|
| 152 |
-
*/
|
| 153 |
-
function interiorIdOf(photo) {
|
| 154 |
-
const direct = String(photo.avatarImageId ?? '').trim();
|
| 155 |
-
if (direct) return direct;
|
| 156 |
-
const sku = String(photo.sku ?? '').trim();
|
| 157 |
-
return sku.startsWith(AVATAR_SKU_PREFIX)
|
| 158 |
-
? sku.slice(AVATAR_SKU_PREFIX.length).trim()
|
| 159 |
-
: '';
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
export function shareURL(photo) {
|
| 163 |
if (!photo || typeof photo !== 'object') return null;
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
* same viewer, same detail panel, same neon, same share anchor -- and it has
|
| 170 |
-
* to stay for that. But it is not an identity the product catalogue knows,
|
| 171 |
-
* and it must not decide the URL: `/share/p/` resolves against the product
|
| 172 |
-
* catalogue, so every such photograph sent through it answered "Not Found".
|
| 173 |
-
* The whole vai-avatar interior set was affected, on the tile's share button
|
| 174 |
-
* and on the viewer's "Xem trang" alike.
|
| 175 |
-
*
|
| 176 |
-
* The interior identity is therefore resolved first and unconditionally, and
|
| 177 |
-
* /share/i/ gets the raw id -- no prefix to strip, and nothing to keep in
|
| 178 |
-
* step with whatever namespace merge.js uses for its tile ids.
|
| 179 |
-
*/
|
| 180 |
-
const interiorId = interiorIdOf(photo);
|
| 181 |
-
if (interiorId) return `${VAI_STUDIO}/share/i/${encodeURIComponent(interiorId)}`;
|
| 182 |
-
/**
|
| 183 |
-
* A record that still says it is an interior but carries no usable id gets NO
|
| 184 |
-
* share URL, rather than falling through to the product branch below.
|
| 185 |
-
*
|
| 186 |
-
* That fall-through is a trap worth naming, because it is exactly how the bug
|
| 187 |
-
* comes back. `avatarimg:` with nothing after it has no id to share, and every
|
| 188 |
-
* remaining branch here would still produce a URL for it: the product branch
|
| 189 |
-
* would encode the prefix itself into `/share/p/avatarimg%3A`, which is the
|
| 190 |
-
* dead route all over again, and the id branch would send it to `/share/i/`
|
| 191 |
-
* with an empty segment, which 404s in its own right. Returning null lets the
|
| 192 |
-
* caller omit the button instead, which is the only outcome that is true.
|
| 193 |
-
*/
|
| 194 |
-
if (isAvatarInterior(photo)) return null;
|
| 195 |
-
if (photo.sku || photo.slug) {
|
| 196 |
-
// slug first, then sku, then mod. A shared URL is a permanent, public,
|
| 197 |
-
// indexable address and the one thing about a product a reader can see:
|
| 198 |
-
// `MH800TC` says nothing about what is linked, while
|
| 199 |
-
// `mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm` reads as the product's name
|
| 200 |
-
// to a person and to a crawler. The slug is already the canonical key this
|
| 201 |
-
// Space's catalogue uses, and vai-avatar's /share/p/ matches on it first, so
|
| 202 |
-
// this changes how the link reads, not whether it resolves.
|
| 203 |
-
const id = String(photo.slug || photo.sku || photo.mod || '').trim();
|
| 204 |
return id ? `${VAI_STUDIO}/share/p/${encodeURIComponent(id)}` : null;
|
| 205 |
}
|
| 206 |
const id = String(photo.id ?? '').trim();
|
|
|
|
| 1 |
+
|
| 2 |
+
// Image database: 1,222 Vietnamese interior-design photographs, encoded offline with
|
| 3 |
+
// CLIP-ViT-B-32 and stored as raw little-endian float32 in 512-d rows.
|
|
|
|
| 4 |
export const DATASET = 'https://huggingface.co/datasets/bep40/interior-image-field-assets/resolve/main/';
|
| 5 |
|
| 6 |
+
// Text tower. `sentence-transformers/clip-ViT-B-32-multilingual-v1` maps 50+ languages
|
| 7 |
+
// (including Vietnamese) into the same 512-d space as CLIP-ViT-B-32, so a Vietnamese
|
| 8 |
+
// query retrieves the matching interior photograph. The stock repository ships a
|
| 9 |
+
// SentenceTransformer wrapper whose ONNX export stops at 768-d `last_hidden_state`,
|
| 10 |
+
// so this fork adds a `text_model.onnx` that includes mean pooling and the 768->512
|
| 11 |
+
// projection, letting Transformers.js run it under the usual CLIP file naming.
|
|
|
|
|
|
|
|
|
|
| 12 |
export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text';
|
| 13 |
|
| 14 |
/**
|
|
|
|
| 17 |
*
|
| 18 |
* The repo uploaded a broken export first (commit 68dc949, 136,951,792 bytes).
|
| 19 |
* Its graph declares `masked` as [batch_size, sequence_length, 768] while an
|
| 20 |
+
* attention mask only ever has 1 feature, so ONNX shape inference aborts with
|
| 21 |
+
*
|
| 22 |
+
* [ShapeInferenceError] Can't merge shape info ... Inferred=1 Declared=768
|
| 23 |
*
|
| 24 |
+
* and the app dies at "Không tải được bộ tìm kiếm". A later commit (9e1d535,
|
| 25 |
+
* 136,915,522 bytes) re-exported the graph with no `value_info` at all, which
|
| 26 |
+
* loads fine -- that is the revision pinned here.
|
| 27 |
+
*
|
| 28 |
+
* Pinning matters beyond reproducibility. transformers.js requests the
|
| 29 |
+
* *unversioned* `/resolve/main/...` URL unless `revision` is given, and caches
|
| 30 |
+
* under that same URL. Anyone who loaded the app while `main` still pointed at
|
| 31 |
+
* the broken graph keeps the bad bytes indefinitely: reload and "Thử lại" both
|
| 32 |
+
* replay from that cache. Naming an explicit commit changes the cache key, so
|
| 33 |
+
* those visitors fetch the known-good file instead of staying stuck.
|
| 34 |
*/
|
| 35 |
export const TEXT_MODEL_REVISION = '9e1d53553228b17abfe6986fbe92e22bd6bd6bad';
|
| 36 |
export const TOKENIZER = 'sentence-transformers/clip-ViT-B-32-multilingual-v1';
|
| 37 |
export const DIMENSIONS = 512;
|
| 38 |
|
|
|
|
| 39 |
export function imageURL(image, width = 360) {
|
| 40 |
return `${DATASET}images/${image.url}?w=${width}&q=80`;
|
| 41 |
}
|
|
|
|
| 111 |
*/
|
| 112 |
const VAI_STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
export function shareURL(photo) {
|
| 115 |
if (!photo || typeof photo !== 'object') return null;
|
| 116 |
+
if (photo.sku) {
|
| 117 |
+
// The sku is the identity vai-avatar writes edits under, so it is always
|
| 118 |
+
// present for a product; `mod` is the fallback for the handful of rows that
|
| 119 |
+
// carry only a model code.
|
| 120 |
+
const id = String(photo.sku || photo.mod || '').trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return id ? `${VAI_STUDIO}/share/p/${encodeURIComponent(id)}` : null;
|
| 122 |
}
|
| 123 |
const id = String(photo.id ?? '').trim();
|
src/avatar-interiors.js
DELETED
|
@@ -1,224 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Interior photographs published on vai-avatar, read live.
|
| 3 |
-
* =========================================================================
|
| 4 |
-
*
|
| 5 |
-
* This Space used to know about exactly one collection of interiors: the 1,222
|
| 6 |
-
* photographs baked into bep40/interior-image-field-assets as a frozen CLIP
|
| 7 |
-
* matrix. Anything added on vai-avatar -- an AI image, a photo a customer sent
|
| 8 |
-
* by Zalo, an edit of either -- was invisible here until somebody re-ran a
|
| 9 |
-
* 1,222-image embed, which nobody was going to do.
|
| 10 |
-
*
|
| 11 |
-
* vai-avatar writes its interiors to a dataset this file reads directly:
|
| 12 |
-
*
|
| 13 |
-
* bep40/vai-avatar2-interior index.json (1,134 rows)
|
| 14 |
-
*
|
| 15 |
-
* `index.json` is the one to use, not `metadata.json`, because it carries
|
| 16 |
-
* `created_at`. That single field is what makes "newest" answerable: 153 of the
|
| 17 |
-
* rows have one, spanning 2026-09-20 to 2026-10-05, and the newest is the image
|
| 18 |
-
* generated minutes before this file was written. `metadata.json` has 1,752
|
| 19 |
-
* rows but no timestamps at all, so it cannot order anything.
|
| 20 |
-
*
|
| 21 |
-
* ── Why the dataset and not the API ──
|
| 22 |
-
* bep40-vai-avatar2-interior-api serves the same records and serves images, but
|
| 23 |
-
* the dataset is the canonical write target: the Space writes there, the Hub
|
| 24 |
-
* versions it, and a read needs no second Space to be awake. Both were checked
|
| 25 |
-
* to return `access-control-allow-origin` echoing the caller, which is what
|
| 26 |
-
* lets a tile load cross-origin from this Space at all -- without it every tile
|
| 27 |
-
* would silently fall back to its BlurHash and the failure would look like a
|
| 28 |
-
* layout bug rather than a network one.
|
| 29 |
-
*/
|
| 30 |
-
|
| 31 |
-
// Semantic synonym expansion ("nhà phố" -> nhà ống, "biệt thự" -> villa),
|
| 32 |
-
// so a caption spelled one way answers a query typed another way.
|
| 33 |
-
import { expandQueryTerms } from './semantic-terms.js';
|
| 34 |
-
|
| 35 |
-
/** The dataset vai-avatar's interior index is committed to. */
|
| 36 |
-
export const AVATAR_INTERIOR_DATASET = 'https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/';
|
| 37 |
-
|
| 38 |
-
/**
|
| 39 |
-
* Image host, preferred over the dataset path because it is the URL vai-avatar's
|
| 40 |
-
* own share pages use, so the same photograph resolves the same way everywhere.
|
| 41 |
-
* The dataset copy under images/ is the versioned fallback.
|
| 42 |
-
*/
|
| 43 |
-
export const AVATAR_INTERIOR_API = 'https://bep40-vai-avatar2-interior-api.hf.space';
|
| 44 |
-
|
| 45 |
-
/** How many recent additions are shown at once. */
|
| 46 |
-
export const RECENT_LIMIT = 60;
|
| 47 |
-
|
| 48 |
-
/**
|
| 49 |
-
* How many of the newest photographs carry the neon ring.
|
| 50 |
-
*
|
| 51 |
-
* Separate from RECENT_LIMIT on purpose. The brief asks for the newest photos to
|
| 52 |
-
* be highlighted, and it also asks for more photographs to be visible on the
|
| 53 |
-
* page -- and those pull in opposite directions if one list drives both. Twenty-
|
| 54 |
-
* four glowing rings on a dense field is not emphasis, it is decoration: the
|
| 55 |
-
* eye stops being able to tell which one matters.
|
| 56 |
-
*
|
| 57 |
-
* So the newest RECENT_LIMIT photographs are shown, and only the newest
|
| 58 |
-
* FEATURED_LIMIT of them ring. Everything else renders as an ordinary tile with
|
| 59 |
-
* no ring, which is what makes the ten that do ring legible.
|
| 60 |
-
*/
|
| 61 |
-
export const FEATURED_LIMIT = 10;
|
| 62 |
-
|
| 63 |
-
/**
|
| 64 |
-
* Rows to walk, newest first.
|
| 65 |
-
*
|
| 66 |
-
* Fixed rather than time-windowed on purpose. A window ("anything from the last
|
| 67 |
-
* 7 days") silently empties itself when nobody adds anything for a week, and the
|
| 68 |
-
* feature would go quiet with nothing failing. Fixed-N plus an explicit
|
| 69 |
-
* created_at sort means the panel always shows the same NUMBER of newest images
|
| 70 |
-
* and "newest" is always meaningful.
|
| 71 |
-
*
|
| 72 |
-
* 200 rather than RECENT_LIMIT because the filter below drops rows without a
|
| 73 |
-
* timestamp and rows whose image cannot be addressed, so the raw slice has to be
|
| 74 |
-
* comfortably larger than the target to still yield RECENT_LIMIT records.
|
| 75 |
-
*/
|
| 76 |
-
const RECENT_ROWS = 200;
|
| 77 |
-
|
| 78 |
-
/**
|
| 79 |
-
* A usable absolute image URL for a record, or '' if none can be built.
|
| 80 |
-
*
|
| 81 |
-
* The id is used rather than `src`: `src` points at third-party CDNs as well as
|
| 82 |
-
* local copies, and one of those URLs failing on a customer's network would put a
|
| 83 |
-
* permanently broken tile in the field with no visible cause.
|
| 84 |
-
*/
|
| 85 |
-
function leadImage(record) {
|
| 86 |
-
const id = String(record?.image_id || '').trim();
|
| 87 |
-
return id ? `${AVATAR_INTERIOR_API}/image/${encodeURIComponent(id)}.jpg` : '';
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
/** Fold Vietnamese diacritics so "Bếp đá" and "bep da" compare equal. */
|
| 91 |
-
export function foldText(value) {
|
| 92 |
-
return String(value ?? '')
|
| 93 |
-
.normalize('NFD')
|
| 94 |
-
.replace(/[̀-ͯ]/g, '')
|
| 95 |
-
.replace(/đ/g, 'd')
|
| 96 |
-
.replace(/Đ/g, 'D')
|
| 97 |
-
.toLowerCase();
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
/**
|
| 101 |
-
* Turn one index.json row into a field record.
|
| 102 |
-
*
|
| 103 |
-
* The shape is deliberately the one merge.js already understands for a product
|
| 104 |
-
* (`sku` present, `images` an array) so a fresh interior photograph travels
|
| 105 |
-
* the same path as a catalogue product: same viewer, same share anchor, same neon.
|
| 106 |
-
* Interiors from the baked dataset have no `sku`, and keeping the two apart
|
| 107 |
-
* would put every new photograph on its own code path for no gain.
|
| 108 |
-
*/
|
| 109 |
-
function toRecord(row) {
|
| 110 |
-
const id = String(row?.image_id || '').trim();
|
| 111 |
-
if (!id) return null;
|
| 112 |
-
const image = leadImage(row);
|
| 113 |
-
if (!image) return null;
|
| 114 |
-
// Title first, then the article's alt text, then the post title: `desc` is
|
| 115 |
-
// what a person typed when they added the image, which is the most specific
|
| 116 |
-
// thing on the row and the most likely to be what someone later types back.
|
| 117 |
-
const title = String(row.desc || row.alt || (row.post_titles || [])[0] || '').trim();
|
| 118 |
-
const categories = []
|
| 119 |
-
.concat(row.categories || [], row.room_hints || [], row.labels || [])
|
| 120 |
-
.map(value => String(value || '').trim())
|
| 121 |
-
.filter(Boolean);
|
| 122 |
-
const keywords = (row.keywords || []).map(value => String(value || '').trim()).filter(Boolean);
|
| 123 |
-
const w = Number(row.w), h = Number(row.h);
|
| 124 |
-
const ar = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
|
| 125 |
-
return {
|
| 126 |
-
// Namespaced so it can never collide with a Malloca SKU or a baked interior
|
| 127 |
-
// id in merge.js's de-duplication set.
|
| 128 |
-
sku: `avatarimg:${id}`,
|
| 129 |
-
avatarImageId: id,
|
| 130 |
-
title,
|
| 131 |
-
name: title,
|
| 132 |
-
description: String(row.desc || '').trim(),
|
| 133 |
-
categories,
|
| 134 |
-
keywords,
|
| 135 |
-
source: String(row.source || '').trim(),
|
| 136 |
-
link: String((row.post_urls || [])[0] || '').trim(),
|
| 137 |
-
ar: Math.max(0.5, Math.min(2, ar)),
|
| 138 |
-
images: [image],
|
| 139 |
-
src: image,
|
| 140 |
-
/** ISO string. The sort key, and what "new" means everywhere below. */
|
| 141 |
-
addedAt: String(row.created_at || '').trim(),
|
| 142 |
-
};
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
/**
|
| 146 |
-
* Read the newest interior photographs published on vai-avatar.
|
| 147 |
-
*
|
| 148 |
-
* Returns records sorted newest-first, capped at `limit`. An empty array on any
|
| 149 |
-
* failure -- this is an enhancement on top of a working search, and a Hub
|
| 150 |
-
* hiccup must not take the field down with it.
|
| 151 |
-
*/
|
| 152 |
-
export async function loadRecentAvatarInteriors(limit = RECENT_LIMIT) {
|
| 153 |
-
try {
|
| 154 |
-
const response = await fetch(`${AVATAR_INTERIOR_DATASET}index.json`, { cache: 'no-cache' });
|
| 155 |
-
if (!response.ok) return [];
|
| 156 |
-
const rows = await response.json();
|
| 157 |
-
if (!Array.isArray(rows)) return [];
|
| 158 |
-
const dated = rows
|
| 159 |
-
.filter(row => row && typeof row.created_at === 'string' && row.created_at)
|
| 160 |
-
.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0))
|
| 161 |
-
.slice(0, RECENT_ROWS);
|
| 162 |
-
const records = [];
|
| 163 |
-
for (const row of dated) {
|
| 164 |
-
const record = toRecord(row);
|
| 165 |
-
if (record) records.push(record);
|
| 166 |
-
}
|
| 167 |
-
records.sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : 0));
|
| 168 |
-
return records.slice(0, limit);
|
| 169 |
-
} catch {
|
| 170 |
-
// Network failure, CORS, malformed JSON: the field keeps working without it.
|
| 171 |
-
return [];
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
/**
|
| 176 |
-
* Interiors from vai-avatar that match a query.
|
| 177 |
-
*
|
| 178 |
-
* The baked CLIP matrix cannot score these -- it was computed from 1,222 images
|
| 179 |
-
* and holds no vector for anything added since -- so the query is matched
|
| 180 |
-
* against the record's own text instead. That is exact for how a person actually
|
| 181 |
-
* adds an image: they type a caption, and that caption is what somebody later
|
| 182 |
-
* types back when searching for it.
|
| 183 |
-
*
|
| 184 |
-
* A record containing the WHOLE query scores 900, which is the same identity
|
| 185 |
-
* threshold relevance.js uses for the neon outline, so an exact literal match
|
| 186 |
-
* here lights up for the same reason a matched SKU does.
|
| 187 |
-
*
|
| 188 |
-
* The term set is EXPANDED with semantic synonyms (semantic-terms.js) before
|
| 189 |
-
* matching: a caption "Nhà ống mặt tiền" must answer a search for "nhà phố",
|
| 190 |
-
* and "Biệt thự vườn" must answer "biệt thự". Expansion lives in one shared
|
| 191 |
-
* table so the synonym pairs can never drift between the two Spaces that read
|
| 192 |
-
* this dataset.
|
| 193 |
-
*
|
| 194 |
-
* Additive to the CLIP results, never a replacement: returning nothing leaves
|
| 195 |
-
* the field exactly as it was before this feature existed.
|
| 196 |
-
*/
|
| 197 |
-
export function searchAvatarInteriors(text, items, limit = RECENT_LIMIT) {
|
| 198 |
-
const folded = foldText(String(text || '').trim());
|
| 199 |
-
if (folded.length < 2 || !items?.length) return [];
|
| 200 |
-
const terms = folded.split(/\s+/).filter(term => term.length > 1);
|
| 201 |
-
// "nhà phố" -> [nha, pho, ong, biet, thu]: a caption spelled "nhà ống" or
|
| 202 |
-
// "biệt thự" is the same intent as the query, and lexical-only matching
|
| 203 |
-
// would never see it. See semantic-terms.js for why the table is narrow.
|
| 204 |
-
const matchTerms = expandQueryTerms(terms);
|
| 205 |
-
const scored = [];
|
| 206 |
-
for (const item of items) {
|
| 207 |
-
const haystack = foldText([item.title, item.description, ...(item.categories || []), ...(item.keywords || [])].join(' '));
|
| 208 |
-
let score = 0;
|
| 209 |
-
if (folded.length >= 3 && haystack.includes(folded)) score += 900;
|
| 210 |
-
let hits = 0;
|
| 211 |
-
for (const term of matchTerms) if (haystack.includes(term)) { score += 25; hits++; }
|
| 212 |
-
if (!hits) continue;
|
| 213 |
-
// Covering the whole query beats matching one rare word hard.
|
| 214 |
-
score += hits * 10;
|
| 215 |
-
// A single accidental 25-point term (e.g. "tu" matching one caption) is
|
| 216 |
-
// noise: unrelated interiors must not appear at all. Keep only interiors
|
| 217 |
-
// with a real match — either the whole-phrase 900 identity hit, or enough
|
| 218 |
-
// term coverage to reach a meaningful 60 points.
|
| 219 |
-
if (score < 900 && score < 60) continue;
|
| 220 |
-
scored.push({ ...item, score });
|
| 221 |
-
}
|
| 222 |
-
scored.sort((a, b) => b.score - a.score || (a.addedAt < b.addedAt ? 1 : -1));
|
| 223 |
-
return scored.slice(0, limit);
|
| 224 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/catalogue.js
CHANGED
|
@@ -237,10 +237,6 @@ export function normalizeProduct(record) {
|
|
| 237 |
if (!identity || !name) return null;
|
| 238 |
return {
|
| 239 |
sku: identity,
|
| 240 |
-
// Carried through so the viewer's share link can address the product by its
|
| 241 |
-
// readable slug. Already the canonical key in products-gallery.json, and
|
| 242 |
-
// vai-avatar's /share/p/ matches on it first.
|
| 243 |
-
slug,
|
| 244 |
model: text(record.mod, record.model, sku),
|
| 245 |
name,
|
| 246 |
brand: text(record.brand),
|
|
@@ -253,11 +249,6 @@ export function normalizeProduct(record) {
|
|
| 253 |
specs: specsOf(record.specs),
|
| 254 |
feats: featuresOf(record),
|
| 255 |
addedBy: text(record._addedBy, record._source),
|
| 256 |
-
// The recency of an add via vai-avatar's "Thêm SP". Preserved so the field
|
| 257 |
-
// can enlarge recently-added products the same way it enlarges recently
|
| 258 |
-
// published interiors; a brand-new Grandx/Garis row is as much "new" as the
|
| 259 |
-
// newest avatar interior is.
|
| 260 |
-
addedAt: record._added_at || record.addedAt || null,
|
| 261 |
};
|
| 262 |
}
|
| 263 |
|
|
@@ -905,40 +896,6 @@ function isTombstone(record) {
|
|
| 905 |
return flag === true || flag === 1 || flag === 'true' || flag === '1';
|
| 906 |
}
|
| 907 |
|
| 908 |
-
/**
|
| 909 |
-
* Image directories on the V.AISTUDIO Space that no longer exist (verified 404
|
| 910 |
-
* on /resolve/main/). A URL under one of these can never render a photograph,
|
| 911 |
-
* so a row whose LEAD image points here would paint a broken tile. The old
|
| 912 |
-
* Eurogold export lived in /eurogold_img/ and /eurogold_tb_img/; the current
|
| 913 |
-
* live sources live at www.eurogold.vn (see the conflict rows below).
|
| 914 |
-
*/
|
| 915 |
-
const DEAD_IMAGE_DIRS = [
|
| 916 |
-
'/eurogold_img/',
|
| 917 |
-
'/eurogold_tb_img/',
|
| 918 |
-
'/demax_img/',
|
| 919 |
-
'/canzy_img/',
|
| 920 |
-
'/images/',
|
| 921 |
-
];
|
| 922 |
-
|
| 923 |
-
const DEAD_IMAGE_HOSTS = ['huggingface.co/spaces/bep40/v.aistudio'];
|
| 924 |
-
|
| 925 |
-
/** True when a URL points into a V.AISTUDIO directory that no longer exists. */
|
| 926 |
-
function isDeadImageUrl(url) {
|
| 927 |
-
if (typeof url !== 'string' || !url) return false;
|
| 928 |
-
const lower = url.toLowerCase();
|
| 929 |
-
if (!DEAD_IMAGE_HOSTS.some(host => lower.includes(host))) return false;
|
| 930 |
-
return DEAD_IMAGE_DIRS.some(dir => lower.includes(dir));
|
| 931 |
-
}
|
| 932 |
-
|
| 933 |
-
/** The first renderable URL in a record's gallery, or ''. */
|
| 934 |
-
function leadImageOf(record) {
|
| 935 |
-
return imagesOf(record)[0] || '';
|
| 936 |
-
}
|
| 937 |
-
|
| 938 |
-
function leadIsDead(record) {
|
| 939 |
-
return isDeadImageUrl(leadImageOf(record));
|
| 940 |
-
}
|
| 941 |
-
|
| 942 |
/**
|
| 943 |
* Build the product map from both live sources.
|
| 944 |
*
|
|
@@ -969,37 +926,6 @@ export function buildCatalogue(addedRecords, promos, baseRecords) {
|
|
| 969 |
function upsert(key, patch, supersede) {
|
| 970 |
const existing = raw.get(key);
|
| 971 |
if (!existing) { raw.set(key, { ...patch }); return; }
|
| 972 |
-
/**
|
| 973 |
-
* Prefer the row whose LEAD image can actually render.
|
| 974 |
-
*
|
| 975 |
-
* The base catalogue carries two rows for the same Eurogold/SKU in the
|
| 976 |
-
* conflict cases (one with a live www.eurogold.vn photo, one whose
|
| 977 |
-
* /eurogold_img/... URL is dead), and the plain merge below lets the LAST
|
| 978 |
-
* row overwrite the good photograph with a 404. Guarding the merge keeps
|
| 979 |
-
* the live image: a dead-lead patch only replaces the existing row when
|
| 980 |
-
* the existing row is dead too, so a good photo can never be clobbered by
|
| 981 |
-
* a broken one and a logo can never become a product's lead.
|
| 982 |
-
*/
|
| 983 |
-
const existingDead = leadIsDead(existing);
|
| 984 |
-
const patchDead = leadIsDead(patch);
|
| 985 |
-
if (!existingDead && patchDead) {
|
| 986 |
-
// The patch may still carry a fresh name / price / specs; only its
|
| 987 |
-
// image fields are refused. Applying the rest of the patch but keeping
|
| 988 |
-
// the existing (live) gallery is what lets an edit land without
|
| 989 |
-
// clobbering a photograph. Base rows upsert without a `supersede`
|
| 990 |
-
// table, so the images are restored explicitly rather than via
|
| 991 |
-
// EDIT_SUPERSEDES.
|
| 992 |
-
const merged = { ...existing };
|
| 993 |
-
if (supersede) {
|
| 994 |
-
for (const [field, stale] of supersede) {
|
| 995 |
-
if (patch[field] === undefined) continue;
|
| 996 |
-
for (const name of stale) delete merged[name];
|
| 997 |
-
}
|
| 998 |
-
}
|
| 999 |
-
const liveImgs = imagesOf(existing);
|
| 1000 |
-
raw.set(key, { ...merged, ...patch, imgs: liveImgs });
|
| 1001 |
-
return;
|
| 1002 |
-
}
|
| 1003 |
const merged = { ...existing };
|
| 1004 |
if (supersede) {
|
| 1005 |
for (const [field, stale] of supersede) {
|
|
@@ -1077,57 +1003,9 @@ export function buildCatalogue(addedRecords, promos, baseRecords) {
|
|
| 1077 |
const product = normalizeProduct(record);
|
| 1078 |
if (product) items.set(key, product);
|
| 1079 |
}
|
| 1080 |
-
recoverDeadLeads(items);
|
| 1081 |
return items;
|
| 1082 |
}
|
| 1083 |
|
| 1084 |
-
/**
|
| 1085 |
-
* Recover products whose ONLY photograph is a dead V.AISTUDIO URL.
|
| 1086 |
-
*
|
| 1087 |
-
* The /eurogold_img/ and /eurogold_tb_img/ exports were deleted from the
|
| 1088 |
-
* V.AISTUDIO Space, leaving a residue of products whose lead image 404s and
|
| 1089 |
-
* whose live twin (www.eurogold.vn) is missing from the base rows. For most
|
| 1090 |
-
* of them the dead filename is itself a SKU that DOES exist with a live
|
| 1091 |
-
* image:
|
| 1092 |
-
*
|
| 1093 |
-
* EPD4635VX -> EPD4635.jpg (one product, VX and non-VX variants)
|
| 1094 |
-
* EUM101 -> EUM101L.jpg (Liên hoàn / Liên hoàn hộp nhôm)
|
| 1095 |
-
* EUC87848EB-> EUC87848GA.jpg (Chậu rửa bát chống xước)
|
| 1096 |
-
* EAS1200G -> EAS1200.jpg (Giá mắc quần áo nâng hạ điện, G = gang trắng)
|
| 1097 |
-
* ETT103B -> ETT01B.jpg (Thanh treo góc tủ áo)
|
| 1098 |
-
*
|
| 1099 |
-
* The filename is the identity the V.AISTUDIO admin gave the upload, so
|
| 1100 |
-
* mapping through it is a restore, not a guess. Only used when the product
|
| 1101 |
-
* actually HAS a dead lead and the target row exists with its own live lead,
|
| 1102 |
-
* and only the target's LEAD image is borrowed -- never a gallery, and never
|
| 1103 |
-
* a logo.
|
| 1104 |
-
*/
|
| 1105 |
-
const EUROGOLD_DEAD_RECOVERY = {
|
| 1106 |
-
'EPD4635VX': 'EPD4635', 'EUM101': 'EUM101L', 'ETW8080VX': 'ETW8080',
|
| 1107 |
-
'ETW8090VX': 'ETW8090', 'EUC87848EB': 'EUC87848GA', 'EUC88050GA': 'EUC88050GE',
|
| 1108 |
-
'EAS1200G': 'EAS1200', 'EAS1200P': 'EAS1200', 'ETT103B': 'ETT01B', 'ETT103M': 'ETT01B',
|
| 1109 |
-
};
|
| 1110 |
-
|
| 1111 |
-
function recoverDeadLeads(items) {
|
| 1112 |
-
if (!items) return;
|
| 1113 |
-
for (const [key, product] of items) {
|
| 1114 |
-
const lead = Array.isArray(product.images) ? product.images[0] : '';
|
| 1115 |
-
if (!lead || !isDeadImageUrl(lead)) continue;
|
| 1116 |
-
let targetSku = EUROGOLD_DEAD_RECOVERY[key.toUpperCase()];
|
| 1117 |
-
if (!targetSku) {
|
| 1118 |
-
// Fall back to the dead filename itself: "EPD4635.jpg" names SKU EPD4635.
|
| 1119 |
-
const file = lead.split('/').pop().replace(/\.[a-z0-9]+$/i, '').toUpperCase();
|
| 1120 |
-
if (file && file !== key.toUpperCase()) targetSku = file;
|
| 1121 |
-
}
|
| 1122 |
-
if (!targetSku) continue;
|
| 1123 |
-
const target = items.get(targetSku);
|
| 1124 |
-
if (!target) continue;
|
| 1125 |
-
const targetLead = Array.isArray(target.images) ? target.images[0] : '';
|
| 1126 |
-
if (!targetLead || isDeadImageUrl(targetLead)) continue;
|
| 1127 |
-
product.images = [targetLead, ...product.images.filter(url => !isDeadImageUrl(url))];
|
| 1128 |
-
}
|
| 1129 |
-
}
|
| 1130 |
-
|
| 1131 |
/**
|
| 1132 |
* Content hash over identity plus the fields the UI renders.
|
| 1133 |
*
|
|
|
|
| 237 |
if (!identity || !name) return null;
|
| 238 |
return {
|
| 239 |
sku: identity,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
model: text(record.mod, record.model, sku),
|
| 241 |
name,
|
| 242 |
brand: text(record.brand),
|
|
|
|
| 249 |
specs: specsOf(record.specs),
|
| 250 |
feats: featuresOf(record),
|
| 251 |
addedBy: text(record._addedBy, record._source),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
};
|
| 253 |
}
|
| 254 |
|
|
|
|
| 896 |
return flag === true || flag === 1 || flag === 'true' || flag === '1';
|
| 897 |
}
|
| 898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
/**
|
| 900 |
* Build the product map from both live sources.
|
| 901 |
*
|
|
|
|
| 926 |
function upsert(key, patch, supersede) {
|
| 927 |
const existing = raw.get(key);
|
| 928 |
if (!existing) { raw.set(key, { ...patch }); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
const merged = { ...existing };
|
| 930 |
if (supersede) {
|
| 931 |
for (const [field, stale] of supersede) {
|
|
|
|
| 1003 |
const product = normalizeProduct(record);
|
| 1004 |
if (product) items.set(key, product);
|
| 1005 |
}
|
|
|
|
| 1006 |
return items;
|
| 1007 |
}
|
| 1008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
/**
|
| 1010 |
* Content hash over identity plus the fields the UI renders.
|
| 1011 |
*
|
src/detail.js
DELETED
|
@@ -1,369 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* What is known about the open photo, beyond the pixels.
|
| 3 |
-
* =====================================================
|
| 4 |
-
*
|
| 5 |
-
* The worker already loads the whole interior index and hands each record
|
| 6 |
-
* through untouched -- `image-embeddings.json` rows carry `title`,
|
| 7 |
-
* `categories` and `source` -- and the catalogue loader already normalises
|
| 8 |
-
* every product into `summary`, `specs` and `feats`. None of it was displayed.
|
| 9 |
-
* The viewer showed a price and a few specs for a product, and for an interior
|
| 10 |
-
* photograph it showed nothing at all: no title, no room, no categories, no
|
| 11 |
-
* link back to the article the image came from.
|
| 12 |
-
*
|
| 13 |
-
* This module turns both record shapes into one presentable summary. It is a
|
| 14 |
-
* port of `detailOf` from the vai-avatar Space's share-seo.ts, so the in-app
|
| 15 |
-
* viewer and the /share/ landing page agree on what a photograph is called and
|
| 16 |
-
* which room it shows. The two cannot import each other -- this is a static
|
| 17 |
-
* Space with no server and vai-avatar is a separate repo -- so the logic is
|
| 18 |
-
* duplicated rather than fetched, and both copies are tested against the real
|
| 19 |
-
* datasets.
|
| 20 |
-
*
|
| 21 |
-
* Pure derivation only: no fetching, no globals, and nothing here runs in
|
| 22 |
-
* the worker. It takes whatever the caller already has.
|
| 23 |
-
*/
|
| 24 |
-
|
| 25 |
-
/**
|
| 26 |
-
* Fold to a comparison key: lower case, no diacritics, no punctuation.
|
| 27 |
-
*
|
| 28 |
-
* `đ` needs its own replacement: it has no canonical decomposition, so NFD
|
| 29 |
-
* alone leaves "Nhà Đông" and "Nha Dong" as different strings.
|
| 30 |
-
*/
|
| 31 |
-
export function normKey(value) {
|
| 32 |
-
return String(value ?? '')
|
| 33 |
-
.normalize('NFD')
|
| 34 |
-
.replace(/[̀-ͯ]/g, '')
|
| 35 |
-
.replace(/đ/g, 'd')
|
| 36 |
-
.replace(/Đ/g, 'D')
|
| 37 |
-
.toLowerCase()
|
| 38 |
-
.replace(/[^a-z0-9]+/g, ' ')
|
| 39 |
-
.trim();
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
/**
|
| 43 |
-
* Strip the scraped HTML the index and catalogue carry.
|
| 44 |
-
*
|
| 45 |
-
* Worth doing twice over: the markup would otherwise be shown verbatim in the
|
| 46 |
-
* panel, and an entity like `…` tokenizes as the junk word "hellip".
|
| 47 |
-
*/
|
| 48 |
-
function plain(raw, max = 0) {
|
| 49 |
-
let text = String(raw ?? '');
|
| 50 |
-
// The scraper writes Python-style container reprs into some fields, so an
|
| 51 |
-
// empty description can literally be the two characters "[]".
|
| 52 |
-
if (!text || text === '[]' || text === '{}') return '';
|
| 53 |
-
text = text
|
| 54 |
-
.replace(/<[^>]*>/g, ' ')
|
| 55 |
-
.replace(/&#\d+;/g, ' ')
|
| 56 |
-
.replace(/&[a-z]+;/gi, ' ')
|
| 57 |
-
.replace(/\s+/g, ' ')
|
| 58 |
-
.trim();
|
| 59 |
-
return max > 0 && text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
/**
|
| 63 |
-
* Rooms, as `match key -> display label`.
|
| 64 |
-
*
|
| 65 |
-
* The two halves differ deliberately. Matching folds diacritics away because
|
| 66 |
-
* the scraped categories spell a room inconsistently; the label is the
|
| 67 |
-
* correctly-spelled Vietnamese that belongs in a heading, since deriving it from
|
| 68 |
-
* the key would print "Van Phong" with no accents at all.
|
| 69 |
-
*/
|
| 70 |
-
const ROOMS = [
|
| 71 |
-
['phong khach', 'Phòng khách'],
|
| 72 |
-
['phong ngu', 'Phòng ngủ'],
|
| 73 |
-
['phong tam', 'Phòng tắm'],
|
| 74 |
-
['phong an', 'Phòng ăn'],
|
| 75 |
-
['phong lam viec', 'Phòng làm việc'],
|
| 76 |
-
['phong thong', 'Phòng thông'],
|
| 77 |
-
['sanh', 'Sảnh'],
|
| 78 |
-
['van phong', 'Văn phòng'],
|
| 79 |
-
['ban cong', 'Ban công'],
|
| 80 |
-
['san thuong', 'Sân thượng'],
|
| 81 |
-
['hanh lang', 'Hành lang'],
|
| 82 |
-
['cau thang', 'Cầu thang'],
|
| 83 |
-
['garage', 'Garage'],
|
| 84 |
-
['tang lau', 'Tầng lầu'],
|
| 85 |
-
['nha ve sinh', 'Nhà vệ sinh'],
|
| 86 |
-
['kho', 'Kho'],
|
| 87 |
-
['bep', 'Bếp'],
|
| 88 |
-
['baner', 'Banner'],
|
| 89 |
-
['tap ket', 'Tấp kết'],
|
| 90 |
-
];
|
| 91 |
-
|
| 92 |
-
/**
|
| 93 |
-
* Categories that describe the SOURCE ARTICLE, not the room.
|
| 94 |
-
*
|
| 95 |
-
* These are scraped page titles, so the same field mixes "NỘI THẤT CĂN HỘ" with
|
| 96 |
-
* "BÁO GIÁ" and "GIỚI THIỆU". Showing an editorial section as if it were a room
|
| 97 |
-
* is simply wrong, so these are kept out of the headline and shown as topics.
|
| 98 |
-
*/
|
| 99 |
-
const EDITORIAL = [
|
| 100 |
-
'bao gia', 'gioi thieu', 'xay dung', 'sua nha', 'ai anh', 'tin tuc',
|
| 101 |
-
'kien thuc', 'video', 'hinh anh', 'thiet ke kien truc', 'blog', 'cap nhat',
|
| 102 |
-
'mua ban', 'lien he', 'tuyen dung',
|
| 103 |
-
];
|
| 104 |
-
|
| 105 |
-
/**
|
| 106 |
-
* The room a photograph shows, if its categories name one.
|
| 107 |
-
*
|
| 108 |
-
* Matching is on WORD SET, not on the whole string: the categories prepend a
|
| 109 |
-
* topic, so "NỘI THẤT VĂN PHÒNG" never matches "van phong" by prefix or
|
| 110 |
-
* equality. Requiring every word of the room to be present, and scoring by how
|
| 111 |
-
* specific the match is, makes both "phong khach" and "noi that phong khach"
|
| 112 |
-
* resolve while "van phong" still cannot claim a category that only says "phong".
|
| 113 |
-
*/
|
| 114 |
-
export function roomOf(categories) {
|
| 115 |
-
const wanted = ROOMS.map(([key, label]) => ({ label, words: new Set(normKey(key).split(' ')) }));
|
| 116 |
-
for (const category of categories) {
|
| 117 |
-
const have = new Set(normKey(category).split(' ').filter(Boolean));
|
| 118 |
-
if (!have.size) continue;
|
| 119 |
-
let best = null;
|
| 120 |
-
let bestScore = 0;
|
| 121 |
-
for (const room of wanted) {
|
| 122 |
-
let hits = 0;
|
| 123 |
-
for (const word of room.words) if (have.has(word)) hits++;
|
| 124 |
-
if (hits !== room.words.size) continue;
|
| 125 |
-
// Prefer the most specific room, so "phong khach" beats a bare "phong".
|
| 126 |
-
if (room.words.size > bestScore) { bestScore = room.words.size; best = room.label; }
|
| 127 |
-
}
|
| 128 |
-
if (best) return best;
|
| 129 |
-
}
|
| 130 |
-
return '';
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
/**
|
| 134 |
-
* Removed: hostOf().
|
| 135 |
-
*
|
| 136 |
-
* It existed only to put the scraped origin's hostname in the fact table --
|
| 137 |
-
* "Nguồn: daibau.vn", "Nguồn: malloca.com". Nothing reads that any more, and
|
| 138 |
-
* keeping it would keep `source` and `sourceHost` alive in detailOf's return,
|
| 139 |
-
* which is the part that matters: a field named `source` on a panel object is
|
| 140 |
-
* an invitation for the next person to render it again.
|
| 141 |
-
*
|
| 142 |
-
* The origin URLs are still on the records themselves (image-embeddings.json
|
| 143 |
-
* carries `source`, the catalogue carries `link`) and still go to vai-avatar,
|
| 144 |
-
* where schema.org "license" carries the attribution for crawlers. What is gone
|
| 145 |
-
* is naming them in front of a reader.
|
| 146 |
-
*/
|
| 147 |
-
|
| 148 |
-
/**
|
| 149 |
-
* The heading used when an interior record carries no title and no category.
|
| 150 |
-
*
|
| 151 |
-
* Exported so the viewer can tell "this photo is generically titled" apart from
|
| 152 |
-
* "this photo has a real title", rather than repeating the literal in two files
|
| 153 |
-
* and letting them drift apart.
|
| 154 |
-
*/
|
| 155 |
-
export const GENERIC_HEADING = 'Ảnh nội thất';
|
| 156 |
-
|
| 157 |
-
/**
|
| 158 |
-
* Building and project names, which are a category but not a topic.
|
| 159 |
-
*
|
| 160 |
-
* "VINHOMES GRAND PARK" says which development a photograph belongs to, not
|
| 161 |
-
* what it depicts. Naming it as a subject reads as a non-sequitur ("thuộc chủ
|
| 162 |
-
* đề Vinhomes Grand Park"), so it is dropped from the sentence -- the title
|
| 163 |
-
* almost always mentions the project anyway, and it reads better there.
|
| 164 |
-
*/
|
| 165 |
-
const PROJECT = /vinhomes|masteri|elysian|gamuda|global\s*city|the\s*global/i;
|
| 166 |
-
|
| 167 |
-
/**
|
| 168 |
-
* Describe an interior photograph, in Vietnamese, from what the record has.
|
| 169 |
-
*
|
| 170 |
-
* The dataset carries no description field -- image-embeddings.json has id, url,
|
| 171 |
-
* ar, blur, title, categories, source and nothing else -- so the panel used to
|
| 172 |
-
* fall back to one fixed sentence that named nothing: "Ảnh trong bộ sưu tập thiết
|
| 173 |
-
* kế nội thất Việt Nam của V.AI STUDIO". Every photograph read identically.
|
| 174 |
-
*
|
| 175 |
-
* What it does carry is a title and a category list, and those are enough to
|
| 176 |
-
* say something specific and true about each photo. The sentence leads with the
|
| 177 |
-
* room when the categories name one, names up to two topics, and otherwise --
|
| 178 |
-
* which is 679 of the 1,222 records, mostly those tagged only with a project or
|
| 179 |
-
* with an unaccented category -- it uses the title, because on those records
|
| 180 |
-
* the title is the most informative field there is.
|
| 181 |
-
*
|
| 182 |
-
* Every clause is derived from a field on the record. Nothing is invented.
|
| 183 |
-
*
|
| 184 |
-
* The categories are scraped and untidy: "Uncategorized", unaccented
|
| 185 |
-
* "XAY DUNG VA CAI TAO", "The Global City" and "THE GLOBAL CITY" both present,
|
| 186 |
-
* and a handful of nothing at all. An unaccented category is dropped from the
|
| 187 |
-
* sentence rather than rendered as-is, since "Thuộc chủ đề Xay dung va cai tao"
|
| 188 |
-
* is worse than saying nothing -- the topic survives as a tag below, which is
|
| 189 |
-
* where an unaccented label still reads fine.
|
| 190 |
-
*/
|
| 191 |
-
function describeInterior(record, room, categories) {
|
| 192 |
-
const title = plain(record?.title, 160);
|
| 193 |
-
const accented = value => /[̀-ͯ]/.test(value.normalize('NFD'));
|
| 194 |
-
|
| 195 |
-
const topics = categories
|
| 196 |
-
.filter(value => {
|
| 197 |
-
const key = normKey(value);
|
| 198 |
-
return key
|
| 199 |
-
&& key !== 'uncategorized'
|
| 200 |
-
&& accented(value)
|
| 201 |
-
&& !EDITORIAL.includes(key)
|
| 202 |
-
&& !PROJECT.test(value)
|
| 203 |
-
&& normKey(value) !== normKey(room);
|
| 204 |
-
})
|
| 205 |
-
.map(value => value.charAt(0).toUpperCase() + value.slice(1).toLowerCase());
|
| 206 |
-
|
| 207 |
-
// Drop a topic the title already says outright, so "Phòng khách đẹp" is not
|
| 208 |
-
// followed by a phrase restating "Phòng khách".
|
| 209 |
-
const titleKey = normKey(title);
|
| 210 |
-
const extra = topics.filter(topic => !titleKey.includes(normKey(topic)));
|
| 211 |
-
|
| 212 |
-
const parts = [];
|
| 213 |
-
if (room) parts.push(`Ảnh chụp ${room.toLowerCase()}`);
|
| 214 |
-
if (extra.length) {
|
| 215 |
-
// Two topics read as a list; more than two is noise, so name the first few
|
| 216 |
-
// and count the rest.
|
| 217 |
-
const named = extra.slice(0, 2).join(' và ');
|
| 218 |
-
const rest = extra.length - Math.min(2, extra.length);
|
| 219 |
-
parts.push(rest > 0 ? `thuộc chủ đề ${named} và ${rest} chủ đề khác` : `thuộc chủ đề ${named}`);
|
| 220 |
-
}
|
| 221 |
-
if (!parts.length) {
|
| 222 |
-
// No usable topic. The title is still worth saying -- it is the article's
|
| 223 |
-
// own headline, and on a record like "Sofa đã bàn giao cho căn hộ chung cư"
|
| 224 |
-
// it is the only description that exists.
|
| 225 |
-
if (title && title !== GENERIC_HEADING) {
|
| 226 |
-
return `Ảnh minh hoạ cho bài viết “${title}” trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.`;
|
| 227 |
-
}
|
| 228 |
-
return 'Ảnh trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.';
|
| 229 |
-
}
|
| 230 |
-
const sentence = parts.join(', ');
|
| 231 |
-
return `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}, trong bộ sưu tập thiết kế nội thất Việt Nam của V.AI STUDIO.`;
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
/**
|
| 235 |
-
* Describe a catalogue product from its spec sheet when it has no summary.
|
| 236 |
-
*
|
| 237 |
-
* Two thirds of the catalogue -- 6,347 of the 9,631 gallery rows -- carries an
|
| 238 |
-
* empty `summary`, because the field was never scraped from their source pages.
|
| 239 |
-
* Their panel therefore had no prose at all: a product name, a spec table, a
|
| 240 |
-
* feature list, and nothing connecting them.
|
| 241 |
-
*
|
| 242 |
-
* A spec sheet is a description in a different grammar. Naming the product's
|
| 243 |
-
* kind and material in a sentence -- "Bếp ga âm tủ bằng inox 304, kích thước
|
| 244 |
-
* 80cm, 3 vùng nấu" -- says what it is and what distinguishes it, from fields
|
| 245 |
-
* that are already there. The keys are filtered so only the ones that describe
|
| 246 |
-
* the object survive: prices, stock flags, warranty boilerplate and section
|
| 247 |
-
* headings are noise in a sentence.
|
| 248 |
-
*
|
| 249 |
-
* Falls back to the feature list when there are no usable specs, and to the
|
| 250 |
-
* brand and category when there is neither -- which is what the caller had
|
| 251 |
-
* before, so a product with nothing at all still says something true.
|
| 252 |
-
*/
|
| 253 |
-
const SPEC_SKIP = /nhóm sản phẩm|^loại|^giá|^giá bán|xuất xứ|^bảo hành|^tình trạng|^số lượng|^giao hàng|^liên hệ|thông tin|^hướng dẫn|^chính sách|^mã sản phẩm|^sku|^brand|^thương hiệu/i;
|
| 254 |
-
|
| 255 |
-
/** Keys that name the object itself -- used first, in this order of preference. */
|
| 256 |
-
const SPEC_HEADLINE = [
|
| 257 |
-
/^(loại sản phẩm|loại bếp|loại chậu|nhóm sản phẩm)/i,
|
| 258 |
-
/^(chất liệu|màu sắc|kiểu dáng|kích thước)/i,
|
| 259 |
-
];
|
| 260 |
-
|
| 261 |
-
function describeProduct(record, specs, feats) {
|
| 262 |
-
const usable = specs.filter(([key]) => key && !SPEC_SKIP.test(key));
|
| 263 |
-
if (!usable.length) {
|
| 264 |
-
if (feats.length) {
|
| 265 |
-
const named = feats.slice(0, 2).join(', ');
|
| 266 |
-
const rest = feats.length - Math.min(2, feats.length);
|
| 267 |
-
return `Sản phẩm nổi bật với ${named}${rest > 0 ? ` và ${rest} đặc điểm khác` : ''}.`;
|
| 268 |
-
}
|
| 269 |
-
return [plain(record.brand, 40), plain(record.category ?? record.c, 80)]
|
| 270 |
-
.filter(Boolean).join(' · ') || '';
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
const clauses = [];
|
| 274 |
-
for (const matcher of SPEC_HEADLINE) {
|
| 275 |
-
const hit = usable.find(([key]) => matcher.test(key));
|
| 276 |
-
// Take the first matching spec and drop it from the pool, so a later clause
|
| 277 |
-
// cannot restate the same field.
|
| 278 |
-
if (hit) clauses.push(hit[1]);
|
| 279 |
-
}
|
| 280 |
-
// Two headline fields, then the two most specific remaining facts -- enough to
|
| 281 |
-
// distinguish the product without turning the panel into the spec table it
|
| 282 |
-
// already shows.
|
| 283 |
-
for (const [key, value] of usable) {
|
| 284 |
-
if (clauses.length >= 4) break;
|
| 285 |
-
if (clauses.includes(value)) continue;
|
| 286 |
-
clauses.push(value);
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
const name = plain(record.name ?? record.n, 160) || plain(record.sku, 40);
|
| 290 |
-
const body = clauses.slice(0, 4).join(', ');
|
| 291 |
-
return name && body ? `${name}: ${body}.` : (name || body);
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
/**
|
| 295 |
-
* Build the panel contents for one open record.
|
| 296 |
-
*
|
| 297 |
-
* `record` is whatever the field holds: an interior row ({ id, url, title,
|
| 298 |
-
* categories, source }) or a catalogue product ({ sku, name, price, summary,
|
| 299 |
-
* specs, feats, link, images }). Both are flattened to the same shape, so the
|
| 300 |
-
* viewer renders one component for either.
|
| 301 |
-
*/
|
| 302 |
-
|
| 303 |
-
export function detailOf(record) {
|
| 304 |
-
/**
|
| 305 |
-
* A product, or a photograph published on vai-avatar?
|
| 306 |
-
*
|
| 307 |
-
* `sku` alone cannot answer this. avatar-interiors.js gives every vai-avatar
|
| 308 |
-
* photograph a namespaced `sku` (`avatarimg:<id>`) precisely so it travels the
|
| 309 |
-
* same code path as a catalogue product -- same viewer, same panel, same
|
| 310 |
-
* neon -- and that is worth keeping. But it is not a catalogue identity, and
|
| 311 |
-
* keying the panel off its presence rendered every such photograph as a
|
| 312 |
-
* product: the heading was the literal `avatarimg:b68dc779370dc70e`, the
|
| 313 |
-
* description came from an empty spec sheet, and no room, category or tag
|
| 314 |
-
* appeared even though the record carries all of them.
|
| 315 |
-
*
|
| 316 |
-
* `avatarImageId` is what actually distinguishes them. It is present only on
|
| 317 |
-
* records read from the vai-avatar interior index, and nothing else in the
|
| 318 |
-
* catalogue has it.
|
| 319 |
-
*/
|
| 320 |
-
const isProduct = Boolean(record?.sku) && !record?.avatarImageId;
|
| 321 |
-
const categories = Array.isArray(record?.categories)
|
| 322 |
-
? record.categories.map(value => plain(value, 60)).filter(Boolean)
|
| 323 |
-
: [];
|
| 324 |
-
const room = roomOf(categories);
|
| 325 |
-
const roomKey = normKey(room);
|
| 326 |
-
const editorial = categories.filter(value => EDITORIAL.includes(normKey(value)));
|
| 327 |
-
|
| 328 |
-
const tags = (room ? [room] : []).concat(
|
| 329 |
-
categories.filter(value => normKey(value) !== roomKey && !EDITORIAL.includes(normKey(value))),
|
| 330 |
-
).slice(0, 10);
|
| 331 |
-
|
| 332 |
-
const specs = Array.isArray(record?.specs)
|
| 333 |
-
? record.specs
|
| 334 |
-
.filter(pair => Array.isArray(pair) && pair.length >= 2)
|
| 335 |
-
.map(([key, value]) => [plain(key, 60), plain(value, 160)])
|
| 336 |
-
.filter(([key, value]) => key && value)
|
| 337 |
-
: [];
|
| 338 |
-
const feats = Array.isArray(record?.feats)
|
| 339 |
-
? record.feats.map(value => plain(value, 90)).filter(Boolean)
|
| 340 |
-
: [];
|
| 341 |
-
|
| 342 |
-
const facts = [];
|
| 343 |
-
let heading;
|
| 344 |
-
let description = plain(record?.summary ?? record?.sum ?? record?.desc, 400);
|
| 345 |
-
|
| 346 |
-
if (isProduct) {
|
| 347 |
-
heading = plain(record.name ?? record.n, 160) || plain(record.sku, 40);
|
| 348 |
-
if (!description) {
|
| 349 |
-
description = describeProduct(record, specs, feats)
|
| 350 |
-
|| [plain(record.brand, 40), plain(record.category ?? record.c, 80)].filter(Boolean).join(' · ');
|
| 351 |
-
}
|
| 352 |
-
// The catalogue already normalises a full spec sheet and a feature list;
|
| 353 |
-
// showing four of each is what made the panel worth opening at all.
|
| 354 |
-
for (const [key, value] of specs.slice(0, 4)) facts.push([key, value]);
|
| 355 |
-
for (const feature of feats.slice(0, 3)) facts.push(['Tính năng', feature]);
|
| 356 |
-
} else {
|
| 357 |
-
heading = plain(record?.title, 160) || room || GENERIC_HEADING;
|
| 358 |
-
if (!description) description = describeInterior(record, room, categories);
|
| 359 |
-
if (record?.id) facts.push(['Mã ảnh', String(record.id)]);
|
| 360 |
-
if (room) facts.push(['Không gian', room]);
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
if (editorial.length) facts.push(['Chủ đề', editorial.join(', ')]);
|
| 364 |
-
|
| 365 |
-
// No `source`, no `sourceHost`. The panel describes what the reader is
|
| 366 |
-
// looking at; where it was scraped from is not part of that, and a field
|
| 367 |
-
// carrying it is only ever going to be rendered again.
|
| 368 |
-
return { heading, room, description, tags, facts, isProduct };
|
| 369 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/image-loader.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
/** Bounded download/decode queue, with at most two image insertions per frame. */
|
| 2 |
-
export function createImageLoader({ concurrency =
|
| 3 |
const queue = [], completed = [];
|
| 4 |
const active = new Set(), cache = new Map();
|
| 5 |
let disposed = false, frame = 0, scheduled = false;
|
|
@@ -14,7 +14,7 @@ export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) {
|
|
| 14 |
|
| 15 |
function flush() {
|
| 16 |
frame = 0;
|
| 17 |
-
for (let i = 0; i <
|
| 18 |
const job = completed.shift();
|
| 19 |
if (job.cancelled) remember(job.image);
|
| 20 |
else job.receive(job.image);
|
|
@@ -36,7 +36,7 @@ export function createImageLoader({ concurrency = 12, cacheSize = 128 } = {}) {
|
|
| 36 |
active.add(job);
|
| 37 |
const photo = job.image = new Image();
|
| 38 |
photo.decoding = 'async';
|
| 39 |
-
photo.fetchPriority = job.priority <
|
| 40 |
photo.src = job.url;
|
| 41 |
// Decoding completes before this element is attached to the animated field.
|
| 42 |
photo.decode().then(() => {
|
|
|
|
| 1 |
/** Bounded download/decode queue, with at most two image insertions per frame. */
|
| 2 |
+
export function createImageLoader({ concurrency = 6, cacheSize = 64 } = {}) {
|
| 3 |
const queue = [], completed = [];
|
| 4 |
const active = new Set(), cache = new Map();
|
| 5 |
let disposed = false, frame = 0, scheduled = false;
|
|
|
|
| 14 |
|
| 15 |
function flush() {
|
| 16 |
frame = 0;
|
| 17 |
+
for (let i = 0; i < 2 && completed.length; i++) {
|
| 18 |
const job = completed.shift();
|
| 19 |
if (job.cancelled) remember(job.image);
|
| 20 |
else job.receive(job.image);
|
|
|
|
| 36 |
active.add(job);
|
| 37 |
const photo = job.image = new Image();
|
| 38 |
photo.decoding = 'async';
|
| 39 |
+
photo.fetchPriority = job.priority < 12 ? 'high' : 'low';
|
| 40 |
photo.src = job.url;
|
| 41 |
// Decoding completes before this element is attached to the animated field.
|
| 42 |
photo.decode().then(() => {
|
src/index.css
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
@import 'tailwindcss';
|
| 2 |
-
@import './neon-frame.css';
|
| 3 |
|
| 4 |
:root { font-family: Arial, Helvetica, sans-serif; color: #deded7; background: #10110f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
|
| 5 |
* { box-sizing: border-box; }
|
|
@@ -8,92 +7,17 @@ button, input, a { -webkit-tap-highlight-color: transparent; }
|
|
| 8 |
button, input { font: inherit; }
|
| 9 |
button { cursor: pointer; }
|
| 10 |
button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offset: 5px; }
|
| 11 |
-
|
| 12 |
-
/* ── V.AI STUDIO background ────────────────────────────────────────────────
|
| 13 |
-
* The same artwork vai-avatar shows on its body: public/avatars/vaistudio_center.png,
|
| 14 |
-
* copied here byte-for-byte so the two Spaces read as one brand (md5 checked).
|
| 15 |
-
*
|
| 16 |
-
* Rendered at 30% opacity as a separate fixed layer rather than as a
|
| 17 |
-
* `background-image` on .app, because:
|
| 18 |
-
*
|
| 19 |
-
* * opacity on the element would fade the photographs and the search pill
|
| 20 |
-
* along with it, and
|
| 21 |
-
* * `background-attachment: fixed` + `background-image` cannot be composed
|
| 22 |
-
* with the two dimming layers (.vignette / .grain) that already sit on top
|
| 23 |
-
* of the field, so the artwork would fight them instead of sitting under.
|
| 24 |
-
*
|
| 25 |
-
* `pointer-events: none` keeps it from swallowing clicks meant for a tile, and
|
| 26 |
-
* z-index 0 puts it below .image-field (z-index auto, painted later) while
|
| 27 |
-
* still above the base background colour.
|
| 28 |
-
*
|
| 29 |
-
* The image is 816x816 and deliberately centred-cropped on the logo, matching
|
| 30 |
-
* vai-avatar's own mobile rule: on narrow portrait screens it is sized to full
|
| 31 |
-
* width and tiled vertically so the artwork is never cropped to a logo sliver.
|
| 32 |
-
*/
|
| 33 |
-
.brand-backdrop {
|
| 34 |
-
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
| 35 |
-
background-color: #10110f;
|
| 36 |
-
background-image: url('/brand/vaistudio_center.png');
|
| 37 |
-
background-size: cover;
|
| 38 |
-
background-position: center center;
|
| 39 |
-
background-repeat: no-repeat;
|
| 40 |
-
background-attachment: fixed;
|
| 41 |
-
opacity: .3;
|
| 42 |
-
}
|
| 43 |
-
@media (max-width: 768px) {
|
| 44 |
-
.brand-backdrop { background-position: center top; background-size: 100% auto; background-repeat: repeat; background-attachment: scroll; }
|
| 45 |
-
}
|
| 46 |
.app { min-height: 320px; background: #11120f; overflow: clip; }
|
| 47 |
-
/* Above the backdrop, below the field's own dimming layers. */
|
| 48 |
-
.app { background: transparent; }
|
| 49 |
/* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
|
| 50 |
.image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
|
| 51 |
.image-tile { position: absolute; left: 0; top: 0; display: block; border: 0; padding: 0; border-radius: 2px; background-color: #252823; background-size: cover; overflow: hidden; will-change: transform; transform-origin: center; box-shadow: 0 1px 5px #0002; }
|
| 52 |
-
|
| 53 |
-
photograph. A z-index on a static element is ignored, and without this the
|
| 54 |
-
<img> -- appended after the anchor when the thumbnail loads -- paints on top
|
| 55 |
-
of it, so the share button opens the viewer instead of sharing. */
|
| 56 |
-
.image-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; filter: brightness(.92); transition: opacity 120ms ease, filter 120ms ease; }
|
| 57 |
.image-tile img.loaded { opacity: 1; }
|
| 58 |
.image-tile:hover { box-shadow: 0 10px 35px #0005; }
|
| 59 |
.image-tile:hover img { filter: brightness(1); }
|
| 60 |
-
/*
|
| 61 |
-
and, once it loads, an <img> at width/height 100%. The image is appended
|
| 62 |
-
later, so without this it paints on top and every click lands on the
|
| 63 |
-
photograph: the share button opens the viewer it was meant to bypass. */
|
| 64 |
-
/* Share link on the tile. Hidden until hover so the field stays a field: 150
|
| 65 |
-
photographs each wearing a button turns the plane into a control panel. It is
|
| 66 |
-
aria-hidden and tabIndex -1 (see ImageField), so this is purely a pointer
|
| 67 |
-
affordance -- the reachable share is the named link inside the viewer.
|
| 68 |
-
Sized in the tile's own coordinate space, so it scales with the photo. */
|
| 69 |
-
.image-share { position: absolute; z-index: 1; right: 5px; top: 5px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; background: #0d0f0bb8; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); color: #e6ebe0; opacity: 0; transform: scale(.82); transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, color 160ms ease; }
|
| 70 |
-
.image-share svg { width: 13px; height: 13px; }
|
| 71 |
-
.image-share:hover { background: #0d0f0be6; color: #ffffff; }
|
| 72 |
-
.image-share[hidden] { display: none; }
|
| 73 |
-
.image-tile:hover .image-share, .image-tile:focus-within .image-share { opacity: 1; transform: scale(1); }
|
| 74 |
-
/* Top-ranked results are the ones most likely to be sent, so theirs show the
|
| 75 |
-
button without waiting for hover. */
|
| 76 |
-
.image-tile[data-rank="1"] .image-share, .image-tile[data-rank="2"] .image-share, .image-tile[data-rank="3"] .image-share { opacity: 1; transform: scale(1); }
|
| 77 |
-
@media (prefers-reduced-motion: reduce) { .image-share { transition: opacity 120ms linear; } }
|
| 78 |
-
/* Relevance is a soft per-photo treatment, not a second results layer.
|
| 79 |
-
* neon-frame.css owns the one exception -- a ringed tile is never dimmed --
|
| 80 |
-
* because that is a property of the ring, not of the fade. */
|
| 81 |
.image-tile { opacity: var(--relevance-opacity, 1); filter: blur(var(--relevance-blur, 0px)); transition: opacity 650ms ease, filter 650ms ease; }
|
| 82 |
.image-tile:hover, .image-tile:focus-visible { opacity: 1; filter: blur(0); }
|
| 83 |
-
/*
|
| 84 |
-
Lift the hovered tile above its neighbours.
|
| 85 |
-
Required for the share button to be usable at all. Tiles overlap by design and
|
| 86 |
-
each tile's own z-index is a stable seed of its id (ImageField.jsx sets 1..100
|
| 87 |
-
from a hash), not its search rank -- so a tile stays buried under whichever
|
| 88 |
-
neighbour hashed higher, permanently. Measured on the idle field: of 79
|
| 89 |
-
in-viewport share buttons, 0 were the topmost element at their own centre --
|
| 90 |
-
13 were covered by another tile's photograph, 8 by another tile outright, and
|
| 91 |
-
the rest hit their own icon. There is no way to express "raise me above my
|
| 92 |
-
neighbours" for the button alone, because the button cannot be hovered until
|
| 93 |
-
it is on top. Scoped to .image-tile:hover, which also covers :focus-within, so
|
| 94 |
-
keyboard users reach the same affordance.
|
| 95 |
-
*/
|
| 96 |
-
.image-tile:hover, .image-tile:focus-within { z-index: 2147483647; }
|
| 97 |
.vignette { position: absolute; inset: 0; pointer-events: none; z-index: 300; background: radial-gradient(ellipse at center, transparent 42%, #0c0d0b30 100%), linear-gradient(0deg, #10110f99, transparent 70px); }
|
| 98 |
.grain { position: absolute; inset: 0; z-index: 301; pointer-events: none; opacity: .045; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E"); }
|
| 99 |
.search-center { position: absolute; z-index: 400; width: clamp(350px, 36vw, 640px); max-width: calc(100% - 44px); left: 50%; top: 50%; transform: translate(-50%, -50%); }
|
|
@@ -105,35 +29,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
|
|
| 105 |
.clear-button { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; display: grid; place-items: center; padding: 7px; color: #4c5449; border: 0; background: transparent; border-radius: 50%; transition: background 150ms; }
|
| 106 |
.clear-button:hover { background: #0000000d; }
|
| 107 |
.clear-button svg { width: 18px; height: 18px; }
|
| 108 |
-
|
| 109 |
-
/* ── The neon frame lives in src/neon-frame.css ─────────────────────────────
|
| 110 |
-
* Imported at the top of this file rather than kept inline, for one reason: it
|
| 111 |
-
* also has to ship as dist/assets/neon-frame.css, which index.html loads after
|
| 112 |
-
* the built app.css.
|
| 113 |
-
*
|
| 114 |
-
* The Space is `sdk: static`, so dist/ -- not src/ -- is what the browser gets.
|
| 115 |
-
* Until the bundle is rebuilt from source, that built app.css still carries the
|
| 116 |
-
* old `@keyframes neon-run { transform: rotate(1turn) }`, which rotated the
|
| 117 |
-
* ring's mask along with its gradient and pulled the painted border off the
|
| 118 |
-
* tile's edges. The override file is the fix that does not need a rebuild; this
|
| 119 |
-
* import is what stops the next rebuild from dropping it.
|
| 120 |
-
*
|
| 121 |
-
* One definition, two delivery routes. They are kept in step deliberately: the
|
| 122 |
-
* override is inert once the build includes these rules, rather than the two
|
| 123 |
-
* quietly describing different effects.
|
| 124 |
-
*/
|
| 125 |
-
|
| 126 |
-
/* ── Search status, footer, and the full-screen viewer ────────────────────
|
| 127 |
-
* Restored wholesale. The previous edit removed this whole tail by accident
|
| 128 |
-
* while stripping the size/position controls, which left every one of these
|
| 129 |
-
* classes unstyled: opening a photo rendered a raw <dialog> with its default
|
| 130 |
-
* UA box, the share toolbar stacked vertically as plain text, and the error
|
| 131 |
-
* state had no card behind it. The controls were 12 selectors; what went with
|
| 132 |
-
* them was everything after them in the file.
|
| 133 |
-
*/
|
| 134 |
-
/* The progress hairline along the bottom of the pill. Restored with the rest
|
| 135 |
-
of the tail -- without it the loading bar rendered as a plain block and the
|
| 136 |
-
pill gained a visible box while the CLIP model downloaded. */
|
| 137 |
.search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
|
| 138 |
.search-progress.visible { opacity: .5; }
|
| 139 |
.search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
|
|
@@ -175,15 +70,10 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
|
|
| 175 |
(share, caption, close) under justify-content: space-between, so the caption
|
| 176 |
is pushed to the middle and the share button to the leading edge. */
|
| 177 |
.photo-share { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; }
|
| 178 |
-
/* The label is the accessible name, and it doubles as the confirmation channel:
|
| 179 |
-
"Đã chia sẻ" appears here for 1.8s after a share. So the span must be
|
| 180 |
-
visually hidden, not display:none -- display:none takes the text out of the
|
| 181 |
-
accessibility tree too, leaving an icon-only button with no name at all, and
|
| 182 |
-
a phone-width button whose confirmation the reader can never see. */
|
| 183 |
.photo-share svg { width: 17px; height: 17px; flex: none; }
|
| 184 |
.photo-share:hover { background: #1b2115f2; border-color: #ffffff2e; color: #fff; }
|
| 185 |
@media (max-width: 560px) {
|
| 186 |
-
.photo-share span {
|
| 187 |
.photo-share { padding: 12px; }
|
| 188 |
}
|
| 189 |
.photo-toolbar svg { width: 22px; height: 22px; }
|
|
@@ -210,24 +100,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
|
|
| 210 |
.photo-step.is-next { right: 8px; }
|
| 211 |
}
|
| 212 |
|
| 213 |
-
/* Date chip in the time-navigation counter. Only visible when paging through
|
| 214 |
-
the interior list by addedAt: newest → oldest. */
|
| 215 |
-
.photo-counter .photo-date {
|
| 216 |
-
display: inline-flex;
|
| 217 |
-
margin-left: 8px;
|
| 218 |
-
padding: 1px 7px;
|
| 219 |
-
font-size: 10px;
|
| 220 |
-
font-weight: 600;
|
| 221 |
-
letter-spacing: .06em;
|
| 222 |
-
color: #9fd6a4;
|
| 223 |
-
background: #2a36227a;
|
| 224 |
-
border: 1px solid #4a6b3a33;
|
| 225 |
-
border-radius: 5px;
|
| 226 |
-
}
|
| 227 |
-
@media (max-width: 650px) {
|
| 228 |
-
.photo-counter .photo-date { display: none; }
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
/* Product caption inside the viewer: the same fields the detail card shows, so
|
| 232 |
a photo opened from the product strip carries its context with it. */
|
| 233 |
.photo-caption { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border: 1px solid #ffffff17; border-radius: 10px; background: #0f130ccc; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
|
@@ -242,37 +114,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offs
|
|
| 242 |
.photo-counter { display: none; }
|
| 243 |
}
|
| 244 |
|
| 245 |
-
/* Detail card inside the viewer, for interiors and products alike: title,
|
| 246 |
-
description, room/topic tags, fact table and a link to the source article --
|
| 247 |
-
none of which the viewer showed before. Collapsed by default so the
|
| 248 |
-
photograph is the first impression on a phone. */
|
| 249 |
-
.photo-detail { position: absolute; z-index: 2; left: 24px; bottom: 18px; width: min(460px, calc(100vw - 48px)); overflow: hidden; border: 1px solid #ffffff1f; border-radius: 12px; background: #0f130ce8; -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
|
| 250 |
-
.photo-detail-summary { display: flex; align-items: center; gap: 10px; padding: 11px 14px; cursor: pointer; list-style: none; -webkit-user-select: none; user-select: none; }
|
| 251 |
-
.photo-detail-summary::-webkit-details-marker { display: none; }
|
| 252 |
-
.photo-detail-summary:hover { background: #ffffff0a; }
|
| 253 |
-
.photo-detail-title { flex: 1; min-width: 0; overflow: hidden; font-size: 14px; line-height: 1.35; color: #e4e9dc; text-overflow: ellipsis; white-space: nowrap; }
|
| 254 |
-
.photo-detail-price { flex: none; font-size: 13px; font-weight: 600; color: #9fd6a4; font-variant-numeric: tabular-nums; }
|
| 255 |
-
.photo-detail-toggle { flex: none; padding: 3px 9px; font-size: 11px; color: #99a291; text-transform: uppercase; letter-spacing: .05em; border: 1px solid #ffffff1f; border-radius: 999px; transition: color 180ms; }
|
| 256 |
-
.photo-detail[open] .photo-detail-toggle { color: #cdd6c4; border-color: #ffffff33; }
|
| 257 |
-
.photo-detail-body { padding: 0 14px 13px; border-top: 1px solid #ffffff12; }
|
| 258 |
-
.photo-detail-desc { margin: 11px 0 0; font-size: 13px; line-height: 1.6; color: #c3cabb; }
|
| 259 |
-
.photo-detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 11px 0 0; padding: 0; list-style: none; }
|
| 260 |
-
.photo-detail-tags li { padding: 3px 9px; font-size: 11px; color: #b6c2ab; background: #ffffff0a; border: 1px solid #ffffff17; border-radius: 999px; }
|
| 261 |
-
.photo-detail-facts { display: grid; gap: 4px; margin: 11px 0 0; font-size: 12px; }
|
| 262 |
-
.photo-detail-facts > div { display: grid; grid-template-columns: minmax(0, 38%) minmax(0, 62%); gap: 10px; }
|
| 263 |
-
.photo-detail-facts dt { color: #939d8a; overflow-wrap: anywhere; }
|
| 264 |
-
.photo-detail-facts dd { margin: 0; color: #d5dbcd; overflow-wrap: anywhere; }
|
| 265 |
-
/* The outbound "Xem bài gốc" link is gone from the panel; see PhotoViewer.
|
| 266 |
-
No replacement rule -- the source site now appears only as a "Nguồn" row in
|
| 267 |
-
the fact table. */
|
| 268 |
-
/* When the price/spec caption is also on screen, the detail card moves to the
|
| 269 |
-
top instead: both are anchored bottom-left and would collide. */
|
| 270 |
-
.photo-viewer:has(.photo-caption) .photo-detail { top: 76px; bottom: auto; }
|
| 271 |
-
@media (max-width: 650px) {
|
| 272 |
-
.photo-detail { left: 12px; right: 12px; bottom: 12px; width: auto; }
|
| 273 |
-
.photo-viewer:has(.photo-caption) .photo-detail { top: 70px; }
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
/* Malloca product strip. Sits above the vignette so products stay legible
|
| 277 |
over the photograph field, and below the search pill's stacking level. */
|
| 278 |
@media (max-width: 650px) {
|
|
|
|
| 1 |
@import 'tailwindcss';
|
|
|
|
| 2 |
|
| 3 |
:root { font-family: Arial, Helvetica, sans-serif; color: #deded7; background: #10110f; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
|
| 4 |
* { box-sizing: border-box; }
|
|
|
|
| 7 |
button, input { font: inherit; }
|
| 8 |
button { cursor: pointer; }
|
| 9 |
button:focus-visible, a:focus-visible { outline: 2px solid #d7e1d1; outline-offset: 5px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
.app { min-height: 320px; background: #11120f; overflow: clip; }
|
|
|
|
|
|
|
| 11 |
/* Unlike hidden, clip prevents focus/scrollIntoView from panning the canvas. */
|
| 12 |
.image-field { position: absolute; inset: 0; overflow: clip; contain: strict; }
|
| 13 |
.image-tile { position: absolute; left: 0; top: 0; display: block; border: 0; padding: 0; border-radius: 2px; background-color: #252823; background-size: cover; overflow: hidden; will-change: transform; transform-origin: center; box-shadow: 0 1px 5px #0002; }
|
| 14 |
+
.image-tile img { width: 100%; height: 100%; object-fit: cover; opacity: 0; filter: brightness(.92); transition: opacity 120ms ease, filter 120ms ease; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
.image-tile img.loaded { opacity: 1; }
|
| 16 |
.image-tile:hover { box-shadow: 0 10px 35px #0005; }
|
| 17 |
.image-tile:hover img { filter: brightness(1); }
|
| 18 |
+
/* Relevance is a soft per-photo treatment, not a second results layer. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
.image-tile { opacity: var(--relevance-opacity, 1); filter: blur(var(--relevance-blur, 0px)); transition: opacity 650ms ease, filter 650ms ease; }
|
| 20 |
.image-tile:hover, .image-tile:focus-visible { opacity: 1; filter: blur(0); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
.vignette { position: absolute; inset: 0; pointer-events: none; z-index: 300; background: radial-gradient(ellipse at center, transparent 42%, #0c0d0b30 100%), linear-gradient(0deg, #10110f99, transparent 70px); }
|
| 22 |
.grain { position: absolute; inset: 0; z-index: 301; pointer-events: none; opacity: .045; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E"); }
|
| 23 |
.search-center { position: absolute; z-index: 400; width: clamp(350px, 36vw, 640px); max-width: calc(100% - 44px); left: 50%; top: 50%; transform: translate(-50%, -50%); }
|
|
|
|
| 29 |
.clear-button { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; display: grid; place-items: center; padding: 7px; color: #4c5449; border: 0; background: transparent; border-radius: 50%; transition: background 150ms; }
|
| 30 |
.clear-button:hover { background: #0000000d; }
|
| 31 |
.clear-button svg { width: 18px; height: 18px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
.search-progress { position: absolute; bottom: 0; left: 24px; right: 24px; height: 1px; opacity: 0; overflow: hidden; transition: opacity 300ms; }
|
| 33 |
.search-progress.visible { opacity: .5; }
|
| 34 |
.search-progress span { display: block; height: 100%; background: #47513f; transition: width 300ms; animation: progress-glow 1.6s ease-in-out infinite; }
|
|
|
|
| 70 |
(share, caption, close) under justify-content: space-between, so the caption
|
| 71 |
is pushed to the middle and the share button to the leading edge. */
|
| 72 |
.photo-share { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
.photo-share svg { width: 17px; height: 17px; flex: none; }
|
| 74 |
.photo-share:hover { background: #1b2115f2; border-color: #ffffff2e; color: #fff; }
|
| 75 |
@media (max-width: 560px) {
|
| 76 |
+
.photo-share span { display: none; }
|
| 77 |
.photo-share { padding: 12px; }
|
| 78 |
}
|
| 79 |
.photo-toolbar svg { width: 22px; height: 22px; }
|
|
|
|
| 100 |
.photo-step.is-next { right: 8px; }
|
| 101 |
}
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
/* Product caption inside the viewer: the same fields the detail card shows, so
|
| 104 |
a photo opened from the product strip carries its context with it. */
|
| 105 |
.photo-caption { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 18px; max-width: min(560px, calc(100vw - 48px)); padding: 12px 16px; border: 1px solid #ffffff17; border-radius: 10px; background: #0f130ccc; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
|
|
|
| 114 |
.photo-counter { display: none; }
|
| 115 |
}
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
/* Malloca product strip. Sits above the vignette so products stay legible
|
| 118 |
over the photograph field, and below the search pill's stacking level. */
|
| 119 |
@media (max-width: 650px) {
|
src/layout.js
CHANGED
|
@@ -5,121 +5,37 @@ export function seed(text) {
|
|
| 5 |
return (hash >>> 0) / 4294967295;
|
| 6 |
}
|
| 7 |
|
| 8 |
-
|
| 9 |
-
* How many photographs the field holds.
|
| 10 |
-
*
|
| 11 |
-
* 150 on desktop / 96 on a phone, up from 96 / 56. The raise is deliberate: the
|
| 12 |
-
* featured tiles are now 1.5x larger, so they occupy noticeably more of the
|
| 13 |
-
* plane than before, and at the old counts the gaps between them read as empty
|
| 14 |
-
* space rather than as a dense field. More ordinary tiles fills that space
|
| 15 |
-
* without touching the emphasis -- the newest photographs still ring, and there
|
| 16 |
-
* are still only ten of those.
|
| 17 |
-
*
|
| 18 |
-
* A phone is raised less than a desktop because tile AREA is bounded by the
|
| 19 |
-
* viewport, not by the tile count: past ~100 the extra tiles fall below their own
|
| 20 |
-
* thumbnail size and the field turns to grain.
|
| 21 |
-
*/
|
| 22 |
-
export function fieldCount(width) { return width < 650 ? 96 : 150; }
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
*
|
| 27 |
-
* Below the lower bound the tiles stop being recognisable as photographs, and
|
| 28 |
-
* above the upper bound a single tile covers the search pill and most of the
|
| 29 |
-
* viewport, so the field stops reading as a collection at all.
|
| 30 |
-
*/
|
| 31 |
-
export const MIN_TILE_SCALE = 0.6;
|
| 32 |
-
export const MAX_TILE_SCALE = 1.8;
|
| 33 |
-
|
| 34 |
-
/**
|
| 35 |
-
* How far the whole plane may be pushed, in px, before it stops being
|
| 36 |
-
* reachable. Larger than half the shortest likely viewport, so a photographer
|
| 37 |
-
* can be moved out from under the search pill without being lost off-screen.
|
| 38 |
-
*/
|
| 39 |
-
export const MAX_FIELD_OFFSET = 320;
|
| 40 |
-
|
| 41 |
-
export function clampTileScale(value) {
|
| 42 |
-
const scale = Number(value);
|
| 43 |
-
return Number.isFinite(scale) ? Math.max(MIN_TILE_SCALE, Math.min(MAX_TILE_SCALE, scale)) : 1;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
export function clampFieldOffset(value) {
|
| 47 |
-
const offset = Number(value);
|
| 48 |
-
return Number.isFinite(offset) ? Math.max(-MAX_FIELD_OFFSET, Math.min(MAX_FIELD_OFFSET, offset)) : 0;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
/**
|
| 52 |
-
* Size one tile.
|
| 53 |
-
*
|
| 54 |
-
* `tileScale` is the user's size multiplier and is applied to `unit` BEFORE the
|
| 55 |
-
* per-photograph variance, so every tile grows and shrinks together and the
|
| 56 |
-
* collection keeps its rhythm instead of the biggest photographs absorbing the
|
| 57 |
-
* whole change.
|
| 58 |
-
*/
|
| 59 |
-
export function dimensions(image, viewportWidth, tileScale = 1) {
|
| 60 |
-
const unit = Math.max(68, Math.min(168, viewportWidth / 10.25)) * tileScale;
|
| 61 |
const area = unit * unit * (0.74 + seed(image.id + 'size') * 0.48);
|
| 62 |
const ar = Math.max(0.5, Math.min(2, image.ar));
|
| 63 |
return { width: Math.sqrt(area * ar), height: Math.sqrt(area / ar) };
|
| 64 |
}
|
| 65 |
|
| 66 |
-
export function scatter(image, index, count, width, height
|
| 67 |
const columns = Math.ceil(Math.sqrt(count * width / height));
|
| 68 |
const rows = Math.ceil(count / columns);
|
| 69 |
return {
|
| 70 |
-
x: -35 + (index % columns + 0.5 + (seed(image.id + 'x') - 0.5) * 0.44) * (width + 70) / columns
|
| 71 |
-
y: -40 + (Math.floor(index / columns) + 0.5 + (seed(image.id + 'y') - 0.5) * 0.48) * (height + 80) / rows
|
| 72 |
scale: 0.88 + seed(image.id + 'scale') * 0.20,
|
| 73 |
};
|
| 74 |
}
|
| 75 |
|
| 76 |
/** One continuous layout for the whole collection, including non-matching photos. */
|
| 77 |
-
export function fieldPosition(image, rank, count, width, height
|
| 78 |
const angle = rank * 2.399963229728653 + 0.54;
|
| 79 |
const radius = Math.sqrt((rank + 0.6) / count);
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
// the box the visitor typed in, not pushed out towards the field's rim.
|
| 83 |
-
const x = width / 2 + Math.cos(angle) * radius * width * 0.62;
|
| 84 |
-
let y = Math.sin(angle) * radius * height * 0.66;
|
| 85 |
// Ease the central photographs around the pill, without a separate results layer.
|
| 86 |
-
//
|
| 87 |
-
// Clearance has to account for the TILE, not just its centre. A featured tile
|
| 88 |
-
// is 1.5x its neighbours, so a distance tuned to clear an ordinary tile still
|
| 89 |
-
// lets a featured corner land under the pill. `dimensions()` is therefore
|
| 90 |
-
// called here for the real half-height, and the push grows with it.
|
| 91 |
const clearance = Math.max(0, 1 - Math.abs(x - width / 2) / (width * 0.31));
|
| 92 |
-
|
| 93 |
-
// 1.32 for a central featured tile -- so the clearance has to be computed from
|
| 94 |
-
// the RENDERED half-extent. Measuring the unscaled box let a 289px tile clear
|
| 95 |
-
// the pill by 221px and still land on it.
|
| 96 |
-
const scale = 0.96 + seed(image.id + 'scale') * 0.18 + (1 - radius) * 0.18;
|
| 97 |
-
const size = dimensions(image, width, image?.featuredScale || 1);
|
| 98 |
-
const half = Math.max(size.width, size.height) / 2 * scale;
|
| 99 |
-
// PILL_HALF is the search box's own half-width plus margin, so "far enough" is
|
| 100 |
-
// measured from the box rather than from its centreline. The 22px margin
|
| 101 |
-
// (previously 34) lets a result settle closer to the box while still clearing
|
| 102 |
-
// the glass itself.
|
| 103 |
-
const pillHalf = (width < 650 ? 165 : 235) + 22;
|
| 104 |
-
// 80/118 was a generous shove; 74/96 keeps the same shape but brings the arc
|
| 105 |
-
// in so the top results read as "attached" to the pill rather than banished.
|
| 106 |
-
y += Math.sign(y || 1) * clearance * (width < 650 ? 60 : 92) * (1 - radius * 0.45);
|
| 107 |
-
// If the tile's own rendered box could still reach the pill, push it clear along
|
| 108 |
-
// y. Both conditions must hold: the tile is near the pill's row AND its column
|
| 109 |
-
// overlaps the pill's width. Only genuinely-overlapping tiles move, so the arc
|
| 110 |
-
// keeps its shape and tiles already clear are left alone.
|
| 111 |
-
if (clearance > 0
|
| 112 |
-
&& Math.abs(y) < half + 40
|
| 113 |
-
&& Math.abs(x - width / 2) < pillHalf + half) {
|
| 114 |
-
y += Math.sign(y || 1) * (half + 40 - Math.abs(y));
|
| 115 |
-
}
|
| 116 |
return {
|
| 117 |
-
x
|
| 118 |
-
|
| 119 |
-
// would be a second, independently-evaluated expression for one number, and
|
| 120 |
-
// the clearance would silently stop matching what is drawn if the two ever
|
| 121 |
-
// drifted apart.
|
| 122 |
-
scale,
|
| 123 |
};
|
| 124 |
}
|
| 125 |
|
|
|
|
| 5 |
return (hash >>> 0) / 4294967295;
|
| 6 |
}
|
| 7 |
|
| 8 |
+
export function fieldCount(width) { return width < 650 ? 56 : 96; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
export function dimensions(image, viewportWidth) {
|
| 11 |
+
const unit = Math.max(68, Math.min(168, viewportWidth / 10.25));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const area = unit * unit * (0.74 + seed(image.id + 'size') * 0.48);
|
| 13 |
const ar = Math.max(0.5, Math.min(2, image.ar));
|
| 14 |
return { width: Math.sqrt(area * ar), height: Math.sqrt(area / ar) };
|
| 15 |
}
|
| 16 |
|
| 17 |
+
export function scatter(image, index, count, width, height) {
|
| 18 |
const columns = Math.ceil(Math.sqrt(count * width / height));
|
| 19 |
const rows = Math.ceil(count / columns);
|
| 20 |
return {
|
| 21 |
+
x: -35 + (index % columns + 0.5 + (seed(image.id + 'x') - 0.5) * 0.44) * (width + 70) / columns,
|
| 22 |
+
y: -40 + (Math.floor(index / columns) + 0.5 + (seed(image.id + 'y') - 0.5) * 0.48) * (height + 80) / rows,
|
| 23 |
scale: 0.88 + seed(image.id + 'scale') * 0.20,
|
| 24 |
};
|
| 25 |
}
|
| 26 |
|
| 27 |
/** One continuous layout for the whole collection, including non-matching photos. */
|
| 28 |
+
export function fieldPosition(image, rank, count, width, height) {
|
| 29 |
const angle = rank * 2.399963229728653 + 0.54;
|
| 30 |
const radius = Math.sqrt((rank + 0.6) / count);
|
| 31 |
+
const x = width / 2 + Math.cos(angle) * radius * width * 0.68;
|
| 32 |
+
let y = Math.sin(angle) * radius * height * 0.70;
|
|
|
|
|
|
|
|
|
|
| 33 |
// Ease the central photographs around the pill, without a separate results layer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const clearance = Math.max(0, 1 - Math.abs(x - width / 2) / (width * 0.31));
|
| 35 |
+
y += Math.sign(y || 1) * clearance * (width < 650 ? 65 : 95) * (1 - radius * 0.45);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return {
|
| 37 |
+
x, y: height / 2 + y,
|
| 38 |
+
scale: 0.96 + seed(image.id + 'scale') * 0.18 + (1 - radius) * 0.18,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
};
|
| 40 |
}
|
| 41 |
|
src/merge.js
CHANGED
|
@@ -1,47 +1,77 @@
|
|
| 1 |
/**
|
| 2 |
* Fold Malloca products into the interior ranking, so both kinds of photograph
|
| 3 |
* occupy the SAME field and travel the same path once opened.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
/** A product has to be worth showing, but not so many that interiors vanish. */
|
| 7 |
-
const PRODUCT_LIMIT =
|
| 8 |
|
| 9 |
/** Insert product `i` after roughly (i+1) * STRIDE interiors. */
|
| 10 |
const STRIDE = 2;
|
| 11 |
|
| 12 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
const interiorKey = id => `interior:${id}`;
|
| 14 |
const productKey = sku => `malloca:${sku}`;
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
'/eurogold_img/',
|
| 25 |
-
'/eurogold_tb_img/',
|
| 26 |
-
'/demax_img/',
|
| 27 |
-
'/canzy_img/',
|
| 28 |
-
'/images/',
|
| 29 |
-
];
|
| 30 |
-
|
| 31 |
-
function isDeadImageUrl(url) {
|
| 32 |
-
if (typeof url !== 'string' || !url) return false;
|
| 33 |
-
if (!/huggingface\.co\/spaces\/bep40\/v\.aistudio/i.test(url)) return false;
|
| 34 |
-
const lower = url.toLowerCase();
|
| 35 |
-
return DEAD_IMAGE_DIRS.some(dir => lower.includes(dir));
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
function leadImage(product) {
|
| 39 |
for (const value of product?.images ?? []) {
|
| 40 |
-
if (typeof value === 'string' && /^(https?:\/\/|data:image\/)/i.test(value)
|
| 41 |
}
|
| 42 |
return '';
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
function tileDefaults(product) {
|
| 46 |
return {
|
| 47 |
id: productKey(product.sku),
|
|
@@ -49,6 +79,20 @@ function tileDefaults(product) {
|
|
| 49 |
};
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
function assignRelevance(list) {
|
| 53 |
const interiorAt = i => (i >= 0 && i < list.length && typeof list[i].score === 'number' ? i : -1);
|
| 54 |
const floor = list.reduce((min, r) => (Number.isFinite(r.score) ? Math.min(min, r.score) : min), Infinity);
|
|
@@ -65,170 +109,42 @@ function assignRelevance(list) {
|
|
| 65 |
return list;
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
function unwrap(item) {
|
| 69 |
if (!item || typeof item !== 'object') return null;
|
| 70 |
if (item.product && typeof item.product === 'object') return item.product;
|
| 71 |
return item;
|
| 72 |
}
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
};
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
function featuredTile(record, scale = 1.5) {
|
| 86 |
-
return {
|
| 87 |
-
...record,
|
| 88 |
-
...tileDefaults(record),
|
| 89 |
-
id: avatarInteriorKey(record.avatarImageId),
|
| 90 |
-
featured: true,
|
| 91 |
-
featuredScale: scale,
|
| 92 |
-
score: Number.isFinite(record.score) ? record.score : 1,
|
| 93 |
-
};
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
export function mergeResults(interiors, products, options = {}) {
|
| 97 |
const productLimit = options.productLimit ?? PRODUCT_LIMIT;
|
| 98 |
const interiorLimit = options.interiorLimit ?? 60;
|
| 99 |
|
| 100 |
const seen = new Set();
|
| 101 |
const out = [];
|
| 102 |
-
|
| 103 |
-
const fresh = [];
|
| 104 |
-
const ordered = (options.avatarInteriors ?? [])
|
| 105 |
-
.filter(item => item && typeof item === 'object' && item.avatarImageId)
|
| 106 |
-
.sort((a, b) => (a.addedAt < b.addedAt ? 1 : a.addedAt > b.addedAt ? -1 : 0));
|
| 107 |
-
|
| 108 |
-
const featuredCount = options.featuredLimit ?? FEATURED_LIMIT;
|
| 109 |
-
for (const [index, item] of ordered.entries()) {
|
| 110 |
-
const key = avatarInteriorKey(item.avatarImageId);
|
| 111 |
-
if (seen.has(key)) continue;
|
| 112 |
-
seen.add(key);
|
| 113 |
-
fresh.push(index < featuredCount ? featuredTile(item, options.avatarScale ?? 1.5) : plainTile(item));
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
const RING_LIMIT = options.recentProductRing ?? 12;
|
| 117 |
-
const showNewRow = String(options.query ?? "").trim().length === 0;
|
| 118 |
-
const recents = showNewRow
|
| 119 |
-
? (options.recentProducts ?? [])
|
| 120 |
-
.filter(item => item && typeof item === 'object' && item.sku && !seen.has(productKey(item.sku)))
|
| 121 |
-
.sort((a, b) => (String(a.addedAt || '') < String(b.addedAt || '') ? 1 : String(a.addedAt || '') > String(b.addedAt || '') ? -1 : 0))
|
| 122 |
-
: [];
|
| 123 |
-
const freshProducts = [];
|
| 124 |
-
for (const [index, item] of recents.entries()) {
|
| 125 |
-
const src = leadImage(item);
|
| 126 |
-
if (!src) continue;
|
| 127 |
-
const key = productKey(item.sku);
|
| 128 |
-
if (seen.has(key)) continue;
|
| 129 |
-
seen.add(key);
|
| 130 |
-
const tile = {
|
| 131 |
-
...item,
|
| 132 |
-
...tileDefaults(item),
|
| 133 |
-
src,
|
| 134 |
-
score: Number.isFinite(item.score) ? item.score : 1,
|
| 135 |
-
featured: true,
|
| 136 |
-
featuredScale: options.recentScale ?? 1.45,
|
| 137 |
-
};
|
| 138 |
-
freshProducts.push(tile);
|
| 139 |
-
if (freshProducts.length >= RING_LIMIT) break;
|
| 140 |
-
}
|
| 141 |
-
for (let i = freshProducts.length - 1; i >= 0; i--) fresh.unshift(freshProducts[i]);
|
| 142 |
-
// `fresh` là avatar interiors (mới nhất + những ảnh khớp query). Luôn đưa vào
|
| 143 |
-
// field — trước đây chỉ đưa khi KHÔNG có query (showNewRow), nên một ảnh mới
|
| 144 |
-
// đăng trên vai-avatar có caption khớp "nhà phố" không bao giờ hiện khi tìm
|
| 145 |
-
// "nhà phố". Từ khóa query vẫn được tôn trọng: avatarInteriors đi qua
|
| 146 |
-
// searchAvatarInteriors ở App.jsx rồi mới tới đây, nên chỉ ảnh khớp xuất hiện.
|
| 147 |
-
out.unshift(...fresh);
|
| 148 |
-
|
| 149 |
-
const KEYWORD_FLOOR = options.keywordFloor ?? 300;
|
| 150 |
-
const productAnswerItems = (products ?? []).filter(item => {
|
| 151 |
-
const product = unwrap(item);
|
| 152 |
-
const src = leadImage(product);
|
| 153 |
-
return product?.sku && src;
|
| 154 |
-
});
|
| 155 |
-
|
| 156 |
-
const queryTokens = extractQueryTokens(options.query);
|
| 157 |
-
const productTokens = productLikeTerms(queryTokens);
|
| 158 |
-
|
| 159 |
-
const decisive = hasDecisiveWinner((products ?? []).map(hit => Number.isFinite(hit?.score) ? hit.score : 0));
|
| 160 |
-
const hasProductAnswer = productTokens.length >= 2
|
| 161 |
-
&& productAnswerItems.some(item => {
|
| 162 |
-
const product = unwrap(item);
|
| 163 |
-
const score = Number.isFinite(item?.score) ? item.score : 0;
|
| 164 |
-
if (score < KEYWORD_FLOOR) return false;
|
| 165 |
-
const name = normalizeText(product.name || '');
|
| 166 |
-
return productTokens.every(token => {
|
| 167 |
-
if (wordIn(name, token)) return true;
|
| 168 |
-
const synonyms = NAME_WORD_SYNONYMS[token];
|
| 169 |
-
return !!synonyms && synonyms.some(alias => wordIn(name, alias));
|
| 170 |
-
});
|
| 171 |
-
});
|
| 172 |
-
/**
|
| 173 |
-
* Truy vấn THUẦN NGỮ NGHĨA thì không bao giờ crowd-out ảnh interiors.
|
| 174 |
-
*
|
| 175 |
-
* "bàn ghế" có thể khiến scorer dựng một sản phẩm "Bàn ghế gỗ óc chó" đạt
|
| 176 |
-
* 929 điểm (300 tất cả token trong tên + 100 tên chứa query + 2×16 title +
|
| 177 |
-
* 35 hits + ...) — vượt ngưỡng identity 700 dù KHÔNG phải trùng SKU/tên
|
| 178 |
-
* chính xác — và hasDecisiveWinner gỡ sạch ảnh khỏi field. Nhưng "bàn ghế",
|
| 179 |
-
* "nội thất", "phòng khách", "nhà phố" là câu hỏi về KHÔNG GIAN: người gõ
|
| 180 |
-
* chúng muốn xem ẢNH trước. Chỉ khi query mang token sản phẩm thật sự
|
| 181 |
-
* (productTokens.length > 0: "kệ xoong", "GP170BD", "máy hút mùi") thì
|
| 182 |
-
* identity/decisive mới được gỡ ảnh.
|
| 183 |
-
*/
|
| 184 |
-
const spaceQuery = isSpaceQuery(options.query);
|
| 185 |
-
const pureSemanticQuery = productTokens.length === 0;
|
| 186 |
-
if (!pureSemanticQuery && (decisive || hasProductAnswer)) interiors = [];
|
| 187 |
-
|
| 188 |
-
/**
|
| 189 |
-
* Truy vấn KHÔNG GIAN thì không trả sản phẩm "vô tình trùng token".
|
| 190 |
-
*
|
| 191 |
-
* "phòng khách" làm scoreProduct() dựng 24 sản phẩm khóa cửa "dùng cho cửa
|
| 192 |
-
* phòng ngủ", tay nắm "phòng tắm", vòi rửa "phong cách Nhật" — mỗi cái ghim
|
| 193 |
-
* một từ lẻ ("khach", "phong") trong tên/spec dài. Chúng không phải là câu
|
| 194 |
-
* trả lời cho "phòng khách", và chúng cướp chỗ của ảnh nội thất thật.
|
| 195 |
-
*
|
| 196 |
-
* Quy tắc: với space-query, một sản phẩm chỉ được giữ nếu TÊN của nó chứa
|
| 197 |
-
* ít nhất một token "đặc tả" của query — tức token không phải từ ngữ nghĩa
|
| 198 |
-
* ("sofa phòng khách" → giữ sofa vì tên chứa "sofa"; "phòng ngủ" thuần
|
| 199 |
-
* không-gian → không SP, field là 100% ảnh). Trúng SKU/model chính xác
|
| 200 |
-
* (decisive, xử lý ở scoredHits qua isExact) vẫn luôn được giữ.
|
| 201 |
-
*/
|
| 202 |
-
// Sản phẩm chỉ được ở lại nếu tên chứa một token đặc tả thật sự của query.
|
| 203 |
-
function staysInSpaceQuery(product) {
|
| 204 |
-
if (!spaceQuery || !product?.name) return true;
|
| 205 |
-
const name = normalizeText(product.name);
|
| 206 |
-
return productTokens.some(token => wordIn(name, token));
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
const cosineFloor = options.cosFloor ?? 0.18;
|
| 210 |
-
const cosineRel = options.cosRel ?? 0.45;
|
| 211 |
-
let topCosine = 0;
|
| 212 |
for (const image of interiors ?? []) {
|
| 213 |
-
if (
|
| 214 |
-
}
|
| 215 |
-
const effectiveFloor = Math.max(cosineFloor, topCosine * cosineRel);
|
| 216 |
-
for (const image of interiors ?? []) {
|
| 217 |
-
const score = image?.score;
|
| 218 |
-
const filteredScore = score === undefined || !Number.isFinite(score) || score >= effectiveFloor;
|
| 219 |
-
if (!image?.id || !filteredScore || seen.has(interiorKey(image.id))) continue;
|
| 220 |
seen.add(interiorKey(image.id));
|
| 221 |
out.push(image);
|
| 222 |
if (out.length >= interiorLimit) break;
|
| 223 |
}
|
| 224 |
|
| 225 |
const hits = [];
|
| 226 |
-
const exact = exactMatches(interiors ?? [], options.query ?? '', products ?? []);
|
| 227 |
-
const keywordFeatured = options.keywordFeatured ?? productLimit;
|
| 228 |
-
|
| 229 |
-
const scoredHits = [];
|
| 230 |
-
const isProductQuery = productTokens.length > 0;
|
| 231 |
-
const productFloor = options.productFloor ?? (isProductQuery ? 20 : 120);
|
| 232 |
for (const item of products ?? []) {
|
| 233 |
const product = unwrap(item);
|
| 234 |
const src = leadImage(product);
|
|
@@ -236,64 +152,17 @@ export function mergeResults(interiors, products, options = {}) {
|
|
| 236 |
const key = productKey(product.sku);
|
| 237 |
if (seen.has(key)) continue;
|
| 238 |
seen.add(key);
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
if (score !== 0 && score < productFloor) continue;
|
| 242 |
-
// Space query: đừng để sản phẩm "trùng bừa một từ" chiếm field (xem
|
| 243 |
-
// staysInSpaceQuery). Trúng SKU/model chính xác vẫn giữ — isExact là kết
|
| 244 |
-
// quả của `exactMatches`, chỉ set khi có decisive winner >= 700.
|
| 245 |
-
if (!isExact && !staysInSpaceQuery(product)) continue;
|
| 246 |
-
scoredHits.push({ product, src, key, isExact, score });
|
| 247 |
-
if (scoredHits.length >= productLimit) break;
|
| 248 |
}
|
| 249 |
-
const freshAdded = options.freshAdded ?? 4;
|
| 250 |
-
scoredHits.sort((a, b) => b.score - a.score);
|
| 251 |
-
const freshOrdered = scoredHits
|
| 252 |
-
.filter(hit => {
|
| 253 |
-
const at = hit.product?.addedAt;
|
| 254 |
-
return typeof at === 'string' ? at.length > 0 : Number.isFinite(at) && at > 0;
|
| 255 |
-
})
|
| 256 |
-
.sort((a, b) => String(b.product?.addedAt || '').localeCompare(String(a.product?.addedAt || '')));
|
| 257 |
-
const freshSkus = new Set(freshOrdered.slice(0, freshAdded).map(hit => hit.product.sku));
|
| 258 |
-
scoredHits.forEach((hit, rank) => {
|
| 259 |
-
// Products never ring (neon) on semantic queries (ban, ghe, noi that, nha
|
| 260 |
-
// pho, biet thu): the ring says "this IS the photo you asked for", and a
|
| 261 |
-
// kitchen accessory thumbnail next to a semantic query would say the
|
| 262 |
-
// opposite. Only real photographs (CLIP interiors, fresh vai-avatar shots)
|
| 263 |
-
// get the ring; decisive exact SKUs still ring.
|
| 264 |
-
const isSemanticQuery = productTokens.length === 0;
|
| 265 |
-
const featuredByKeyword = hit.isExact
|
| 266 |
-
|| (rank < keywordFeatured && hit.score >= KEYWORD_FLOOR && !isSemanticQuery);
|
| 267 |
-
const fresh = freshSkus.has(hit.product.sku);
|
| 268 |
-
const featured = hit.isExact || featuredByKeyword || fresh;
|
| 269 |
-
const scale = hit.isExact ? (options.exactScale ?? 1.55)
|
| 270 |
-
: (fresh ? (options.freshScale ?? 1.45) : (options.keywordScale ?? 1.4));
|
| 271 |
-
hits.push({
|
| 272 |
-
...hit.product,
|
| 273 |
-
...tileDefaults(hit.product),
|
| 274 |
-
src: hit.src,
|
| 275 |
-
...(featured ? { featured: true, featuredScale: scale } : {}),
|
| 276 |
-
});
|
| 277 |
-
});
|
| 278 |
if (!hits.length) return out;
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
| 285 |
}
|
| 286 |
return assignRelevance(out);
|
| 287 |
}
|
| 288 |
-
|
| 289 |
-
function extractQueryTokens(query) {
|
| 290 |
-
const text = normalizeText(String(query || '').trim());
|
| 291 |
-
if (!text) return [];
|
| 292 |
-
return text.split(/\s+/).filter(token => token.length > 1);
|
| 293 |
-
}
|
| 294 |
-
|
| 295 |
-
function wordIn(text, token) {
|
| 296 |
-
if (!text || !token) return false;
|
| 297 |
-
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
| 298 |
-
return new RegExp(`(^|[^a-z0-9])${escaped}($|[^a-z0-9])`).test(text);
|
| 299 |
-
}
|
|
|
|
| 1 |
/**
|
| 2 |
* Fold Malloca products into the interior ranking, so both kinds of photograph
|
| 3 |
* occupy the SAME field and travel the same path once opened.
|
| 4 |
+
*
|
| 5 |
+
* ── Why this is done by interleaving, not by embedding the products ──
|
| 6 |
+
* A product image could in principle be pushed through the same CLIP image
|
| 7 |
+
* tower as the interiors and baked into the 512-d matrix. That was rejected
|
| 8 |
+
* deliberately:
|
| 9 |
+
*
|
| 10 |
+
* * The product set is written by vai-avatar at runtime, and a baked matrix
|
| 11 |
+
* can only know what existed when it was built, so "Thêm SP" would stay
|
| 12 |
+
* invisible until somebody re-ran an embed over the whole catalogue.
|
| 13 |
+
* * There is no image tower in the pinned model repo to do it with: it ships
|
| 14 |
+
* `text_model.onnx` and nothing else, so encoding product photos would need
|
| 15 |
+
* a second model and a second download.
|
| 16 |
+
*
|
| 17 |
+
* The catalogue is small and live (a few hundred products, not thousands), so
|
| 18 |
+
* ranking it lexically per query is cheap. Its hits are then woven into the
|
| 19 |
+
* interior ranking by rank position, which is what makes a product appear
|
| 20 |
+
* among the interior photographs instead of in a separate bar.
|
| 21 |
+
*
|
| 22 |
+
* ── Why the interleaving is positional and not append-only ──
|
| 23 |
+
* Appending products after the interiors would make "bếp" show thirty kitchen
|
| 24 |
+
* photographs and then the ovens, which reads as a second result list rather
|
| 25 |
+
* than one field. Spacing them by rank puts each product roughly where its
|
| 26 |
+
* strength justifies it, so a query that matches both kinds of image produces
|
| 27 |
+
* one continuous, plausibly-ordered plane.
|
| 28 |
*/
|
| 29 |
|
| 30 |
/** A product has to be worth showing, but not so many that interiors vanish. */
|
| 31 |
+
const PRODUCT_LIMIT = 12;
|
| 32 |
|
| 33 |
/** Insert product `i` after roughly (i+1) * STRIDE interiors. */
|
| 34 |
const STRIDE = 2;
|
| 35 |
|
| 36 |
+
/**
|
| 37 |
+
* De-duplication keys, one namespace per kind of record.
|
| 38 |
+
*
|
| 39 |
+
* Sharing a single set would mean an interior id and a product key competing
|
| 40 |
+
* for the same slot, so any collision silently DROPS a product from the field
|
| 41 |
+
* with no error. Tagging both sides removes the possibility rather than
|
| 42 |
+
* narrowing it: interior ids are opaque strings, and it is not the caller's
|
| 43 |
+
* job to know they happen to be 10 hex characters today.
|
| 44 |
+
*/
|
| 45 |
const interiorKey = id => `interior:${id}`;
|
| 46 |
const productKey = sku => `malloca:${sku}`;
|
| 47 |
|
| 48 |
+
/**
|
| 49 |
+
* The image that represents a product in the field.
|
| 50 |
+
*
|
| 51 |
+
* Only absolute http(s) and inline data URLs are kept. Anything else -- a
|
| 52 |
+
* relative path, a blob, an object -- cannot be rendered by an <img> here, and
|
| 53 |
+
* a tile that renders nothing is worse than a product that stays in the
|
| 54 |
+
* catalogue and is reachable through search.
|
| 55 |
+
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
function leadImage(product) {
|
| 57 |
for (const value of product?.images ?? []) {
|
| 58 |
+
if (typeof value === 'string' && /^(https?:\/\/|data:image\/)/i.test(value)) return value;
|
| 59 |
}
|
| 60 |
return '';
|
| 61 |
}
|
| 62 |
|
| 63 |
+
/**
|
| 64 |
+
* Product photographs are catalogue shots, not interiors, so they arrive with
|
| 65 |
+
* none of the geometry the field needs. Two defaults stand in for it:
|
| 66 |
+
*
|
| 67 |
+
* id -- every tile is keyed on it, and the product's own sku is the natural
|
| 68 |
+
* stable identity. Layout seeds (position, size, phase) are derived
|
| 69 |
+
* from it, so a product keeps the same place and scale across
|
| 70 |
+
* searches instead of jumping when it re-ranks.
|
| 71 |
+
* ar -- dimensions() clamps this to [0.5, 2]. Without a value it computes
|
| 72 |
+
* NaN and the tile collapses to no size at all. Product shots are
|
| 73 |
+
* square-ish, so 1 is the honest default.
|
| 74 |
+
*/
|
| 75 |
function tileDefaults(product) {
|
| 76 |
return {
|
| 77 |
id: productKey(product.sku),
|
|
|
|
| 79 |
};
|
| 80 |
}
|
| 81 |
|
| 82 |
+
/**
|
| 83 |
+
* Give every product the visual prominence of the photograph it sits beside.
|
| 84 |
+
*
|
| 85 |
+
* This is load-bearing rather than cosmetic. relevanceAppearance() dims and
|
| 86 |
+
* blurs any tile whose `score` is not a finite number -- it is written for
|
| 87 |
+
* cosine results -- so a product merged in WITHOUT a score would come out at
|
| 88 |
+
* 14% opacity and blurred, i.e. effectively invisible in the field. Borrowing
|
| 89 |
+
* the neighbouring interior score makes a product look exactly as relevant as
|
| 90 |
+
* the photograph next to it, which is the intended reading.
|
| 91 |
+
*
|
| 92 |
+
* A query that matches products but no interiors at all has no neighbour to
|
| 93 |
+
* copy; those get a descending synthetic score so the strongest hit is the
|
| 94 |
+
* brightest thing on screen.
|
| 95 |
+
*/
|
| 96 |
function assignRelevance(list) {
|
| 97 |
const interiorAt = i => (i >= 0 && i < list.length && typeof list[i].score === 'number' ? i : -1);
|
| 98 |
const floor = list.reduce((min, r) => (Number.isFinite(r.score) ? Math.min(min, r.score) : min), Infinity);
|
|
|
|
| 109 |
return list;
|
| 110 |
}
|
| 111 |
|
| 112 |
+
/**
|
| 113 |
+
* Unwrap a catalogue hit.
|
| 114 |
+
*
|
| 115 |
+
* The worker ranks hits as `{ sku, score, product }`, while a caller that has
|
| 116 |
+
* already normalised them passes the product itself. Both carry a `sku`, so
|
| 117 |
+
* checking `product` FIRST is what stops the wrapper being mistaken for the
|
| 118 |
+
* record: the wrapper has no `images`, and taking it would drop the product.
|
| 119 |
+
*/
|
| 120 |
function unwrap(item) {
|
| 121 |
if (!item || typeof item !== 'object') return null;
|
| 122 |
if (item.product && typeof item.product === 'object') return item.product;
|
| 123 |
return item;
|
| 124 |
}
|
| 125 |
|
| 126 |
+
/**
|
| 127 |
+
* Produce one merged, de-duplicated list for the field.
|
| 128 |
+
*
|
| 129 |
+
* `interiors` are the CLIP-ranked photographs (already sorted, highest first);
|
| 130 |
+
* `products` are the lexically-ranked catalogue hits. Only the ORDER of the
|
| 131 |
+
* products is meaningful -- their scores come from token overlap, so they are
|
| 132 |
+
* not comparable with a cosine and are deliberately never blended into one.
|
| 133 |
+
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
export function mergeResults(interiors, products, options = {}) {
|
| 135 |
const productLimit = options.productLimit ?? PRODUCT_LIMIT;
|
| 136 |
const interiorLimit = options.interiorLimit ?? 60;
|
| 137 |
|
| 138 |
const seen = new Set();
|
| 139 |
const out = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
for (const image of interiors ?? []) {
|
| 141 |
+
if (!image?.id || seen.has(interiorKey(image.id))) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
seen.add(interiorKey(image.id));
|
| 143 |
out.push(image);
|
| 144 |
if (out.length >= interiorLimit) break;
|
| 145 |
}
|
| 146 |
|
| 147 |
const hits = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
for (const item of products ?? []) {
|
| 149 |
const product = unwrap(item);
|
| 150 |
const src = leadImage(product);
|
|
|
|
| 152 |
const key = productKey(product.sku);
|
| 153 |
if (seen.has(key)) continue;
|
| 154 |
seen.add(key);
|
| 155 |
+
hits.push({ ...product, ...tileDefaults(product), src });
|
| 156 |
+
if (hits.length >= productLimit) break;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
if (!hits.length) return out;
|
| 159 |
|
| 160 |
+
/**
|
| 161 |
+
* Walk backwards while splicing. Inserting forwards would shift every later
|
| 162 |
+
* insertion point, so product 5 would land somewhere the loop already passed.
|
| 163 |
+
*/
|
| 164 |
+
for (let i = hits.length - 1; i >= 0; i--) {
|
| 165 |
+
out.splice(Math.min(out.length, (i + 1) * STRIDE), 0, hits[i]);
|
| 166 |
}
|
| 167 |
return assignRelevance(out);
|
| 168 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/neon-frame.css
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* --neon-angle must be REGISTERED to be animatable: an unregistered custom
|
| 3 |
-
* property is not a number the engine can interpolate, so a keyframe from 0deg
|
| 4 |
-
* to 360deg would flip once at the midpoint instead of sweeping.
|
| 5 |
-
*
|
| 6 |
-
* `inherits: false` keeps the angle on the ring itself. The tile is transformed
|
| 7 |
-
* every frame by the field engine, and an inherited animated property would be
|
| 8 |
-
* recomputed against a moving box, which is how the gradient ends up sliding
|
| 9 |
-
* relative to the picture it is supposed to be travelling around.
|
| 10 |
-
*
|
| 11 |
-
* These rules are kept byte-compatible with dist/assets/neon-frame.css, which is
|
| 12 |
-
* what the Space loads today on top of the built app.css. The build therefore
|
| 13 |
-
* reproduces this frame rather than replacing it, and the override file becomes
|
| 14 |
-
* a no-op instead of a divergence between two definitions of the same effect.
|
| 15 |
-
*/
|
| 16 |
-
@property --neon-angle {
|
| 17 |
-
syntax: '<angle>';
|
| 18 |
-
inherits: false;
|
| 19 |
-
initial-value: 0deg;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
.image-tile { --neon: #22f5d0; --neon-soft: #7ef9ff; --neon-run: 2.6s; }
|
| 23 |
-
.image-tile.exact { --relevance-opacity: 1 !important; --relevance-blur: 0px !important; }
|
| 24 |
-
/* A featured tile -- the exact answer, or a photograph just added on
|
| 25 |
-
vai-avatar -- is never dimmed either. Same reason: it is the thing being
|
| 26 |
-
pointed at, and a ring around something at 14% opacity is not a pointer. */
|
| 27 |
-
.image-tile.featured { --relevance-opacity: 1 !important; --relevance-blur: 0px !important; }
|
| 28 |
-
|
| 29 |
-
/* The ring is drawn on `.exact` AND `.featured`. They are not alternatives: the
|
| 30 |
-
brief asks for the running neon on the exact result in every query AND on the
|
| 31 |
-
newest photographs, so both ring. A featured tile that is also the exact
|
| 32 |
-
answer matches both selectors and still renders exactly one ring. */
|
| 33 |
-
.image-tile.exact::before,
|
| 34 |
-
.image-tile.featured::before {
|
| 35 |
-
content: ''; position: absolute; inset: 0; z-index: 2;
|
| 36 |
-
border-radius: 2px;
|
| 37 |
-
/* A 1px gap between the photo edge and the light keeps the frame legible
|
| 38 |
-
against the tile's dark background. */
|
| 39 |
-
padding: 1px;
|
| 40 |
-
/* `from var(--neon-angle)` is the whole effect. The layer itself never moves:
|
| 41 |
-
no transform, so the mask stays welded to the tile's edges and the gradient
|
| 42 |
-
is what sweeps. The stops are a white-hot head, a comet tail fading back
|
| 43 |
-
round to 250deg, and a clear gap before the head returns -- so the eye
|
| 44 |
-
reads one light travelling rather than a ring pulsing in place. */
|
| 45 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 46 |
-
var(--neon) 0deg,
|
| 47 |
-
var(--neon-soft) 38deg,
|
| 48 |
-
#ffffff 70deg,
|
| 49 |
-
var(--neon-soft) 102deg,
|
| 50 |
-
var(--neon) 148deg,
|
| 51 |
-
#22f5d05c 250deg,
|
| 52 |
-
#22f5d000 344deg,
|
| 53 |
-
var(--neon) 360deg);
|
| 54 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 55 |
-
-webkit-mask-composite: xor;
|
| 56 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 57 |
-
mask-composite: exclude;
|
| 58 |
-
/* Any transform here drags the mask off the frame, which is exactly the bug
|
| 59 |
-
this replaced. Stated explicitly so a later edit cannot reintroduce it. */
|
| 60 |
-
transform: none;
|
| 61 |
-
pointer-events: none;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
/* The halo: the same sweep from the same angle, spread wider and blurred, so
|
| 65 |
-
the light blooms off the edge instead of being cut flat against it. A
|
| 66 |
-
pseudo-element can only carry one background, and a neon frame needs both the
|
| 67 |
-
stroke and the glow -- hence two layers, one pseudo-element apart. */
|
| 68 |
-
.image-tile.exact::after,
|
| 69 |
-
.image-tile.featured::after {
|
| 70 |
-
content: ''; position: absolute; inset: 0; z-index: 1;
|
| 71 |
-
border-radius: 2px;
|
| 72 |
-
/* Half the previous padding, halving the ring width. */
|
| 73 |
-
padding: 2.5px;
|
| 74 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 75 |
-
#22f5d000 0deg,
|
| 76 |
-
#22f5d085 62deg,
|
| 77 |
-
#b6fdffc4 92deg,
|
| 78 |
-
#22f5d05e 138deg,
|
| 79 |
-
#22f5d01f 250deg,
|
| 80 |
-
#22f5d000 348deg,
|
| 81 |
-
#22f5d000 360deg);
|
| 82 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 83 |
-
-webkit-mask-composite: xor;
|
| 84 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 85 |
-
mask-composite: exclude;
|
| 86 |
-
filter: blur(4px);
|
| 87 |
-
opacity: .82;
|
| 88 |
-
/* No box-shadow animation. A shadow's shape is the tile's shape, so it
|
| 89 |
-
breathes in place and can never run along the perimeter -- which is why it
|
| 90 |
-
is replaced by the gradient sweep above rather than merely re-timed. */
|
| 91 |
-
box-shadow: none;
|
| 92 |
-
pointer-events: none;
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
/* The head sweeps one full turn clockwise; the halo sweeps the same turn over
|
| 96 |
-
the same duration, so the two re-align every lap instead of drifting apart. */
|
| 97 |
-
@keyframes neon-run {
|
| 98 |
-
from { --neon-angle: 0deg; }
|
| 99 |
-
to { --neon-angle: 360deg; }
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
/*
|
| 103 |
-
* Gate the animation behind @supports at-rule(@property).
|
| 104 |
-
*
|
| 105 |
-
* Without the registration the angle is an untyped token, so `neon-run` would
|
| 106 |
-
* jump from 0deg to 360deg at the halfway point -- the ring would blink rather
|
| 107 |
-
* than run, which is worse than the plain static ring it replaced. Declaring the
|
| 108 |
-
* angle and then animating it unconditionally would put that blink on every
|
| 109 |
-
* browser that has not shipped @property. Browsers outside the gate keep the
|
| 110 |
-
* gradient, which is a lit neon border with the bright head parked at the top.
|
| 111 |
-
*/
|
| 112 |
-
@supports at-rule(@property) {
|
| 113 |
-
.image-tile.exact::before,
|
| 114 |
-
.image-tile.featured::before,
|
| 115 |
-
.image-tile.exact::after,
|
| 116 |
-
.image-tile.featured::after {
|
| 117 |
-
animation: neon-run var(--neon-run) linear infinite;
|
| 118 |
-
}
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
/* A featured tile runs faster and thinner, so the newest photographs are
|
| 122 |
-
distinguishable from an exact hit by pace as well as by size. */
|
| 123 |
-
.image-tile.featured { --neon-run: 2s; }
|
| 124 |
-
.image-tile.featured::before { padding: 1.5px; }
|
| 125 |
-
|
| 126 |
-
/* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
|
| 127 |
-
and this outline marks a correct answer rather than an error. */
|
| 128 |
-
@media (prefers-reduced-motion: reduce) {
|
| 129 |
-
.image-tile.exact::before, .image-tile.exact::after,
|
| 130 |
-
.image-tile.featured::before, .image-tile.featured::after { animation: none; }
|
| 131 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/product-search.js
DELETED
|
@@ -1,312 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Product search, ported from the vai-avatar Space so results match it exactly.
|
| 3 |
-
* =========================================================================
|
| 4 |
-
*
|
| 5 |
-
* vai-avatar ranks its catalogue in `src/vaix-rag.js::scoreProduct()`. That
|
| 6 |
-
* function encodes a lot of hard-won behaviour that a plain token-overlap
|
| 7 |
-
* score does not have, and every rule below is carried over rather than
|
| 8 |
-
* reinvented:
|
| 9 |
-
*
|
| 10 |
-
* * an exact SKU / model / title match must surface FIRST and ideally alone
|
| 11 |
-
* (typing "GP170BD" is a lookup, not a browse);
|
| 12 |
-
* * millimetre sizes are compared NUMERICALLY, because the specs write them
|
| 13 |
-
* inconsistently ("900 mm", "900mm", "W565 x D280 x H650", "Khoang tủ: 900");
|
| 14 |
-
* * material words ("inox 304", "mây", "kính cường lực") are matched across
|
| 15 |
-
* title AND specs;
|
| 16 |
-
* * brand names are worth a large flat bonus;
|
| 17 |
-
* * multi-term matches are rewarded, so covering the whole query beats
|
| 18 |
-
* matching one rare word very hard.
|
| 19 |
-
*
|
| 20 |
-
* ── Why a separate module instead of extending searchCatalogue() ──
|
| 21 |
-
* catalogue.js already ranks products, but it ranks them with weighted token
|
| 22 |
-
* overlap, which is a genuinely different ranking. Dropping vai-avatar's rules
|
| 23 |
-
* into that function would have meant shipping one scorer with two sets of
|
| 24 |
-
* weights, where the older one's prefix bonus and the new one's exact-match
|
| 25 |
-
* bonus both fire and neither is predictable. Keeping them apart means the
|
| 26 |
-
* behaviour below is readable as vai-avatar's behaviour and can be diffed
|
| 27 |
-
* against the original when it changes.
|
| 28 |
-
*
|
| 29 |
-
* The two modules agree on normalisation: catalogue.js exports normalizeText /
|
| 30 |
-
* tokenize, and vai-avatar's `sd()` is the same fold (NFD, strip combining
|
| 31 |
-
* marks, đ -> d, lowercase). Imported rather than reimplemented so the two
|
| 32 |
-
* scorers can never drift on what "máy hút mùi" normalises to.
|
| 33 |
-
*/
|
| 34 |
-
|
| 35 |
-
import { normalizeText, tokenize } from './catalogue.js';
|
| 36 |
-
|
| 37 |
-
/** vai-avatar's `sd()`: fold diacritics so "Bếp từ" and "bep tu" match. */
|
| 38 |
-
const sd = normalizeText;
|
| 39 |
-
|
| 40 |
-
/**
|
| 41 |
-
* vai-avatar's extractTerms(): split on whitespace, drop one-character noise.
|
| 42 |
-
*
|
| 43 |
-
* Deliberately NOT catalogue.js's tokenize(), which additionally applies a
|
| 44 |
-
* Vietnamese stopword list. vai-avatar keeps those words ("tu" is both "từ"
|
| 45 |
-
* and "tủ"), and dropping them here would make this Space disagree with the
|
| 46 |
-
* one whose ranking it is supposed to reproduce.
|
| 47 |
-
*/
|
| 48 |
-
export function extractTerms(query) {
|
| 49 |
-
return sd(query).split(/\s+/).filter(term => term.length > 1);
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
/**
|
| 53 |
-
* Material words worth a bonus, copied from vai-avatar.
|
| 54 |
-
*
|
| 55 |
-
* These are searched in the RAW query (not the folded one) so an accented
|
| 56 |
-
* query like "inox 304" still matches, then folded for the comparison.
|
| 57 |
-
*/
|
| 58 |
-
const MATERIAL_WORDS = [
|
| 59 |
-
'inox 304', 'inox sus304', 'inox304', 'inox', 'sus304', 'sus 304',
|
| 60 |
-
'nan oval', 'nan vuông', 'nan tron', 'nan dẹt', 'nan det',
|
| 61 |
-
'khung nhôm', 'nhôm', 'hợp kim', 'kính', 'gốm', 'đá granite', 'da granite',
|
| 62 |
-
'mây', 'thép', 'gang', 'nhựa abs', 'abs', 'thủy tinh',
|
| 63 |
-
];
|
| 64 |
-
|
| 65 |
-
/** Brand bonuses, copied from vai-avatar. */
|
| 66 |
-
const BRAND_NAMES = [
|
| 67 |
-
'malloca', 'eurogold', 'grob', 'canzy', 'demax', 'sunhouse',
|
| 68 |
-
'kangaroo', 'sharp', 'toshiba', 'panasonic', 'samsung', 'lg',
|
| 69 |
-
'electrolux', 'bosch', 'fischer',
|
| 70 |
-
];
|
| 71 |
-
|
| 72 |
-
/**
|
| 73 |
-
* Synonym pairs folded at query time.
|
| 74 |
-
*
|
| 75 |
-
* "Kệ xoong" and "giá xoong" name the same kitchen rack: the catalogue's
|
| 76 |
-
* Eurogold rows spell it "Giá xoong nồi..." while the query typing it from a
|
| 77 |
-
* shop floor says "kệ". Folding one term onto the other in the NAME comparison
|
| 78 |
-
* is what lets a Eurogold giá-xoong match a kệ-xoong query. Deliberately a
|
| 79 |
-
* one-way fold of a specific pair (not a thesaurus) so "giá" (price) in a
|
| 80 |
-
* price string can never accidentally match "kệ" (rack).
|
| 81 |
-
*/
|
| 82 |
-
const NAME_TERM_SYNONYMS = {
|
| 83 |
-
ke: ['gia'],
|
| 84 |
-
gia: ['ke'],
|
| 85 |
-
};
|
| 86 |
-
|
| 87 |
-
/**
|
| 88 |
-
* Does the folded name carry `term`, or one of its synonyms?
|
| 89 |
-
*
|
| 90 |
-
* The synonym only fires on the NAME field (never brand/category/summary), so
|
| 91 |
-
* a "giá" (price) mention in a spec or description cannot inflate a rack
|
| 92 |
-
* query, and only when the term is one of the rack words we actually fold.
|
| 93 |
-
*/
|
| 94 |
-
function nameHas(nm, term) {
|
| 95 |
-
if (nm.includes(term)) return true;
|
| 96 |
-
const synonyms = NAME_TERM_SYNONYMS[term];
|
| 97 |
-
if (!synonyms) return false;
|
| 98 |
-
for (const alias of synonyms) {
|
| 99 |
-
if (nm.includes(alias)) return true;
|
| 100 |
-
}
|
| 101 |
-
return false;
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
/**
|
| 105 |
-
* Every distinct millimetre value mentioned by a product.
|
| 106 |
-
*
|
| 107 |
-
* Two patterns, because sizes are written two ways in this catalogue: with an
|
| 108 |
-
* explicit unit ("900 mm") and as a bare number next to a dimension letter
|
| 109 |
-
* ("W565 x D280 x H650", "Khoang tủ: 900"). Matching on the NUMBER rather than
|
| 110 |
-
* the surrounding text is what makes "700" find a 700mm rack and not a 7000mm
|
| 111 |
-
* one, nor a rack whose only "700" is in its price.
|
| 112 |
-
*/
|
| 113 |
-
function mmValues(text) {
|
| 114 |
-
const out = new Set();
|
| 115 |
-
const withUnit = /(\d{1,4})\s*mm/gi;
|
| 116 |
-
const withDimension = /[wdh]\s*[:=]?\s*(\d{1,4})/gi;
|
| 117 |
-
let match;
|
| 118 |
-
while ((match = withUnit.exec(text))) out.add(parseInt(match[1], 10));
|
| 119 |
-
while ((match = withDimension.exec(text))) out.add(parseInt(match[1], 10));
|
| 120 |
-
return out;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
/**
|
| 124 |
-
* Pull every field the scorer reads off a catalogue product into folded text.
|
| 125 |
-
*
|
| 126 |
-
* The product shape here is catalogue.js's normaliseProduct() output
|
| 127 |
-
* ({ name, model, brand, category, summary, feats, specs }), where `specs` is
|
| 128 |
-
* an array of [key, value] pairs rather than vai-avatar's plain object. That
|
| 129 |
-
* difference is the only thing the port has to reconcile.
|
| 130 |
-
*/
|
| 131 |
-
function fieldsOf(product) {
|
| 132 |
-
const specsText = sd((product.specs || []).map(([key, value]) => `${key} ${value}`).join(' '));
|
| 133 |
-
return {
|
| 134 |
-
name: sd(product.name || ''),
|
| 135 |
-
sku: sd(product.model || product.sku || ''),
|
| 136 |
-
brand: sd(product.brand || ''),
|
| 137 |
-
category: sd(product.category || ''),
|
| 138 |
-
summary: sd(product.summary || ''),
|
| 139 |
-
feats: sd((product.feats || []).join(' ')),
|
| 140 |
-
specsText,
|
| 141 |
-
};
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
/**
|
| 145 |
-
* Score one product against one query. Ported from vai-avatar's scoreProduct.
|
| 146 |
-
*
|
| 147 |
-
* The bonuses are copied verbatim, including their magnitudes, because the
|
| 148 |
-
* RELATIVE order of the rules is the behaviour: a 1000-point exact SKU must
|
| 149 |
-
* still outrank a 300-point all-terms-in-title, which it only does if the
|
| 150 |
-
* numbers stay as they are.
|
| 151 |
-
*/
|
| 152 |
-
export function scoreProduct(product, query, terms) {
|
| 153 |
-
const { name: nm, sku: sk, brand: br, category: cat, summary, feats, specsText } = fieldsOf(product);
|
| 154 |
-
const rawQuery = sd(String(query || '').trim());
|
| 155 |
-
let score = 0;
|
| 156 |
-
|
| 157 |
-
// ── Exact model / SKU / title ────────────────────────────────────────────
|
| 158 |
-
// Typing a concrete model code is a lookup, so that product must come first.
|
| 159 |
-
// Both sides are compacted so "GP 170 BD" folds onto "gp170bd".
|
| 160 |
-
const compactQuery = rawQuery.replace(/[^a-z0-9]/g, '');
|
| 161 |
-
const compactName = nm.replace(/[^a-z0-9]/g, '');
|
| 162 |
-
const compactSku = sk.replace(/[^a-z0-9]/g, '');
|
| 163 |
-
if (compactQuery.length >= 4) {
|
| 164 |
-
if (compactSku === compactQuery) score += 1000;
|
| 165 |
-
else if (compactName === compactQuery) score += 900;
|
| 166 |
-
else if (compactName.includes(compactQuery) || compactSku.includes(compactQuery)) score += 550;
|
| 167 |
-
// The sku-as-substring tier must not fire for a TRIVIALLY SHORT sku. A
|
| 168 |
-
// query "eurogold" must not award 400 to a Grob product whose sku is
|
| 169 |
-
// "GOLD" (4 chars) just because "eurogold" happens to contain "gold" --
|
| 170 |
-
// that is why brand searches kept surfacing unrelated "gold" items above
|
| 171 |
-
// the Eurogold ones. A sku is a specific identity only when it is 5+
|
| 172 |
-
// characters (or strictly longer than half the query), so require both.
|
| 173 |
-
else if (compactSku.length >= Math.max(5, compactQuery.length / 2) && compactQuery.includes(compactSku)) score += 400;
|
| 174 |
-
}
|
| 175 |
-
// Every meaningful term present in the title is a strong signal too.
|
| 176 |
-
const significantTerms = terms.filter(term => term.length > 1 && !/^\d{2,4}mm/.test(term));
|
| 177 |
-
if (significantTerms.length && significantTerms.every(term => nameHas(nm, term))) score += 300;
|
| 178 |
-
|
| 179 |
-
const all = [nm, br, cat, sd(product.summary || ''), feats, specsText].join(' ');
|
| 180 |
-
|
| 181 |
-
// Note vai-avatar compares the ORIGINAL query here, not the folded one, so an
|
| 182 |
-
// accented query can still match an unaccented field exactly.
|
| 183 |
-
const rawTrimmed = String(query || '').trim();
|
| 184 |
-
if (nm === rawTrimmed) score += 200;
|
| 185 |
-
else if (sk === rawTrimmed) score += 180;
|
| 186 |
-
else if (nm.includes(rawTrimmed)) score += 100;
|
| 187 |
-
else if (sk.includes(rawTrimmed)) score += 80;
|
| 188 |
-
else if (all.includes(rawTrimmed)) score += 50;
|
| 189 |
-
|
| 190 |
-
// ── Size (mm) matching ──────────────────────────────────────────────────
|
| 191 |
-
const productMm = mmValues(`${nm} ${specsText} ${sd(product.summary || '')} ${feats} ${summary}`);
|
| 192 |
-
const queryMm = mmValues(rawTrimmed);
|
| 193 |
-
if ([...queryMm].some(value => productMm.has(value))) {
|
| 194 |
-
if (nm.includes(rawTrimmed)) score += 40;
|
| 195 |
-
else if (nm.split(' ').some(token => /^\d{1,4}mm$/i.test(token))) score += 55;
|
| 196 |
-
else if (specsText !== '') score += 50;
|
| 197 |
-
else score += 40;
|
| 198 |
-
} else if (queryMm.size) {
|
| 199 |
-
score -= 10;
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
// Size tokens written out in the query ("700mm") are worth more than a bare
|
| 203 |
-
// numeric match, because typing the unit means the size is deliberate.
|
| 204 |
-
for (const token of terms.filter(term => /^\d{2,4}\s*mm$/.test(term))) {
|
| 205 |
-
const compact = token.replace(/\s+/g, '');
|
| 206 |
-
if (nm.includes(compact)) score += 60;
|
| 207 |
-
else if (sk.includes(compact)) score += 40;
|
| 208 |
-
else if (all.includes(compact)) score += 25;
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
// ── Material ─────────────────────────────────────────────────────────────
|
| 212 |
-
for (const material of MATERIAL_WORDS) {
|
| 213 |
-
const folded = sd(material);
|
| 214 |
-
if (rawQuery.includes(folded) && (nm.includes(folded) || specsText.includes(folded) || sd(product.summary || '').includes(folded))) {
|
| 215 |
-
score += 30;
|
| 216 |
-
break;
|
| 217 |
-
}
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
// ── Per-term scoring ─────────────────────────────────────────────────────
|
| 221 |
-
let titleHits = 0;
|
| 222 |
-
const meaningful = terms.filter(term => term.length > 1 && !/^\d{2,4}\s*mm$/.test(term));
|
| 223 |
-
for (const term of meaningful) {
|
| 224 |
-
if (nameHas(nm, term)) { score += 16; titleHits++; }
|
| 225 |
-
else if (sk.includes(term)) score += 12;
|
| 226 |
-
else if (br.includes(term)) score += 8;
|
| 227 |
-
else if (cat.includes(term)) score += 10;
|
| 228 |
-
else if (all.includes(term)) score += 3;
|
| 229 |
-
}
|
| 230 |
-
// Covering more of the query beats matching one word hard.
|
| 231 |
-
if (titleHits >= 2) score += 35;
|
| 232 |
-
else if (titleHits === 1 && meaningful.length >= 2) score += 10;
|
| 233 |
-
for (const term of meaningful) if (cat.includes(term)) score += 6;
|
| 234 |
-
|
| 235 |
-
for (const brand of BRAND_NAMES) {
|
| 236 |
-
if (rawQuery.includes(brand) && br.includes(brand)) score += 50;
|
| 237 |
-
}
|
| 238 |
-
return score;
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
-
/**
|
| 242 |
-
* Rank a catalogue against a query, vai-avatar's way.
|
| 243 |
-
*
|
| 244 |
-
* `items` is the Map that catalogue.js hands the worker. Returns the same
|
| 245 |
-
* `{ sku, score, product }` shape searchCatalogue returns, so merge.js needs no
|
| 246 |
-
* change to accept it.
|
| 247 |
-
*
|
| 248 |
-
* Scored results only: a product nobody matched is not a weak result, it is
|
| 249 |
-
* not a result, and returning it would put an unrelated photograph in the
|
| 250 |
-
* field next to the query that was typed.
|
| 251 |
-
*/
|
| 252 |
-
export function searchLikeAvatar(text, items, limit = 24) {
|
| 253 |
-
const terms = extractTerms(text);
|
| 254 |
-
if (!terms.length || !items?.size) return [];
|
| 255 |
-
const scored = [];
|
| 256 |
-
for (const product of items.values()) {
|
| 257 |
-
const score = scoreProduct(product, String(text || '').trim(), terms);
|
| 258 |
-
// A score below 24 is a single weak token scrape, not an answer: it would
|
| 259 |
-
// paint an unrelated photograph next to the query typed. Kept in sync with
|
| 260 |
-
// the merge.js productFloor default (20) plus the two strongest weak tiers
|
| 261 |
-
// (16 title + 3+10 hit bonus) — anything under it is noise.
|
| 262 |
-
if (score >= 24) scored.push({ sku: product.sku, score, product });
|
| 263 |
-
}
|
| 264 |
-
scored.sort((a, b) => b.score - a.score || a.sku.localeCompare(b.sku));
|
| 265 |
-
|
| 266 |
-
/**
|
| 267 |
-
* Break equal-score ties so one brand-family does not swallow the field.
|
| 268 |
-
*
|
| 269 |
-
* "kệ xoong" matches ~70 racks across Grob/Hafele/Eurogold at the same
|
| 270 |
-
* score, and the plain sku tie-break above lets the alphabetically-first
|
| 271 |
-
* family (Grob's C1..., Hafele's 5...) take every slot -- Eurogold's E...
|
| 272 |
-
* rows never surface even though they are equally the answer. Round-robin
|
| 273 |
-
* WITHIN each equal-score group: one product per brand, repeated, so the
|
| 274 |
-
* field shows every family that actually matched, while a genuinely higher
|
| 275 |
-
* score still outranks a lower one.
|
| 276 |
-
*/
|
| 277 |
-
if (scored.length > 1) {
|
| 278 |
-
const byScore = new Map();
|
| 279 |
-
for (const hit of scored) {
|
| 280 |
-
if (!byScore.has(hit.score)) byScore.set(hit.score, []);
|
| 281 |
-
byScore.get(hit.score).push(hit);
|
| 282 |
-
}
|
| 283 |
-
const ordered = Array.from(byScore.keys()).sort((a, b) => b - a);
|
| 284 |
-
const roundRobin = [];
|
| 285 |
-
for (const score of ordered) {
|
| 286 |
-
const group = byScore.get(score);
|
| 287 |
-
const grouped = new Map();
|
| 288 |
-
for (const hit of group) {
|
| 289 |
-
const brand = (hit.product?.brand || '').toLowerCase() || 'unknown';
|
| 290 |
-
if (!grouped.has(brand)) grouped.set(brand, []);
|
| 291 |
-
grouped.get(brand).push(hit);
|
| 292 |
-
}
|
| 293 |
-
let progress = true;
|
| 294 |
-
while (progress) {
|
| 295 |
-
progress = false;
|
| 296 |
-
for (const entries of grouped.values()) {
|
| 297 |
-
if (entries.length) {
|
| 298 |
-
roundRobin.push(entries.shift());
|
| 299 |
-
progress = true;
|
| 300 |
-
}
|
| 301 |
-
}
|
| 302 |
-
}
|
| 303 |
-
}
|
| 304 |
-
scored.length = 0;
|
| 305 |
-
scored.push(...roundRobin);
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
return scored.slice(0, limit);
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
/** Exported for the suggestion-style callers that only need folded tokens. */
|
| 312 |
-
export { tokenize };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|